From cac705d90759536b512765e697d306815aed55cd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 07:37:50 +0200 Subject: [PATCH 01/56] feat(agent): define the provider replay checkpoint contract (stage 1) Stage 1 of 4 for provider-native replay (signed thinking blocks on resume). Defines and tests the runtime event/consumer contract without enabling emission: - New src/agent/runtime/provider-replay.ts: ProviderReplayCheckpoint types mirroring the API's AgentRunProviderReplayCheckpointPayloadSchema, strict fail-closed validation (registry error provider-replay-checkpoint-invalid, never echoing block material), the AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT event factory, and an emission gate that is off by default (VERYFRONT_ENABLE_PROVIDER_REPLAY_CHECKPOINT_EMISSION); nothing calls the emission path in production and a test pins gate-off emitting nothing. - Consumer chain copying serverResolvedToolExposureCheckpoint: runtime-request-config -> chat-preparation -> chat-runtime-contract -> default-chat-runtime (__vfProviderReplayCheckpoints) -> applied at the runtime loop entry, attaching providerMetadata.anthropic.rawAssistantMessages through the existing WeakMap side channel so the Anthropic request builder's existing raw-replay consumption reconstructs the assistant turn with original block ordering and signatures. - Signed blocks stay off the AG-UI boundary by design: encoder test pins that reasoning events never carry signature/redactedData; validation errors are tested to never echo signed material. Emission stays disabled; API-side run-scoped append/read (stage 2) and enabling emission (stage 4) are follow-ups. Refs veryfront/veryfront-issue-inbox#522 --- docs/api-reference/veryfront/agent.md | 100 ++-- docs/api-reference/veryfront/errors.md | 279 ++++----- .../src/anthropic-request-builder.test.ts | 67 +++ src/agent/ag-ui/encoder.test.ts | 52 ++ src/agent/hosted/chat-preparation.test.ts | 24 + src/agent/hosted/chat-preparation.ts | 11 + src/agent/hosted/chat-runtime-contract.ts | 3 + .../hosted/cloud-agent-chat-execution.ts | 9 +- src/agent/hosted/default-chat-runtime.ts | 1 + .../hosted/runtime-request-config.test.ts | 94 ++- src/agent/hosted/runtime-request-config.ts | 21 + src/agent/runtime/index.ts | 10 + src/agent/runtime/provider-replay.test.ts | 544 ++++++++++++++++++ src/agent/runtime/provider-replay.ts | 357 ++++++++++++ src/agent/runtime/runtime-tool-config.test.ts | 41 +- src/agent/runtime/runtime-tool-config.ts | 19 + src/errors/error-registry.test.ts | 1 + src/errors/error-registry/agent.ts | 17 + src/errors/index.ts | 1 + 19 files changed, 1459 insertions(+), 192 deletions(-) create mode 100644 src/agent/runtime/provider-replay.test.ts create mode 100644 src/agent/runtime/provider-replay.ts diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index aec9d0a3d0..6f42940c0c 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -655,10 +655,10 @@ Input delivered to a hosted agent-service detached execution callback. | `createConversationRunEventQueueController` | Create conversation run event queue controller. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable.ts#L854) | | `createConversationRunMirror` | Create conversation run mirror. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-mirror.ts#L95) | | `createConversationRunStreamMirror` | Create conversation run stream mirror. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-stream-mirror.ts#L25) | -| `createDefaultAgentServiceChatRuntime` | Create default hosted chat runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/default-chat-runtime.ts#L488) | +| `createDefaultAgentServiceChatRuntime` | Create default hosted chat runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/default-chat-runtime.ts#L489) | | `createDefaultAgentServiceInvokeAgentTool` | Create default hosted invoke agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/default-invoke-agent-tool.ts#L881) | | `createDefaultAgentServiceProjectSteeringRefresh` | Create default hosted project steering refresh. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/default-project-steering-refresh.ts#L217) | -| `createDefaultHostedChatRuntime` | Create default hosted chat runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/default-chat-runtime.ts#L488) | +| `createDefaultHostedChatRuntime` | Create default hosted chat runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/default-chat-runtime.ts#L489) | | `createDefaultHostedInvokeAgentTool` | Create default hosted invoke agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/default-invoke-agent-tool.ts#L881) | | `createDefaultHostedProjectSteeringRefresh` | Create default hosted project steering refresh. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/default-project-steering-refresh.ts#L217) | | `createDefaultResearchRunArtifactMirrorHandler` | Handler for create default research run artifact mirror. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/default-research-artifact-support.ts#L362) | @@ -789,8 +789,8 @@ Input delivered to a hosted agent-service detached execution callback. | `getConversationRunEventJsonByteLength` | Return conversation run event JSON byte length. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-event-normalization.ts#L27) | | `getEmptyHostedFinalizedMessageTerminalError` | Error shape for get empty hosted finalized message terminal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-terminal-error.ts#L113) | | `getForkRuntimeAllowedToolNames` | Return fork runtime allowed tool names. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-native-tool-inventory.ts#L90) | -| `getForwardedHostedModelId` | Return forwarded hosted model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L90) | -| `getForwardedHostedRuntimeOverrides` | Return forwarded hosted runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L100) | +| `getForwardedHostedModelId` | Return forwarded hosted model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L111) | +| `getForwardedHostedRuntimeOverrides` | Return forwarded hosted runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L121) | | `getHostedChildWrittenArtifactPath` | Return hosted child written artifact path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-artifact-support.ts#L112) | | `getHostedMirroredAbortErrorText` | Return hosted mirrored abort error text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L163) | | `getHostedServiceTokenFromRequest` | Request payload for get hosted service token from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L234) | @@ -863,8 +863,8 @@ Input delivered to a hosted agent-service detached execution callback. | `normalizeConversationRunEvents` | Normalizes conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-event-normalization.ts#L95) | | `normalizeEncodedConversationRunEvents` | Normalizes encoded conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-events.ts#L456) | | `normalizeHostedChildArtifactPath` | Normalizes hosted child artifact path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-artifact-support.ts#L133) | -| `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L299) | -| `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L299) | +| `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L304) | +| `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L304) | | `normalizeRuntimeSkillReferencePath` | Normalizes and bounds a portable runtime skill reference path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1011) | | `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L551) | | `parseAgentServiceConfig` | Configuration used by parse agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L159) | @@ -893,9 +893,9 @@ Input delivered to a hosted agent-service detached execution callback. | `persistConversationUserMessage` | Message shape for persist conversation user. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L211) | | `persistLatestConversationUserMessage` | Message shape for persist latest conversation user. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L277) | | `prepareAgentRuntimeMessagesFromUiMessages` | Prepare agent runtime messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-preparation.ts#L34) | -| `prepareAgentServiceChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L497) | -| `prepareAgentServiceChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L369) | -| `prepareAgentServiceChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L630) | +| `prepareAgentServiceChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L507) | +| `prepareAgentServiceChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L374) | +| `prepareAgentServiceChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L641) | | `prepareAgentServiceConversationRootRunContext` | Context for prepare hosted conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L129) | | `prepareConversationRootRunContext` | Context for prepare conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-context.ts#L128) | | `prepareConversationRootRunLifecycle` | Prepare conversation root run lifecycle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L42) | @@ -906,9 +906,9 @@ Input delivered to a hosted agent-service detached execution callback. | `prepareDefaultHostedChildForkSandboxToolSources` | Prepare default hosted child fork sandbox tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-tool-sources.ts#L197) | | `prepareDefaultHostedChildForkToolAssembly` | Prepare default hosted child fork tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-requested-tools.ts#L375) | | `prepareDefaultHostedChildForkToolSources` | Prepare default hosted child fork tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-tool-sources.ts#L88) | -| `prepareHostedChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L497) | -| `prepareHostedChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L369) | -| `prepareHostedChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L630) | +| `prepareHostedChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L507) | +| `prepareHostedChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L374) | +| `prepareHostedChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L641) | | `prepareHostedChatRuntimeToolAssembly` | Prepare hosted chat runtime tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L559) | | `prepareHostedChildForkRuntimeStepMessages` | Prepare hosted child fork runtime step messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L138) | | `prepareHostedConversationRootRunContext` | Context for prepare hosted conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L129) | @@ -937,8 +937,8 @@ Input delivered to a hosted agent-service detached execution callback. | `resolveHostedChildStreamWatchdogState` | State for resolve hosted child stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-stream-watchdog.ts#L33) | | `resolveHostedChildTerminalErrorCode` | Resolves a code is a hosted child terminal error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-status.ts#L91) | | `resolveHostedDurableRunSetupErrorResponse` | Response payload for resolve hosted durable run setup error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/durable-chat-run-start.ts#L122) | -| `resolveHostedRuntimeRequestConfig` | Configuration used by resolve hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L190) | -| `resolveHostedRuntimeThinkingOverride` | Resolves hosted runtime thinking override. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L128) | +| `resolveHostedRuntimeRequestConfig` | Configuration used by resolve hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L211) | +| `resolveHostedRuntimeThinkingOverride` | Resolves hosted runtime thinking override. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L149) | | `resolveNodeAgentServiceTelemetryConfig` | Configuration used by resolve node agent service telemetry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L379) | | `resolveNodeHostedAgentServiceTelemetryConfig` | Configuration used by resolve node hosted agent service telemetry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L315) | | `resolveRuntimeAgentDefinitionsDir` | Resolves runtime agent definitions dir. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition-files.ts#L96) | @@ -1019,7 +1019,7 @@ Input delivered to a hosted agent-service detached execution callback. | Name | Description | Source | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `AgentRuntime` | Implement agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/index.ts#L1082) | +| `AgentRuntime` | Implement agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/index.ts#L1084) | | `AgentRuntimeMessageConversionError` | Error shape for agent runtime message conversion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L138) | | `AgentServiceAuthError` | Error shape for hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L14) | | `AppendConversationRunEventsError` | Error shape for append conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L4) | @@ -1090,16 +1090,16 @@ Input delivered to a hosted agent-service detached execution callback. | `AgentServiceChatProjectAccessError` | Error shape for hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L53) | | `AgentServiceChatProjectAccessResult` | Result returned from hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L60) | | `AgentServiceChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L47) | -| `AgentServiceChatRuntimeAgent` | Public API contract for hosted chat runtime agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L86) | -| `AgentServiceChatRuntimeCreationOptions` | Options accepted by hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L119) | -| `AgentServiceChatRuntimeCreationResult` | Result returned from hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L93) | -| `AgentServiceChatRuntimeFinishPart` | Public API contract for hosted chat runtime finish part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L16) | -| `AgentServiceChatRuntimeOnFinishEvent` | Event emitted for hosted chat runtime on finish. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L53) | -| `AgentServiceChatRuntimeProjectSteering` | Public API contract for hosted chat runtime project steering. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L101) | -| `AgentServiceChatRuntimeStreamInput` | Input payload for hosted chat runtime stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L72) | -| `AgentServiceChatRuntimeStreamResult` | Result returned from hosted chat runtime stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L78) | +| `AgentServiceChatRuntimeAgent` | Public API contract for hosted chat runtime agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L87) | +| `AgentServiceChatRuntimeCreationOptions` | Options accepted by hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L120) | +| `AgentServiceChatRuntimeCreationResult` | Result returned from hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L94) | +| `AgentServiceChatRuntimeFinishPart` | Public API contract for hosted chat runtime finish part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L17) | +| `AgentServiceChatRuntimeOnFinishEvent` | Event emitted for hosted chat runtime on finish. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L54) | +| `AgentServiceChatRuntimeProjectSteering` | Public API contract for hosted chat runtime project steering. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L102) | +| `AgentServiceChatRuntimeStreamInput` | Input payload for hosted chat runtime stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L73) | +| `AgentServiceChatRuntimeStreamResult` | Result returned from hosted chat runtime stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L79) | | `AgentServiceChatRuntimeToolAssemblyResult` | Result returned from hosted chat runtime tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L72) | -| `AgentServiceChatRuntimeToUiMessageStreamOptions` | Options accepted by hosted chat runtime to UI message stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L62) | +| `AgentServiceChatRuntimeToUiMessageStreamOptions` | Options accepted by hosted chat runtime to UI message stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L63) | | `AgentServiceChildChunkMirror` | Public API contract for hosted child chunk mirror. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L5) | | `AgentServiceChildMirrorContext` | Context for hosted child mirror. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L16) | | `AgentServiceChildMirrorPart` | Public API contract for hosted child mirror part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L86) | @@ -1457,9 +1457,9 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedAgentServiceStreamExecutionInput` | Input payload for hosted agent service stream execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/routes.ts#L66) | | `HostedAgUiChatForwardedConfig` | Configuration used by hosted AG-UI chat forwarded. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L44) | | `HostedChatExecutionLifecycleAdapter` | Public API contract for hosted chat execution lifecycle adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-lifecycle-types.ts#L5) | -| `HostedChatExecutionPreparationInput` | Input payload for hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L237) | -| `HostedChatExecutionPreparationResult` | Result returned from hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L283) | -| `HostedChatExecutionPreparationRootRunOptions` | Options accepted by hosted chat execution preparation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L216) | +| `HostedChatExecutionPreparationInput` | Input payload for hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L240) | +| `HostedChatExecutionPreparationResult` | Result returned from hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L288) | +| `HostedChatExecutionPreparationRootRunOptions` | Options accepted by hosted chat execution preparation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L219) | | `HostedChatExecutionRootStreamWatchdog` | Public API contract for hosted chat execution root stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L96) | | `HostedChatExecutionRunContext` | Context for hosted chat execution run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L90) | | `HostedChatExecutionRuntime` | Public API contract for hosted chat execution runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L77) | @@ -1470,26 +1470,26 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedChatRequest` | Request payload for hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L468) | | `HostedChatRequestInput` | Input payload for hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L470) | | `HostedChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L47) | -| `HostedChatRuntimeAgent` | Public API contract for hosted chat runtime agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L86) | +| `HostedChatRuntimeAgent` | Public API contract for hosted chat runtime agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L87) | | `HostedChatRuntimeAgentAdapterInput` | Input payload for hosted chat runtime agent adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-agent-adapter.ts#L25) | | `HostedChatRuntimeAgentAdapterRunner` | Public API contract for hosted chat runtime agent adapter runner. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-agent-adapter.ts#L14) | | `HostedChatRuntimeAgentAdapterWarning` | Public API contract for hosted chat runtime agent adapter warning. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-agent-adapter.ts#L19) | | `HostedChatRuntimeAllowedToolNames` | Public API contract for hosted chat runtime allowed tool names. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L64) | -| `HostedChatRuntimeCreationOptions` | Options accepted by hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L119) | -| `HostedChatRuntimeCreationPreparationInput` | Input payload for hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L106) | -| `HostedChatRuntimeCreationPreparationResult` | Result returned from hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L148) | -| `HostedChatRuntimeCreationResult` | Result returned from hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L93) | -| `HostedChatRuntimeFinishPart` | Public API contract for hosted chat runtime finish part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L16) | -| `HostedChatRuntimeInstructionsInput` | Input payload for hosted chat runtime instructions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L95) | -| `HostedChatRuntimeOnFinishEvent` | Event emitted for hosted chat runtime on finish. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L53) | -| `HostedChatRuntimePreparationRootRunContext` | Context for hosted chat runtime preparation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L79) | -| `HostedChatRuntimePreparationSteering` | Public API contract for hosted chat runtime preparation steering. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L89) | -| `HostedChatRuntimeProjectSteering` | Public API contract for hosted chat runtime project steering. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L101) | -| `HostedChatRuntimeStreamInput` | Input payload for hosted chat runtime stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L72) | -| `HostedChatRuntimeStreamResult` | Result returned from hosted chat runtime stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L78) | +| `HostedChatRuntimeCreationOptions` | Options accepted by hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L120) | +| `HostedChatRuntimeCreationPreparationInput` | Input payload for hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L107) | +| `HostedChatRuntimeCreationPreparationResult` | Result returned from hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L151) | +| `HostedChatRuntimeCreationResult` | Result returned from hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L94) | +| `HostedChatRuntimeFinishPart` | Public API contract for hosted chat runtime finish part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L17) | +| `HostedChatRuntimeInstructionsInput` | Input payload for hosted chat runtime instructions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L96) | +| `HostedChatRuntimeOnFinishEvent` | Event emitted for hosted chat runtime on finish. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L54) | +| `HostedChatRuntimePreparationRootRunContext` | Context for hosted chat runtime preparation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L80) | +| `HostedChatRuntimePreparationSteering` | Public API contract for hosted chat runtime preparation steering. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L90) | +| `HostedChatRuntimeProjectSteering` | Public API contract for hosted chat runtime project steering. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L102) | +| `HostedChatRuntimeStreamInput` | Input payload for hosted chat runtime stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L73) | +| `HostedChatRuntimeStreamResult` | Result returned from hosted chat runtime stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L79) | | `HostedChatRuntimeToolAssemblyContext` | Context for hosted chat runtime tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L50) | | `HostedChatRuntimeToolAssemblyResult` | Result returned from hosted chat runtime tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L72) | -| `HostedChatRuntimeToUiMessageStreamOptions` | Options accepted by hosted chat runtime to UI message stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L62) | +| `HostedChatRuntimeToUiMessageStreamOptions` | Options accepted by hosted chat runtime to UI message stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L63) | | `HostedChildChunkMirror` | Public API contract for hosted child chunk mirror. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L5) | | `HostedChildConversationBodyInput` | Input payload for hosted child conversation body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-bootstrap.ts#L6) | | `HostedChildExecutionLifecycleOptions` | Options accepted by hosted child execution lifecycle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-lifecycle.ts#L107) | @@ -1597,8 +1597,8 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedResponseStreamWriter` | Public API contract for hosted response stream writer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/response-stream.ts#L4) | | `HostedRootRunLifecycleRuntimeAdapter` | Public API contract for hosted root run lifecycle runtime adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/agent-run-lifecycle.ts#L128) | | `HostedRunEventWriterCapability` | Opaque authority for appending events to one exact hosted run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-run-event-writer-token.ts#L105) | -| `HostedRuntimeRequestConfigAgent` | Public API contract for hosted runtime request config agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L24) | -| `HostedRuntimeRequestConfigRequest` | Request payload for hosted runtime request config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L18) | +| `HostedRuntimeRequestConfigAgent` | Public API contract for hosted runtime request config agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L28) | +| `HostedRuntimeRequestConfigRequest` | Request payload for hosted runtime request config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L22) | | `HostedRuntimeSourceBindingError` | Stable control-plane error returned when a request cannot run on this service snapshot. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-source-binding.ts#L9) | | `HostedRuntimeSourceIdentity` | Immutable project source identity served by a standalone agent-service process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-source-binding.ts#L4) | | `HostedRuntimeStateResolverContext` | Context for hosted runtime state resolver. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-state-resolver.ts#L18) | @@ -1673,11 +1673,11 @@ Input delivered to a hosted agent-service detached execution callback. | `NodeHostedAgentServiceTelemetryProcessTarget` | Public API contract for node hosted agent service telemetry process target. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L72) | | `NodeVeryfrontCloudAgentServiceMcpServer` | Public API contract for node Veryfront Cloud agent service MCP server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/veryfront-cloud-agent-service.ts#L68) | | `NodeVeryfrontCloudAgentServiceOptions` | Options accepted by node Veryfront Cloud agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/veryfront-cloud-agent-service.ts#L85) | -| `NodeVeryfrontCloudAgentServicePreparedExecution` | Full type of a prepared cloud agent chat execution, ready to stream or detach. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-agent-chat-execution.ts#L78) | +| `NodeVeryfrontCloudAgentServicePreparedExecution` | Full type of a prepared cloud agent chat execution, ready to stream or detach. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-agent-chat-execution.ts#L81) | | `NodeVeryfrontCloudAgentServiceProcessTarget` | Public API contract for node Veryfront Cloud agent service process target. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/veryfront-cloud-agent-service.ts#L57) | -| `NormalizedAgentServiceChatRequest` | Request payload for normalized hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L56) | +| `NormalizedAgentServiceChatRequest` | Request payload for normalized hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L57) | | `NormalizedAgentServiceContract` | Public API contract for normalized agent service contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/definition.ts#L114) | -| `NormalizedHostedChatRequest` | Request payload for normalized hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L56) | +| `NormalizedHostedChatRequest` | Request payload for normalized hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L57) | | `OpenToolCalls` | Public API contract for open tool calls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L110) | | `ParseAgentServiceChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L103) | | `ParseAgUiSseResponseOptions` | Options for `parseAgUiSseResponse()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/sse-parser.ts#L50) | @@ -1692,7 +1692,7 @@ Input delivered to a hosted agent-service detached execution callback. | `ParseRuntimeAgentRunInvocationHostedChatRequestOptions` | Options accepted when parsing a signed control-plane runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L117) | | `PersistConversationUserMessageFailure` | Public API contract for persist conversation user message failure. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L70) | | `PrepareAgentRuntimeMessagesFromUiMessagesOptions` | Options accepted by prepare agent runtime messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-preparation.ts#L22) | -| `PrepareAgentServiceChatRuntimeMessagesOptions` | Options accepted by prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L63) | +| `PrepareAgentServiceChatRuntimeMessagesOptions` | Options accepted by prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L64) | | `PrepareAgentServiceConversationRootRunContextInput` | Input for hosted root-run preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L86) | | `PrepareConversationRootRunLifecycleOptions` | Options accepted by prepare conversation root run lifecycle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L27) | | `PreparedAgentServiceChatExecution` | Public API contract for prepared hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/prepared-chat-execution.ts#L13) | @@ -1705,7 +1705,7 @@ Input delivered to a hosted agent-service detached execution callback. | `PreparedHostedChatExecutionDetachedInput` | Input payload for prepared hosted chat execution detached. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/prepared-chat-execution.ts#L64) | | `PreparedHostedChatExecutionRuntimeOptions` | Options accepted by prepared hosted chat execution runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/prepared-chat-execution.ts#L34) | | `PreparedHostedChatExecutionStreamInput` | Input payload for prepared hosted chat execution stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/prepared-chat-execution.ts#L56) | -| `PrepareHostedChatRuntimeMessagesOptions` | Options accepted by prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L63) | +| `PrepareHostedChatRuntimeMessagesOptions` | Options accepted by prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L64) | | `PrepareHostedChatRuntimeToolAssemblyInput` | Input payload for prepare hosted chat runtime tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L90) | | `PrepareHostedChildForkRuntimeStepMessagesInput` | Input payload for prepare hosted child fork runtime step messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L31) | | `PrepareHostedConversationRootRunContextInput` | Input for hosted root-run preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L86) | @@ -1735,11 +1735,11 @@ Input delivered to a hosted agent-service detached execution callback. | `ResolveConversationHostedTerminalStateInput` | Input payload for resolve conversation hosted terminal state. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/hosted-terminal.ts#L30) | | `ResolvedAgentConfig` | Configuration used by resolved agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/types.ts#L307) | | `ResolvedAgentServiceRegistrationInput` | Input payload for resolved agent service registration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/registration.ts#L29) | -| `ResolvedHostedRuntimeRequestConfig` | Configuration used by resolved hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L48) | +| `ResolvedHostedRuntimeRequestConfig` | Configuration used by resolved hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L52) | | `ResolvedModelTransport` | Public API contract for resolved model transport. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/types.ts#L326) | | `ResolvedRuntimeState` | State for resolved runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/types.ts#L352) | | `ResolveHostedChildForkRuntimeConfigInput` | Input payload for resolve hosted child fork runtime config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-tool-input.ts#L221) | -| `ResolveHostedRuntimeRequestConfigInput` | Input payload for resolve hosted runtime request config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L38) | +| `ResolveHostedRuntimeRequestConfigInput` | Input payload for resolve hosted runtime request config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L42) | | `ResolveNodeAgentServiceTelemetryConfigOptions` | Options accepted by resolve node agent service telemetry config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L62) | | `ResolveNodeHostedAgentServiceTelemetryConfigOptions` | Options accepted by resolve node hosted agent service telemetry config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L54) | | `ResolveRuntimeAgentDefinitionsDirInput` | Input payload for resolve runtime agent definitions dir. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition-files.ts#L12) | diff --git a/docs/api-reference/veryfront/errors.md b/docs/api-reference/veryfront/errors.md index 19a91e4d4f..f35ec36c1d 100644 --- a/docs/api-reference/veryfront/errors.md +++ b/docs/api-reference/veryfront/errors.md @@ -37,145 +37,146 @@ throw INVALID_WIDGET.create({ detail: "The widget id is malformed." }); ### Components -| Name | Description | Source | -| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `AGENT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L3) | -| `AGENT_INTENT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L27) | -| `AGENT_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L11) | -| `AGENT_TIMEOUT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L19) | -| `ALREADY_EXISTS` | Writing would replace something that is already there. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L58) | -| `API_CLIENT_ERROR` | API client request/response errors (replaces VeryfrontAPIError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L93) | -| `API_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L52) | -| `API_ROUTE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L43) | -| `ASSET_OPTIMIZATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L51) | -| `AUTHENTICATION_REQUIRED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L11) | -| `BRANCH_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L117) | -| `BUILD_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/build-errors.ts#L4) | -| `BUILD_FAILED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L3) | -| `BUNDLE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L11) | -| `CACHE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L20) | -| `CACHE_INVARIANT_VIOLATION` | Cache path invariant violations (replaces CacheInvariantError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L111) | -| `CACHE_PATH_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L76) | -| `CIRCUIT_BREAKER_OPEN` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L68) | -| `CIRCULAR_DEPENDENCY` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L19) | -| `CLIENT_BOUNDARY_VIOLATION` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L3) | -| `CLIENT_ONLY_IN_SERVER` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L19) | -| `COMPILATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L75) | -| `COMPONENT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L28) | -| `CONFIG_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/config-errors.ts#L4) | -| `CONFIG_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L12) | -| `CONFIG_NOT_DEPLOYABLE` | The project's configuration file uses a construct Veryfront Cloud's configuration evaluator can never accept, so the release would answer 500 to every request. Raised before a release is created; the detail names the file, the change that makes the project deployable, and the line when the evaluator located the construct it refused. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L132) | -| `CONFIG_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L3) | -| `CONFIG_PARSE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L20) | -| `CONFIG_TYPE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L37) | -| `CONFIG_VALIDATION_ERROR` | Schema-level config validation (e.g. Zod schema mismatch at runtime) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L29) | -| `CONFIG_VALIDATION_FAILED` | Config file validation failures (replaces ConfigValidationError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L62) | -| `CORS_CONFIG_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L53) | -| `COST_LIMIT_EXCEEDED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L43) | -| `DEFAULT_MODEL_CREDENTIAL_MISMATCH` | The default model's provider has no credential while a different provider does. Resolution stays deterministic rather than silently substituting whichever key happens to be present on this machine. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L73) | -| `DEPENDENCY_MISSING` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L35) | -| `DEPLOYMENT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L3) | -| `DEPLOYMENT_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/deployment-errors.ts#L4) | -| `DEPLOYMENT_VERIFICATION_TIMEOUT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L68) | -| `DEV_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/dev-errors.ts#L4) | -| `DEV_SERVER_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/dev.ts#L11) | -| `DURABLE_RUN_EVENT_PERSISTENCE_FAILED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L59) | -| `DYNAMIC_ROUTE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L27) | -| `ENV_VAR_MISSING` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L19) | -| `ENVIRONMENT_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L35) | -| `ENVIRONMENT_NOT_ROUTABLE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L43) | -| `ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/index.ts#L31) | -| `ERROR_OVERLAY_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/dev.ts#L27) | -| `ERROR_REGISTRY` | Central registry mapping every error slug to its definition. Assembled from the per-category registry fragments. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry.ts#L39) | -| `ERROR_SOLUTIONS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/user-friendly/error-catalog.ts#L6) | -| `ESM_CACHE_INIT_FAILED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L19) | -| `FALLBACK_EXHAUSTED` | Both primary and fallback operations failed (replaces FallbackExecutionError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L129) | -| `FAST_REFRESH_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/dev.ts#L19) | -| `FILE_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L27) | -| `FILE_WATCH_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L28) | -| `GENERAL_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/general-errors.ts#L4) | -| `HMR_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/dev.ts#L3) | -| `HYDRATION_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L3) | -| `IMPORT_MAP_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L45) | -| `IMPORT_RESOLUTION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L11) | -| `INITIALIZATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L75) | -| `INPUT_VALIDATION_FAILED` | HTTP request input validation failures (replaces ValidationError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L101) | -| `INVALID_ARGUMENT` | A value the caller supplied is not acceptable: a CLI flag, a positional argument, a config field, or a function argument. Exit code 2 is the CLI's "invalid usage" code, so a script can tell a typo from a failed run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L48) | -| `INVALID_IMPORT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L27) | -| `INVALID_ROUTE_FILE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L11) | -| `INVALID_USE_CLIENT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L27) | -| `INVALID_USE_SERVER` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L35) | -| `LAYOUT_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L36) | -| `LOCAL_INTEGRATION_CONFIG_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L70) | -| `LOCAL_INTEGRATION_CREDENTIAL_UNAVAILABLE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L68) | -| `LOCAL_INTEGRATION_CREDENTIALS_MISSING` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L78) | -| `LOCAL_INTEGRATION_REQUEST_FAILED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L76) | -| `LOCAL_INTEGRATION_REQUEST_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L59) | -| `LOCAL_INTEGRATION_RESPONSE_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L84) | -| `LOCKFILE_FORMAT_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L51) | -| `LOCKFILE_READ_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L59) | -| `MARKDOWN_COMPILE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L43) | -| `MDX_COMPILE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L35) | -| `MIDDLEWARE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L60) | -| `MODULE_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/module-errors.ts#L4) | -| `MODULE_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L3) | -| `NESTED_CWD_SCOPE` | A scope that owns the process working directory was opened inside another one. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L118) | -| `NETWORK_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L84) | -| `NOT_SUPPORTED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L83) | -| `ORCHESTRATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L35) | -| `PAGE_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L44) | -| `PERMISSION_DENIED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L19) | -| `PLATFORM_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L11) | -| `PORT_IN_USE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L3) | -| `PREVIEW_HOSTNAME_TOO_LONG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L109) | -| `PROBLEM_JSON_CONTENT_TYPE` | Content-Type header for RFC 9457 responses | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/http-error.ts#L32) | -| `PRODUCTION_BUILD_REQUIRED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L27) | -| `PROJECT_EXECUTION_UNAVAILABLE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L52) | -| `PROJECT_SOURCE_EMPTY` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L109) | -| `PUSH_CONFLICT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L84) | -| `PUSH_RECEIPT_MISSING` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L76) | -| `RAG_STORE_CORRUPT` | Persisted RAG index is malformed or failed structural validation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L138) | -| `RAG_STORE_UNAVAILABLE` | A persisted RAG index operation could not be completed safely. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L147) | -| `REDIRECT_DESTINATION_NOT_ALLOWED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L19) | -| `RELEASE_BUILD_TIMEOUT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L60) | -| `RELEASE_MISSING_VERSION` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L52) | -| `RELEASE_NOT_FOUND` | Production domain resolved but no active release found | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L120) | -| `RENDER_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L11) | -| `REQUEST_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L36) | -| `RESOURCE_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L35) | -| `ROUTE_CONFLICT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L3) | -| `ROUTE_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/route-errors.ts#L4) | -| `ROUTE_HANDLER_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L19) | -| `ROUTE_PARAMS_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L35) | -| `RSC_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/rsc-errors.ts#L4) | -| `RSC_PAYLOAD_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L43) | -| `RUNTIME_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/runtime-errors.ts#L4) | -| `SCHEDULE_CONFIG_INVALID` | Schedule definition validation failures (required fields, cron, concurrencyPolicy, target) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L96) | -| `SECURITY_VIOLATION` | Path traversal / secure-fs violations (replaces SecurityError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L92) | -| `SEMAPHORE_TIMEOUT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L60) | -| `SERVER_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/server-errors.ts#L4) | -| `SERVER_ONLY_IN_CLIENT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L11) | -| `SERVER_START_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L12) | -| `SERVICE_OVERLOADED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L44) | -| `SOURCE_DIGEST_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L101) | -| `SOURCE_MAP_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/dev.ts#L35) | -| `SOURCE_SNAPSHOT_FRESHNESS_UNAVAILABLE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L155) | -| `SOURCEMAP_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L67) | -| `SSG_GENERATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L59) | -| `SSR_OUTPUT_LIMIT_EXCEEDED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L51) | -| `SYNC_STATE_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L93) | -| `TEMPLATE_NOT_FOUND` | `veryfront init --template ` (and `npm create veryfront -- --template`) was given a name that is not in the starter catalog. The detail carries the list of valid names so a wrong guess is self-correcting. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L120) | -| `TIMEOUT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L67) | -| `TOKEN_STORAGE_ERROR` | Token storage adapter failures (replaces TokenStorageError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L102) | -| `TOOL_ID_CONFLICT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L51) | -| `TRIGGER_CONFIG_INVALID` | Trigger ID format and input serialization validation failures | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L106) | -| `TRIGGER_EXECUTION_FAILED` | Trigger target task or workflow failed during local run | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L102) | -| `TRIGGER_NOT_SUPPORTED` | Trigger target type is not supported in the current runtime context | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L111) | -| `TRIGGER_TARGET_NOT_FOUND` | Trigger target (task or workflow) not found during local run | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L93) | -| `TYPESCRIPT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L27) | -| `UNKNOWN_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L3) | -| `VERSION_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L43) | -| `WEBHOOK_CONFIG_INVALID` | Webhook definition validation failures (required fields, target, eventFilter) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L87) | +| Name | Description | Source | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `AGENT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L3) | +| `AGENT_INTENT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L27) | +| `AGENT_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L11) | +| `AGENT_TIMEOUT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L19) | +| `ALREADY_EXISTS` | Writing would replace something that is already there. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L58) | +| `API_CLIENT_ERROR` | API client request/response errors (replaces VeryfrontAPIError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L93) | +| `API_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L52) | +| `API_ROUTE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L43) | +| `ASSET_OPTIMIZATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L51) | +| `AUTHENTICATION_REQUIRED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L11) | +| `BRANCH_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L117) | +| `BUILD_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/build-errors.ts#L4) | +| `BUILD_FAILED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L3) | +| `BUNDLE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L11) | +| `CACHE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L20) | +| `CACHE_INVARIANT_VIOLATION` | Cache path invariant violations (replaces CacheInvariantError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L111) | +| `CACHE_PATH_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L76) | +| `CIRCUIT_BREAKER_OPEN` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L68) | +| `CIRCULAR_DEPENDENCY` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L19) | +| `CLIENT_BOUNDARY_VIOLATION` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L3) | +| `CLIENT_ONLY_IN_SERVER` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L19) | +| `COMPILATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L75) | +| `COMPONENT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L28) | +| `CONFIG_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/config-errors.ts#L4) | +| `CONFIG_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L12) | +| `CONFIG_NOT_DEPLOYABLE` | The project's configuration file uses a construct Veryfront Cloud's configuration evaluator can never accept, so the release would answer 500 to every request. Raised before a release is created; the detail names the file, the change that makes the project deployable, and the line when the evaluator located the construct it refused. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L132) | +| `CONFIG_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L3) | +| `CONFIG_PARSE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L20) | +| `CONFIG_TYPE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L37) | +| `CONFIG_VALIDATION_ERROR` | Schema-level config validation (e.g. Zod schema mismatch at runtime) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L29) | +| `CONFIG_VALIDATION_FAILED` | Config file validation failures (replaces ConfigValidationError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L62) | +| `CORS_CONFIG_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L53) | +| `COST_LIMIT_EXCEEDED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L43) | +| `DEFAULT_MODEL_CREDENTIAL_MISMATCH` | The default model's provider has no credential while a different provider does. Resolution stays deterministic rather than silently substituting whichever key happens to be present on this machine. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L73) | +| `DEPENDENCY_MISSING` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L35) | +| `DEPLOYMENT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L3) | +| `DEPLOYMENT_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/deployment-errors.ts#L4) | +| `DEPLOYMENT_VERIFICATION_TIMEOUT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L68) | +| `DEV_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/dev-errors.ts#L4) | +| `DEV_SERVER_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/dev.ts#L11) | +| `DURABLE_RUN_EVENT_PERSISTENCE_FAILED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L59) | +| `DYNAMIC_ROUTE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L27) | +| `ENV_VAR_MISSING` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L19) | +| `ENVIRONMENT_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L35) | +| `ENVIRONMENT_NOT_ROUTABLE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L43) | +| `ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/index.ts#L31) | +| `ERROR_OVERLAY_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/dev.ts#L27) | +| `ERROR_REGISTRY` | Central registry mapping every error slug to its definition. Assembled from the per-category registry fragments. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry.ts#L39) | +| `ERROR_SOLUTIONS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/user-friendly/error-catalog.ts#L6) | +| `ESM_CACHE_INIT_FAILED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L19) | +| `FALLBACK_EXHAUSTED` | Both primary and fallback operations failed (replaces FallbackExecutionError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L129) | +| `FAST_REFRESH_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/dev.ts#L19) | +| `FILE_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L27) | +| `FILE_WATCH_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L28) | +| `GENERAL_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/general-errors.ts#L4) | +| `HMR_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/dev.ts#L3) | +| `HYDRATION_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L3) | +| `IMPORT_MAP_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L45) | +| `IMPORT_RESOLUTION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L11) | +| `INITIALIZATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L75) | +| `INPUT_VALIDATION_FAILED` | HTTP request input validation failures (replaces ValidationError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L101) | +| `INVALID_ARGUMENT` | A value the caller supplied is not acceptable: a CLI flag, a positional argument, a config field, or a function argument. Exit code 2 is the CLI's "invalid usage" code, so a script can tell a typo from a failed run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L48) | +| `INVALID_IMPORT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L27) | +| `INVALID_ROUTE_FILE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L11) | +| `INVALID_USE_CLIENT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L27) | +| `INVALID_USE_SERVER` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L35) | +| `LAYOUT_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L36) | +| `LOCAL_INTEGRATION_CONFIG_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L70) | +| `LOCAL_INTEGRATION_CREDENTIAL_UNAVAILABLE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L68) | +| `LOCAL_INTEGRATION_CREDENTIALS_MISSING` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L78) | +| `LOCAL_INTEGRATION_REQUEST_FAILED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L76) | +| `LOCAL_INTEGRATION_REQUEST_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L59) | +| `LOCAL_INTEGRATION_RESPONSE_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L84) | +| `LOCKFILE_FORMAT_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L51) | +| `LOCKFILE_READ_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L59) | +| `MARKDOWN_COMPILE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L43) | +| `MDX_COMPILE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L35) | +| `MIDDLEWARE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L60) | +| `MODULE_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/module-errors.ts#L4) | +| `MODULE_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L3) | +| `NESTED_CWD_SCOPE` | A scope that owns the process working directory was opened inside another one. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L118) | +| `NETWORK_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L84) | +| `NOT_SUPPORTED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L83) | +| `ORCHESTRATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L35) | +| `PAGE_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L44) | +| `PERMISSION_DENIED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L19) | +| `PLATFORM_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L11) | +| `PORT_IN_USE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L3) | +| `PREVIEW_HOSTNAME_TOO_LONG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L109) | +| `PROBLEM_JSON_CONTENT_TYPE` | Content-Type header for RFC 9457 responses | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/http-error.ts#L32) | +| `PRODUCTION_BUILD_REQUIRED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L27) | +| `PROJECT_EXECUTION_UNAVAILABLE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L52) | +| `PROJECT_SOURCE_EMPTY` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L109) | +| `PROVIDER_REPLAY_CHECKPOINT_INVALID` | Provider replay checkpoint state failed validation at a consumer boundary. Replay state guards provider protocol correctness on resume, so malformed or unappliable state fails closed instead of degrading into an unsigned replay. Details never include checkpoint contents: provider blocks carry signed reasoning material that must stay out of logs and error text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L88) | +| `PUSH_CONFLICT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L84) | +| `PUSH_RECEIPT_MISSING` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L76) | +| `RAG_STORE_CORRUPT` | Persisted RAG index is malformed or failed structural validation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L138) | +| `RAG_STORE_UNAVAILABLE` | A persisted RAG index operation could not be completed safely. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L147) | +| `REDIRECT_DESTINATION_NOT_ALLOWED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L19) | +| `RELEASE_BUILD_TIMEOUT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L60) | +| `RELEASE_MISSING_VERSION` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L52) | +| `RELEASE_NOT_FOUND` | Production domain resolved but no active release found | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L120) | +| `RENDER_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L11) | +| `REQUEST_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L36) | +| `RESOURCE_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L35) | +| `ROUTE_CONFLICT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L3) | +| `ROUTE_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/route-errors.ts#L4) | +| `ROUTE_HANDLER_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L19) | +| `ROUTE_PARAMS_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L35) | +| `RSC_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/rsc-errors.ts#L4) | +| `RSC_PAYLOAD_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L43) | +| `RUNTIME_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/runtime-errors.ts#L4) | +| `SCHEDULE_CONFIG_INVALID` | Schedule definition validation failures (required fields, cron, concurrencyPolicy, target) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L96) | +| `SECURITY_VIOLATION` | Path traversal / secure-fs violations (replaces SecurityError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L92) | +| `SEMAPHORE_TIMEOUT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L60) | +| `SERVER_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/server-errors.ts#L4) | +| `SERVER_ONLY_IN_CLIENT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L11) | +| `SERVER_START_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L12) | +| `SERVICE_OVERLOADED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L44) | +| `SOURCE_DIGEST_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L101) | +| `SOURCE_MAP_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/dev.ts#L35) | +| `SOURCE_SNAPSHOT_FRESHNESS_UNAVAILABLE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L155) | +| `SOURCEMAP_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L67) | +| `SSG_GENERATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L59) | +| `SSR_OUTPUT_LIMIT_EXCEEDED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L51) | +| `SYNC_STATE_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L93) | +| `TEMPLATE_NOT_FOUND` | `veryfront init --template ` (and `npm create veryfront -- --template`) was given a name that is not in the starter catalog. The detail carries the list of valid names so a wrong guess is self-correcting. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L120) | +| `TIMEOUT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L67) | +| `TOKEN_STORAGE_ERROR` | Token storage adapter failures (replaces TokenStorageError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L102) | +| `TOOL_ID_CONFLICT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L51) | +| `TRIGGER_CONFIG_INVALID` | Trigger ID format and input serialization validation failures | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L106) | +| `TRIGGER_EXECUTION_FAILED` | Trigger target task or workflow failed during local run | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L102) | +| `TRIGGER_NOT_SUPPORTED` | Trigger target type is not supported in the current runtime context | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L111) | +| `TRIGGER_TARGET_NOT_FOUND` | Trigger target (task or workflow) not found during local run | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L93) | +| `TYPESCRIPT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L27) | +| `UNKNOWN_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L3) | +| `VERSION_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L43) | +| `WEBHOOK_CONFIG_INVALID` | Webhook definition validation failures (required fields, target, eventFilter) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L87) | ### Functions diff --git a/extensions/ext-llm-anthropic/src/anthropic-request-builder.test.ts b/extensions/ext-llm-anthropic/src/anthropic-request-builder.test.ts index ea5318e58e..5b942d22d8 100644 --- a/extensions/ext-llm-anthropic/src/anthropic-request-builder.test.ts +++ b/extensions/ext-llm-anthropic/src/anthropic-request-builder.test.ts @@ -2988,6 +2988,73 @@ describe("ext-llm-anthropic/anthropic-request-builder", () => { }]); }); + it("replays checkpoint-shaped signed thinking blocks byte-exact on the resume turn", () => { + // Shape delivered by a provider replay checkpoint (veryfront-issue-inbox#522): + // on Opus 4.7+/Sonnet 5/Fable 5 the thinking text comes back empty and the + // signature is the only carrier, so the rebuilt turn must echo the raw + // blocks unchanged beside the tool_use they accompanied. + const rawAssistantContent = [{ + type: "thinking", + thinking: "", + signature: "sig-checkpoint-secret", + }, { + type: "redacted_thinking", + data: "redacted-checkpoint-secret", + }, { + type: "tool_use", + id: "local_lookup_2", + name: "local_lookup", + input: { query: "resume" }, + }]; + const prompt = [{ + role: "user", + content: [{ type: "text", text: "Look this up" }], + }, { + role: "assistant", + content: [{ + type: "reasoning", + text: "", + }, { + type: "tool-call", + toolCallId: "local_lookup_2", + toolName: "local_lookup", + input: { query: "resume" }, + }], + providerMetadata: { anthropic: { rawAssistantMessages: [rawAssistantContent] } }, + }, { + role: "tool", + content: [{ + type: "tool-result", + toolCallId: "local_lookup_2", + toolName: "local_lookup", + output: { type: "json", value: { matches: 2 } }, + }], + }] as unknown as RuntimePromptMessage[]; + + const body = buildAnthropicMessagesRequest( + "claude-sonnet-4-6", + "anthropic", + { prompt, maxOutputTokens: 64 }, + false, + createWarningCollector(), + ); + + assertEquals(body.messages, [{ + role: "user", + content: [{ type: "text", text: "Look this up" }], + }, { + role: "assistant", + content: rawAssistantContent, + }, { + role: "user", + content: [{ + type: "tool_result", + tool_use_id: "local_lookup_2", + content: '{"matches":2}', + }], + }]); + }); + it("rejects raw client tool tampering when canonical content survives", () => { const canonicalCall = { type: "tool-call" as const, diff --git a/src/agent/ag-ui/encoder.test.ts b/src/agent/ag-ui/encoder.test.ts index 872d993c3e..3ce084d0ad 100644 --- a/src/agent/ag-ui/encoder.test.ts +++ b/src/agent/ag-ui/encoder.test.ts @@ -1075,4 +1075,56 @@ describe("agent/ag-ui-encoder tool-input lifecycle", () => { // emitted it, so a normal tool failure must not emit a second. assertEquals(events.map((entry) => entry.event), ["ToolCallResult"]); }); + + it("never carries reasoning signatures or redacted data into AG-UI payloads", () => { + // Signed reasoning blocks are replayed through the private provider replay + // channel (veryfront-issue-inbox#522); the AG-UI transcript boundary must + // stay signature-free so signed material cannot reach display text. + const state = createAgUiEncoderState({ nowMs: null, epochMs: null }); + const signature = "sig-secret-3c2b1a"; + const redactedData = "redacted-secret-4d5e6f"; + + const events = [ + ...mapRuntimeStreamEventToAgUiEvents(state, { + type: "message-start", + messageId: "assistant-signed", + }), + ...mapRuntimeStreamEventToAgUiEvents(state, { + type: "reasoning-start", + id: "reasoning-signed", + }), + ...mapRuntimeStreamEventToAgUiEvents(state, { + type: "reasoning-delta", + id: "reasoning-signed", + delta: "", + }), + ...mapRuntimeStreamEventToAgUiEvents(state, { + type: "reasoning-end", + id: "reasoning-signed", + signature, + redactedData, + }), + ]; + + assertEquals( + events.map((entry) => entry.event), + ["ReasoningMessageStart", "ReasoningMessageContent", "ReasoningMessageEnd"], + "reasoning lifecycle only", + ); + const serialized = JSON.stringify(events); + assertEquals(serialized.includes(signature), false, "signature never leaves the runtime"); + assertEquals( + serialized.includes(redactedData), + false, + "redacted data never leaves the runtime", + ); + assertEquals( + events.at(-1), + { + event: "ReasoningMessageEnd", + payload: { messageId: "assistant-signed:reasoning:0" }, + }, + "reasoning end carries only its message anchor", + ); + }); }); diff --git a/src/agent/hosted/chat-preparation.test.ts b/src/agent/hosted/chat-preparation.test.ts index 8ba074976e..6128a48fd7 100644 --- a/src/agent/hosted/chat-preparation.test.ts +++ b/src/agent/hosted/chat-preparation.test.ts @@ -301,6 +301,18 @@ Deno.test("prepareHostedChatRuntimeCreationOptions builds runtime options from r version: 1, loadedToolNames: ["get_release"], }, + serverResolvedProviderReplayCheckpoints: [{ + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "thinking", thinking: "", signature: "sig-threading" }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }], resolveModelId: (modelId) => modelId ? `resolved:${modelId}` : undefined, resolveModelThinking: (modelId) => modelId ? { enabled: true, budgetTokens: 1234 } : undefined, fetchSteering: (input) => { @@ -369,6 +381,18 @@ Deno.test("prepareHostedChatRuntimeCreationOptions builds runtime options from r version: 1, loadedToolNames: ["get_release"], }, + serverResolvedProviderReplayCheckpoints: [{ + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "thinking", thinking: "", signature: "sig-threading" }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }], liveProjectSteering: { agent: { id: "agent-1", diff --git a/src/agent/hosted/chat-preparation.ts b/src/agent/hosted/chat-preparation.ts index 09fff6b6bb..7016978d57 100644 --- a/src/agent/hosted/chat-preparation.ts +++ b/src/agent/hosted/chat-preparation.ts @@ -40,6 +40,7 @@ import { } from "./context-budget-manager.ts"; import { findSubmittedFormInputResult } from "./form-input-tool.ts"; import type { ToolExposureCheckpoint } from "../runtime/tool-exposure.ts"; +import type { ProviderReplayCheckpoint } from "../runtime/provider-replay.ts"; import { createToolExposureCheckpointEvent, TOOL_SEARCH_TOOL_NAME, @@ -126,6 +127,8 @@ export type HostedChatRuntimeCreationPreparationInput = rootRunContext?: HostedChatRuntimePreparationRootRunContext; /** Trusted checkpoint resolved after hosted service authentication. */ serverResolvedToolExposureCheckpoint?: ToolExposureCheckpoint; + /** Verified provider replay state resolved by the authenticated server. */ + serverResolvedProviderReplayCheckpoints?: readonly ProviderReplayCheckpoint[]; /** Verified integration tool grant for this run, resolved by the control plane. */ serverResolvedIntegrationToolNames?: readonly string[]; /** Service-owned authorization ceiling for Framework host tools. */ @@ -273,6 +276,8 @@ export type HostedChatExecutionPreparationInput< contextBudget?: HostedChatContextBudgetOptions; /** Trusted checkpoint resolved by the authenticated hosted service. */ serverResolvedToolExposureCheckpoint?: ToolExposureCheckpoint; + /** Verified provider replay state resolved by the authenticated server. */ + serverResolvedProviderReplayCheckpoints?: readonly ProviderReplayCheckpoint[]; /** Verified integration tool grant for this run, resolved by the control plane. */ serverResolvedIntegrationToolNames?: readonly string[]; /** Service-owned authorization ceiling for Framework host tools. */ @@ -438,6 +443,11 @@ export async function prepareHostedChatRuntimeCreationOptions< ...(input.serverResolvedToolExposureCheckpoint ? { serverResolvedToolExposureCheckpoint: input.serverResolvedToolExposureCheckpoint } : {}), + ...(input.serverResolvedProviderReplayCheckpoints?.length + ? { + serverResolvedProviderReplayCheckpoints: input.serverResolvedProviderReplayCheckpoints, + } + : {}), ...(input.serverResolvedIntegrationToolNames?.length ? { serverResolvedIntegrationToolNames: input.serverResolvedIntegrationToolNames } : {}), @@ -555,6 +565,7 @@ export async function prepareHostedChatExecution< fetchSteering: input.fetchSteering, buildInstructions: input.buildInstructions, serverResolvedToolExposureCheckpoint: input.serverResolvedToolExposureCheckpoint, + serverResolvedProviderReplayCheckpoints: input.serverResolvedProviderReplayCheckpoints, serverResolvedIntegrationToolNames: input.serverResolvedIntegrationToolNames, hostToolPolicy: input.hostToolPolicy, }); diff --git a/src/agent/hosted/chat-runtime-contract.ts b/src/agent/hosted/chat-runtime-contract.ts index d05946f29b..954cd7f1ba 100644 --- a/src/agent/hosted/chat-runtime-contract.ts +++ b/src/agent/hosted/chat-runtime-contract.ts @@ -9,6 +9,7 @@ import type { AgentRuntimeMessage } from "../runtime/message-adapter.ts"; import type { ConversationRunEvent } from "../conversation/run-events.ts"; import type { RuntimeClientProfile } from "../runtime/client-profile.ts"; import type { ToolExposureCheckpoint } from "../runtime/tool-exposure.ts"; +import type { ProviderReplayCheckpoint } from "../runtime/provider-replay.ts"; import type { RuntimeSkillDefinition } from "../runtime/skill-metadata.ts"; import type { ResolvedSkillSelectorPolicy } from "#veryfront/skill/selector.ts"; @@ -164,6 +165,8 @@ export type HostedChatRuntimeCreationOptions { + const checkpoint = { + version: 1 as const, + messageId: "assistant-message-1", + provider: "anthropic" as const, + providerBlocks: [{ + type: "provider-block" as const, + provider: "anthropic" as const, + block: { type: "thinking", thinking: "", signature: "sig-secret-verified" }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + assertEquals( + getServerResolvedProviderReplayCheckpoints({ + serverResolvedProviderReplayCheckpoints: [checkpoint], + }, true), + [checkpoint], + "verified envelope delivers parsed checkpoints", + ); + assertEquals( + getServerResolvedProviderReplayCheckpoints({ + serverResolvedProviderReplayCheckpoints: [checkpoint], + }, false), + undefined, + "unverified envelope never yields replay state", + ); + assertEquals( + getServerResolvedProviderReplayCheckpoints({}, true), + undefined, + "absent replay state resolves to undefined", + ); + assertEquals( + getServerResolvedProviderReplayCheckpoints(undefined, true), + undefined, + "absent forwarded props resolve to undefined", + ); +}); + +it("server-resolved provider replay checkpoints fail explicitly on forgery-shaped state", () => { + const validBlock = { + type: "provider-block" as const, + provider: "anthropic" as const, + block: { type: "thinking", thinking: "", signature: "sig-secret-forgery" }, + }; + const forgeries: unknown[] = [ + "not-an-array", + [{ version: 1 }], + [{ + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [validBlock], + providerBlockPositions: [0], + totalPartCount: 1, + injected: "extra", + }], + [{ + version: 1, + messageId: "assistant-message-1", + provider: "forged-provider", + providerBlocks: [validBlock], + providerBlockPositions: [0], + totalPartCount: 1, + }], + [{ + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [validBlock], + providerBlockPositions: [1], + totalPartCount: 1, + }], + ]; + for (const forgery of forgeries) { + const error = assertThrows(() => + getServerResolvedProviderReplayCheckpoints({ + serverResolvedProviderReplayCheckpoints: forgery, + }, true) + ); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "provider-replay-checkpoint-invalid", "registry slug"); + assertEquals( + `${error.message}${JSON.stringify(error.context ?? {})}`.includes("sig-secret-forgery"), + false, + "rejection never echoes signed block material", + ); + } +}); + it("ordinary hosted request resolution ignores forwarded private tool exposure state", () => { const result = resolveHostedRuntimeRequestConfig({ agentConfig: { diff --git a/src/agent/hosted/runtime-request-config.ts b/src/agent/hosted/runtime-request-config.ts index 6533a0ba5a..14dac1bdbf 100644 --- a/src/agent/hosted/runtime-request-config.ts +++ b/src/agent/hosted/runtime-request-config.ts @@ -13,6 +13,10 @@ import { isSupportedToolExposureCheckpointVersion, type ToolExposureCheckpoint, } from "../runtime/tool-exposure.ts"; +import { + parseServerResolvedProviderReplayCheckpoints, + type ProviderReplayCheckpoint, +} from "../runtime/provider-replay.ts"; /** Request payload for hosted runtime request config. */ export type HostedRuntimeRequestConfigRequest = Pick< @@ -86,6 +90,23 @@ export function getServerResolvedToolExposureCheckpoint( }; } +/** + * Read the provider replay checkpoints resolved by the authenticated server. + * + * Unverified envelopes never yield replay state. A verified envelope carrying + * malformed state fails explicitly instead of degrading into an unsigned + * replay, unlike the tool exposure checkpoint above whose absence is safe. + */ +export function getServerResolvedProviderReplayCheckpoints( + forwardedProps: Record | undefined, + serverEnvelopeVerified: boolean, +): ProviderReplayCheckpoint[] | undefined { + if (!serverEnvelopeVerified) return undefined; + const value = forwardedProps?.serverResolvedProviderReplayCheckpoints; + if (value === undefined) return undefined; + return parseServerResolvedProviderReplayCheckpoints(value); +} + /** Return forwarded hosted model ID. */ export function getForwardedHostedModelId( forwardedProps: Record | undefined, diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index f3e81eb9db..1e32a806b2 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -101,6 +101,7 @@ import { markRuntimeGeneratedUserMessage } from "./runtime-message-origin.ts"; import { getRuntimeAllowedRemoteTools, getRuntimeForwardedIntegrationToolDefs, + getRuntimeProviderReplayCheckpoints, getRuntimeProviderTools, getRuntimeSourceIntegrationPolicy, getRuntimeToolExposureCheckpoint, @@ -109,6 +110,7 @@ import { resolveRuntimeToolLoading, type RuntimeToolFilterConfig, } from "./runtime-tool-config.ts"; +import { applyProviderReplayCheckpointsToMessages } from "./provider-replay.ts"; import { applySourceIntegrationPolicy, type SourceIntegrationPolicyManifest, @@ -1496,6 +1498,10 @@ export class AgentRuntime { const toolCalls: ToolCall[] = []; const currentMessages = [...messages]; + applyProviderReplayCheckpointsToMessages( + currentMessages, + getRuntimeProviderReplayCheckpoints(this.config), + ); const totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; if (!supportsToolCalling && this.config.tools) { @@ -2159,6 +2165,10 @@ export class AgentRuntime { const toolCalls: ToolCall[] = []; const currentMessages = [...messages]; + applyProviderReplayCheckpointsToMessages( + currentMessages, + getRuntimeProviderReplayCheckpoints(this.config), + ); const totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; if (!supportsToolCalling && this.config.tools) { diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts new file mode 100644 index 0000000000..bf690b1fb4 --- /dev/null +++ b/src/agent/runtime/provider-replay.test.ts @@ -0,0 +1,544 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertInstanceOf, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { VeryfrontError } from "#veryfront/errors"; +import type { Message } from "../types.ts"; +import { + AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE, + applyProviderReplayCheckpointsToMessages, + createAnthropicProviderReplayCheckpoint, + createProviderReplayCheckpointEvent, + isProviderReplayCheckpointEmissionEnabled, + maybeCreateProviderReplayCheckpointEvent, + parseProviderReplayCheckpoint, + parseServerResolvedProviderReplayCheckpoints, + PROVIDER_REPLAY_CHECKPOINT_EMISSION_ENV_FLAG, + type ProviderReplayCheckpoint, +} from "./provider-replay.ts"; +import { attachProviderMetadata, readAttachedProviderMetadata } from "./provider-metadata.ts"; +import { convertToTextGenerationRuntimeMessages } from "./text-generation-runtime-message-converter.ts"; + +const SIGNATURE = "sig-secret-9f8e7d6c5b4a"; +const REDACTED_DATA = "redacted-secret-0a1b2c3d"; + +function createValidCheckpoint(): ProviderReplayCheckpoint { + return { + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [ + { + type: "provider-block", + provider: "anthropic", + block: { type: "thinking", thinking: "", signature: SIGNATURE }, + }, + { + type: "provider-block", + provider: "anthropic", + block: { type: "redacted_thinking", data: REDACTED_DATA }, + }, + { + type: "provider-block", + provider: "anthropic", + block: { + type: "tool_use", + id: "call-1", + name: "lookup", + input: { query: "veryfront" }, + }, + }, + ], + providerBlockPositions: [0, 1, 2], + totalPartCount: 3, + }; +} + +function assertProviderReplayError(operation: () => unknown): VeryfrontError { + const error = assertThrows(operation); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "provider-replay-checkpoint-invalid", "registry slug"); + return error; +} + +function assertInvalidCheckpoint(mutate: (checkpoint: Record) => void): void { + const checkpoint = createValidCheckpoint() as unknown as Record; + mutate(checkpoint); + const error = assertProviderReplayError(() => parseProviderReplayCheckpoint(checkpoint)); + const serialized = JSON.stringify({ + message: error.message, + detail: error.detail, + context: error.context, + }); + assertEquals( + serialized.includes(SIGNATURE), + false, + "validation errors must never echo signed block material", + ); + assertEquals( + serialized.includes(REDACTED_DATA), + false, + "validation errors must never echo redacted block material", + ); +} + +function createAssistantMessage(id: string): Message { + return { + id, + role: "assistant", + parts: [ + { type: "reasoning", text: "" }, + { type: "text", text: "Looking that up." }, + ], + timestamp: 1, + } as Message; +} + +describe("agent/runtime/provider-replay", () => { + describe("parseProviderReplayCheckpoint", () => { + it("should round-trip a valid multi-block checkpoint preserving block order", () => { + const checkpoint = createValidCheckpoint(); + const parsed = parseProviderReplayCheckpoint(checkpoint); + assertEquals(parsed, checkpoint, "parsed checkpoint equals input"); + assertEquals( + parsed.providerBlocks.map((block) => block.block.type), + ["thinking", "redacted_thinking", "tool_use"], + "block order preserved", + ); + }); + + it("should accept optional elapsedMs and emittedAt stamps", () => { + const checkpoint = { ...createValidCheckpoint(), elapsedMs: 12.5, emittedAt: 1756400000000 }; + const parsed = parseProviderReplayCheckpoint(checkpoint); + assertEquals(parsed.elapsedMs, 12.5, "elapsedMs preserved"); + assertEquals(parsed.emittedAt, 1756400000000, "emittedAt preserved"); + }); + + it("should reject a non-record payload", () => { + for (const value of [null, undefined, "checkpoint", 1, [], true]) { + assertProviderReplayError(() => parseProviderReplayCheckpoint(value)); + } + }); + + it("should reject an unsupported version", () => { + assertInvalidCheckpoint((checkpoint) => { + checkpoint.version = 2; + }); + }); + + it("should reject an invalid messageId", () => { + assertInvalidCheckpoint((checkpoint) => { + checkpoint.messageId = ""; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.messageId = "m".repeat(257); + }); + assertInvalidCheckpoint((checkpoint) => { + delete checkpoint.messageId; + }); + }); + + it("should reject an unknown provider", () => { + assertInvalidCheckpoint((checkpoint) => { + checkpoint.provider = "unknown-provider"; + }); + }); + + it("should reject unknown keys smuggled onto the payload", () => { + assertInvalidCheckpoint((checkpoint) => { + checkpoint.injected = "value"; + }); + }); + + it("should reject empty, oversized, and malformed provider block lists", () => { + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlocks = []; + checkpoint.providerBlockPositions = []; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlocks = Array.from({ length: 101 }, () => ({ + type: "provider-block", + provider: "anthropic", + block: { type: "thinking", thinking: "", signature: SIGNATURE }, + })); + checkpoint.providerBlockPositions = Array.from({ length: 101 }, (_, index) => index); + checkpoint.totalPartCount = 101; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlocks = [{ provider: "anthropic", block: {} }]; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlocks = [{ + type: "provider-block", + provider: "anthropic", + block: "not-a-record", + }]; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlocks = [{ + type: "provider-block", + provider: "anthropic", + block: {}, + injected: true, + }]; + }); + }); + + it("should reject a block whose provider differs from the checkpoint provider", () => { + assertInvalidCheckpoint((checkpoint) => { + (checkpoint.providerBlocks as Array>)[0]!.provider = + "openai-responses"; + }); + }); + + it("should reject misaligned or non-increasing block positions", () => { + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlockPositions = [0, 1]; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlockPositions = [0, 2, 1]; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlockPositions = [0, 1, 1]; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlockPositions = [-1, 0, 1]; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlockPositions = [0, 1, 2.5]; + }); + }); + + it("should reject positions and totals that disagree with totalPartCount", () => { + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlockPositions = [0, 1, 3]; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.totalPartCount = 2; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.totalPartCount = 10_001; + checkpoint.providerBlockPositions = [0, 1, 2]; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.totalPartCount = 0; + }); + }); + }); + + describe("parseServerResolvedProviderReplayCheckpoints", () => { + it("should parse a valid checkpoint array", () => { + const checkpoint = createValidCheckpoint(); + assertEquals( + parseServerResolvedProviderReplayCheckpoints([checkpoint]), + [checkpoint], + "array round trip", + ); + }); + + it("should reject a non-array delivery", () => { + assertProviderReplayError(() => + parseServerResolvedProviderReplayCheckpoints(createValidCheckpoint()) + ); + }); + + it("should reject the whole delivery when any entry is forged", () => { + assertProviderReplayError(() => + parseServerResolvedProviderReplayCheckpoints([ + createValidCheckpoint(), + { ...createValidCheckpoint(), provider: "forged" }, + ]) + ); + }); + }); + + describe("createProviderReplayCheckpointEvent", () => { + it("should stamp the durable event type onto a valid checkpoint", () => { + const checkpoint = createValidCheckpoint(); + const event = createProviderReplayCheckpointEvent(checkpoint); + assertEquals(event.type, AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE, "event type"); + const { type: _type, ...payload } = event; + assertEquals(payload, checkpoint, "event payload round trip"); + }); + + it("should refuse to mint an event from a malformed checkpoint", () => { + assertProviderReplayError(() => + createProviderReplayCheckpointEvent( + { ...createValidCheckpoint(), version: 9 } as unknown as ProviderReplayCheckpoint, + ) + ); + }); + }); + + describe("emission gate", () => { + it("should default to disabled when the flag is unset", () => { + assertEquals( + isProviderReplayCheckpointEmissionEnabled(() => undefined), + false, + "unset flag stays off", + ); + assertEquals(isProviderReplayCheckpointEmissionEnabled(), false, "real environment default"); + }); + + it("should only enable on the exact literal true", () => { + for (const value of ["1", "TRUE", "yes", "on", ""]) { + assertEquals( + isProviderReplayCheckpointEmissionEnabled(() => value), + false, + `"${value}" stays off`, + ); + } + assertEquals( + isProviderReplayCheckpointEmissionEnabled((name) => + name === PROVIDER_REPLAY_CHECKPOINT_EMISSION_ENV_FLAG ? "true" : undefined + ), + true, + "literal true enables", + ); + }); + + it("should emit nothing while the gate is off", () => { + assertEquals( + maybeCreateProviderReplayCheckpointEvent({ + checkpoint: createValidCheckpoint(), + readEnv: () => undefined, + }), + null, + "gate off emits nothing", + ); + assertEquals( + maybeCreateProviderReplayCheckpointEvent({ checkpoint: createValidCheckpoint() }), + null, + "gate off by default in this environment", + ); + }); + + it("should mint the event only when the gate is on", () => { + const event = maybeCreateProviderReplayCheckpointEvent({ + checkpoint: createValidCheckpoint(), + readEnv: () => "true", + }); + assertEquals( + event?.type, + AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE, + "gate on emits the event", + ); + }); + }); + + describe("createAnthropicProviderReplayCheckpoint", () => { + it("should return null when no provider metadata is present", () => { + assertEquals( + createAnthropicProviderReplayCheckpoint({ + messageId: "assistant-message-1", + providerMetadata: undefined, + }), + null, + "absent metadata", + ); + assertEquals( + createAnthropicProviderReplayCheckpoint({ + messageId: "assistant-message-1", + providerMetadata: { google: { rawAssistantParts: [] } }, + }), + null, + "non-anthropic metadata is not replay-required", + ); + }); + + it("should build a checkpoint from raw assistant messages preserving order", () => { + const rawAssistantMessages = [ + [ + { type: "thinking", thinking: "", signature: SIGNATURE }, + { type: "tool_use", id: "call-1", name: "lookup", input: { query: "veryfront" } }, + ], + [ + { type: "text", text: "continued" }, + ], + ]; + const checkpoint = createAnthropicProviderReplayCheckpoint({ + messageId: "assistant-message-1", + providerMetadata: { anthropic: { rawAssistantMessages } }, + }); + assertEquals(checkpoint?.provider, "anthropic", "provider"); + assertEquals(checkpoint?.messageId, "assistant-message-1", "message anchor"); + assertEquals(checkpoint?.totalPartCount, 3, "total part count"); + assertEquals(checkpoint?.providerBlockPositions, [0, 1, 2], "positions"); + assertEquals( + checkpoint?.providerBlocks.map((block) => block.block), + [...rawAssistantMessages[0]!, ...rawAssistantMessages[1]!], + "blocks flattened in original order", + ); + assertEquals( + checkpoint?.providerBlocks[0]?.block.signature, + SIGNATURE, + "signature carried byte-exact beside empty thinking text", + ); + assertEquals(checkpoint?.providerBlocks[0]?.block.thinking, "", "empty displayed thinking"); + assertEquals( + parseProviderReplayCheckpoint(checkpoint), + checkpoint, + "emitted checkpoint satisfies the wire contract", + ); + }); + + it("should fail explicitly on malformed raw assistant metadata", () => { + for ( + const anthropic of [ + { rawAssistantMessages: [] }, + { rawAssistantMessages: "not-an-array" }, + { rawAssistantMessages: [["not-a-block"]] }, + { rawAssistantMessages: [[{ thinking: "missing type" }]] }, + ] + ) { + assertProviderReplayError(() => + createAnthropicProviderReplayCheckpoint({ + messageId: "assistant-message-1", + providerMetadata: { anthropic }, + }) + ); + } + }); + + it("should fail explicitly when the turn exceeds the checkpoint block capacity", () => { + assertProviderReplayError(() => + createAnthropicProviderReplayCheckpoint({ + messageId: "assistant-message-1", + providerMetadata: { + anthropic: { + rawAssistantMessages: [ + Array.from({ length: 101 }, () => ({ type: "text", text: "block" })), + ], + }, + }, + }) + ); + }); + }); + + describe("applyProviderReplayCheckpointsToMessages", () => { + it("should attach opaque replay metadata to the matching assistant turn", () => { + const target = createAssistantMessage("assistant-message-1"); + const untouched = createAssistantMessage("assistant-message-2"); + const messages = [ + { id: "user-1", role: "user", parts: [{ type: "text", text: "hi" }], timestamp: 0 }, + target, + untouched, + ] as Message[]; + const checkpoint = createValidCheckpoint(); + + applyProviderReplayCheckpointsToMessages(messages, [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { + anthropic: { + rawAssistantMessages: [checkpoint.providerBlocks.map((block) => block.block)], + }, + }, + "raw blocks attached in original order", + ); + assertEquals( + readAttachedProviderMetadata(untouched), + undefined, + "turns without replay state stay untouched", + ); + assertEquals( + JSON.stringify(messages).includes(SIGNATURE), + false, + "signed material never lands on the public message objects", + ); + }); + + it("should be a no-op for empty or absent deliveries", () => { + const target = createAssistantMessage("assistant-message-1"); + applyProviderReplayCheckpointsToMessages([target], undefined); + applyProviderReplayCheckpointsToMessages([target], []); + assertEquals(readAttachedProviderMetadata(target), undefined, "no metadata attached"); + }); + + it("should skip a checkpoint whose assistant turn left the context", () => { + const target = createAssistantMessage("assistant-message-1"); + applyProviderReplayCheckpointsToMessages( + [target], + [{ ...createValidCheckpoint(), messageId: "assistant-message-gone" }], + ); + assertEquals( + readAttachedProviderMetadata(target), + undefined, + "a dropped turn is not replayed, so it needs no replay state", + ); + }); + + it("should fail explicitly when the checkpoint targets a non-assistant message", () => { + const messages = [ + { id: "user-1", role: "user", parts: [{ type: "text", text: "hi" }], timestamp: 0 }, + ] as Message[]; + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages(messages, [ + { ...createValidCheckpoint(), messageId: "user-1" }, + ]) + ); + }); + + it("should fail explicitly on ambiguous duplicate message ids", () => { + const messages = [ + createAssistantMessage("assistant-message-1"), + createAssistantMessage("assistant-message-1"), + ]; + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages(messages, [createValidCheckpoint()]) + ); + }); + + it("should fail explicitly for providers this runtime cannot reconstruct", () => { + const checkpoint: ProviderReplayCheckpoint = { + ...createValidCheckpoint(), + provider: "openai-responses", + providerBlocks: [{ + type: "provider-block", + provider: "openai-responses", + block: { type: "reasoning", id: "rs-1" }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages( + [createAssistantMessage("assistant-message-1")], + [checkpoint], + ) + ); + }); + + it("should keep fresher in-process replay metadata over a delivered checkpoint", () => { + const target = createAssistantMessage("assistant-message-1"); + const inProcess = { anthropic: { rawAssistantMessages: [[{ type: "text", text: "live" }]] } }; + // Simulate the in-process attach that happens right after a streamed step. + attachProviderMetadata(target, inProcess); + applyProviderReplayCheckpointsToMessages([target], [createValidCheckpoint()]); + assertEquals( + readAttachedProviderMetadata(target), + inProcess, + "in-process metadata wins over the durable copy of the same turn", + ); + }); + + it("should reconstruct the provider request assistant turn through the runtime converter", () => { + const target = createAssistantMessage("assistant-message-1"); + const checkpoint = createValidCheckpoint(); + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + const runtimeMessages = convertToTextGenerationRuntimeMessages([target]); + const assistant = runtimeMessages.find((message) => message.role === "assistant"); + assertEquals( + assistant && "providerMetadata" in assistant ? assistant.providerMetadata : undefined, + { + anthropic: { + rawAssistantMessages: [checkpoint.providerBlocks.map((block) => block.block)], + }, + }, + "provider request path receives the raw replay metadata", + ); + }); + }); +}); diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts new file mode 100644 index 0000000000..664bd29be4 --- /dev/null +++ b/src/agent/runtime/provider-replay.ts @@ -0,0 +1,357 @@ +import { PROVIDER_REPLAY_CHECKPOINT_INVALID } from "#veryfront/errors"; +import { getEnv } from "#veryfront/platform/compat/process.ts"; +import type { Message } from "../types.ts"; +import { attachProviderMetadata, readAttachedProviderMetadata } from "./provider-metadata.ts"; + +/** + * Durable run event type carrying provider-native replay state. + * + * Mirrors `AgentRunProviderReplayCheckpointPayloadSchema` in veryfront-api; + * both sides validate the same shape so a payload accepted here is accepted + * there and vice versa. + */ +export const AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE = + "AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT" as const; + +/** Environment flag gating checkpoint emission; anything but "true" stays off. */ +export const PROVIDER_REPLAY_CHECKPOINT_EMISSION_ENV_FLAG = + "VERYFRONT_ENABLE_PROVIDER_REPLAY_CHECKPOINT_EMISSION" as const; + +const MAX_PROVIDER_REPLAY_BLOCKS = 100; +const MAX_PROVIDER_REPLAY_TOTAL_PARTS = 10_000; +const MAX_PROVIDER_REPLAY_MESSAGE_ID_LENGTH = 256; + +const CHECKPOINT_KEYS = new Set([ + "version", + "messageId", + "provider", + "providerBlocks", + "providerBlockPositions", + "totalPartCount", + "elapsedMs", + "emittedAt", +]); +const BLOCK_KEYS = new Set(["type", "provider", "block"]); + +/** Providers whose continuation contract can require opaque block replay. */ +export type ProviderReplayProvider = "anthropic" | "openai-responses"; + +/** One opaque provider content block replayed byte-exact on resume. */ +export type ProviderReplayBlock = { + type: "provider-block"; + provider: ProviderReplayProvider; + block: Record; +}; + +/** + * Provider-native replay state for one persisted assistant turn. + * + * Blocks are ordered by their original position within the turn; positions are + * strictly increasing and bounded by `totalPartCount`. Block contents may carry + * signed reasoning material and must never be logged or rendered as text. + */ +export type ProviderReplayCheckpoint = { + version: 1; + messageId: string; + provider: ProviderReplayProvider; + providerBlocks: ProviderReplayBlock[]; + providerBlockPositions: number[]; + totalPartCount: number; + elapsedMs?: number; + emittedAt?: number; +}; + +/** Durable event form of a provider replay checkpoint. */ +export type ProviderReplayCheckpointEvent = ProviderReplayCheckpoint & { + type: typeof AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE; +}; + +/** + * Fails checkpoint validation without echoing payload contents. Blocks carry + * signed reasoning material, so details name fields and indices only. + */ +function invalidCheckpoint(detail: string, context?: Record): never { + throw PROVIDER_REPLAY_CHECKPOINT_INVALID.create({ detail, ...(context ? { context } : {}) }); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isProviderReplayProvider(value: unknown): value is ProviderReplayProvider { + return value === "anthropic" || value === "openai-responses"; +} + +function parseProviderReplayBlock( + value: unknown, + provider: ProviderReplayProvider, + index: number, +): ProviderReplayBlock { + if (!isRecord(value)) { + invalidCheckpoint("provider block must be an object", { index }); + } + for (const key of Object.keys(value)) { + if (!BLOCK_KEYS.has(key)) { + invalidCheckpoint("provider block carries an unknown key", { index, key }); + } + } + if (value.type !== "provider-block") { + invalidCheckpoint('provider block type must be "provider-block"', { index }); + } + if (value.provider !== provider) { + invalidCheckpoint("provider block must match the checkpoint provider", { index }); + } + if (!isRecord(value.block)) { + invalidCheckpoint("provider block content must be an object", { index }); + } + return { type: "provider-block", provider, block: value.block }; +} + +/** Parse untrusted checkpoint state; malformed state fails explicitly. */ +export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayCheckpoint { + if (!isRecord(value)) { + invalidCheckpoint("checkpoint must be an object"); + } + for (const key of Object.keys(value)) { + if (!CHECKPOINT_KEYS.has(key)) { + invalidCheckpoint("checkpoint carries an unknown key", { key }); + } + } + if (value.version !== 1) { + invalidCheckpoint("checkpoint version is unsupported"); + } + if ( + typeof value.messageId !== "string" || + value.messageId.length === 0 || + value.messageId.length > MAX_PROVIDER_REPLAY_MESSAGE_ID_LENGTH + ) { + invalidCheckpoint("checkpoint messageId must be a bounded non-empty string"); + } + if (!isProviderReplayProvider(value.provider)) { + invalidCheckpoint("checkpoint provider is not a replay-capable provider"); + } + if ( + !Array.isArray(value.providerBlocks) || + value.providerBlocks.length === 0 || + value.providerBlocks.length > MAX_PROVIDER_REPLAY_BLOCKS + ) { + invalidCheckpoint( + `checkpoint providerBlocks must contain 1-${MAX_PROVIDER_REPLAY_BLOCKS} blocks`, + ); + } + const provider = value.provider; + const providerBlocks = value.providerBlocks.map((block, index) => + parseProviderReplayBlock(block, provider, index) + ); + if ( + typeof value.totalPartCount !== "number" || + !Number.isSafeInteger(value.totalPartCount) || + value.totalPartCount < 1 || + value.totalPartCount > MAX_PROVIDER_REPLAY_TOTAL_PARTS + ) { + invalidCheckpoint( + `checkpoint totalPartCount must be an integer between 1 and ${MAX_PROVIDER_REPLAY_TOTAL_PARTS}`, + ); + } + if (value.totalPartCount < providerBlocks.length) { + invalidCheckpoint("checkpoint totalPartCount cannot be lower than the block count"); + } + if ( + !Array.isArray(value.providerBlockPositions) || + value.providerBlockPositions.length !== providerBlocks.length + ) { + invalidCheckpoint("checkpoint providerBlockPositions must align one-to-one with blocks"); + } + const positions: number[] = []; + for (const [index, position] of value.providerBlockPositions.entries()) { + if ( + typeof position !== "number" || + !Number.isSafeInteger(position) || + position < 0 || + position >= value.totalPartCount + ) { + invalidCheckpoint("checkpoint block position must be an integer below totalPartCount", { + index, + }); + } + const previous = positions.at(-1); + if (previous !== undefined && position <= previous) { + invalidCheckpoint("checkpoint block positions must be strictly increasing", { index }); + } + positions.push(position); + } + if ( + value.elapsedMs !== undefined && + (typeof value.elapsedMs !== "number" || !Number.isFinite(value.elapsedMs) || + value.elapsedMs < 0) + ) { + invalidCheckpoint("checkpoint elapsedMs must be a finite non-negative number"); + } + if ( + value.emittedAt !== undefined && + (typeof value.emittedAt !== "number" || !Number.isSafeInteger(value.emittedAt) || + value.emittedAt < 0) + ) { + invalidCheckpoint("checkpoint emittedAt must be a non-negative integer"); + } + return { + version: 1, + messageId: value.messageId, + provider, + providerBlocks, + providerBlockPositions: positions, + totalPartCount: value.totalPartCount, + ...(value.elapsedMs !== undefined ? { elapsedMs: value.elapsedMs } : {}), + ...(value.emittedAt !== undefined ? { emittedAt: value.emittedAt } : {}), + }; +} + +/** + * Parse a server-resolved checkpoint delivery. The whole delivery fails when + * any entry is malformed: applying only the well-formed subset would silently + * degrade replay for the rest. + */ +export function parseServerResolvedProviderReplayCheckpoints( + value: unknown, +): ProviderReplayCheckpoint[] { + if (!Array.isArray(value)) { + invalidCheckpoint("server-resolved provider replay checkpoints must be an array"); + } + return value.map((entry) => parseProviderReplayCheckpoint(entry)); +} + +/** Convert a validated checkpoint into its durable root-run event. */ +export function createProviderReplayCheckpointEvent( + checkpoint: ProviderReplayCheckpoint, +): ProviderReplayCheckpointEvent { + return { + type: AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE, + ...parseProviderReplayCheckpoint(checkpoint), + }; +} + +/** Return whether checkpoint emission is enabled; default off in every environment. */ +export function isProviderReplayCheckpointEmissionEnabled( + readEnv: (name: string) => string | undefined = getEnv, +): boolean { + return readEnv(PROVIDER_REPLAY_CHECKPOINT_EMISSION_ENV_FLAG) === "true"; +} + +/** + * Sole emission entry point for the durable checkpoint event. + * + * Returns null while the emission gate is off, so no runtime path can persist + * a checkpoint before the API-side append support ships and the flag is turned + * on deliberately (stage 4 of veryfront-issue-inbox#522). + */ +export function maybeCreateProviderReplayCheckpointEvent(input: { + checkpoint: ProviderReplayCheckpoint; + readEnv?: (name: string) => string | undefined; +}): ProviderReplayCheckpointEvent | null { + if (!isProviderReplayCheckpointEmissionEnabled(input.readEnv ?? getEnv)) { + return null; + } + return createProviderReplayCheckpointEvent(input.checkpoint); +} + +/** + * Build a checkpoint from the Anthropic raw-replay metadata the provider + * already emits in-process (`providerMetadata.anthropic.rawAssistantMessages`). + * + * Returns null when the metadata carries no Anthropic replay state; malformed + * replay state fails explicitly rather than emitting a partial checkpoint. + */ +export function createAnthropicProviderReplayCheckpoint(input: { + messageId: string; + providerMetadata: Record | undefined; +}): ProviderReplayCheckpoint | null { + const anthropic = input.providerMetadata?.anthropic; + if (anthropic === undefined) return null; + if (!isRecord(anthropic)) { + invalidCheckpoint("anthropic provider metadata must be an object"); + } + const rawAssistantMessages = anthropic.rawAssistantMessages; + if (rawAssistantMessages === undefined) return null; + if (!Array.isArray(rawAssistantMessages) || rawAssistantMessages.length === 0) { + invalidCheckpoint("anthropic raw assistant messages must be a non-empty array"); + } + const blocks: Record[] = []; + for (const [messageIndex, rawContent] of rawAssistantMessages.entries()) { + if (!Array.isArray(rawContent)) { + invalidCheckpoint("anthropic raw assistant content must be an array", { messageIndex }); + } + for (const [blockIndex, block] of rawContent.entries()) { + if (!isRecord(block) || typeof block.type !== "string") { + invalidCheckpoint("anthropic raw assistant block must be a typed object", { + messageIndex, + blockIndex, + }); + } + blocks.push(block); + } + } + if (blocks.length === 0 || blocks.length > MAX_PROVIDER_REPLAY_BLOCKS) { + invalidCheckpoint( + `anthropic raw assistant turn must carry 1-${MAX_PROVIDER_REPLAY_BLOCKS} blocks`, + { blockCount: blocks.length }, + ); + } + return parseProviderReplayCheckpoint({ + version: 1, + messageId: input.messageId, + provider: "anthropic", + providerBlocks: blocks.map((block) => ({ + type: "provider-block", + provider: "anthropic", + block, + })), + providerBlockPositions: blocks.map((_, index) => index), + totalPartCount: blocks.length, + }); +} + +/** + * Attach delivered replay state to the assistant turns it anchors to. + * + * Metadata rides the same internal side channel as in-process raw replay + * (`attachProviderMetadata`), so signed blocks never appear on the public + * message objects and cannot reach transcripts or logs that serialize them. + * A checkpoint whose turn is no longer in context is skipped: a turn that is + * not replayed to the provider has no replay obligation. Every other mismatch + * fails explicitly. + */ +export function applyProviderReplayCheckpointsToMessages( + messages: readonly Message[], + checkpoints: readonly ProviderReplayCheckpoint[] | undefined, +): void { + if (checkpoints === undefined || checkpoints.length === 0) return; + for (const checkpoint of checkpoints) { + const matches = messages.filter((message) => message.id === checkpoint.messageId); + if (matches.length === 0) continue; + if (matches.length > 1) { + invalidCheckpoint("checkpoint messageId matches more than one message", { + messageId: checkpoint.messageId, + }); + } + const target = matches[0]!; + if (target.role !== "assistant") { + invalidCheckpoint("checkpoint messageId must anchor to an assistant message", { + messageId: checkpoint.messageId, + role: target.role, + }); + } + if (checkpoint.provider !== "anthropic") { + invalidCheckpoint("this runtime has no replay reconstruction for the checkpoint provider", { + provider: checkpoint.provider, + }); + } + // In-process metadata attached during this run is the same replay state at + // first hand; the durable checkpoint never overrides it. + if (readAttachedProviderMetadata(target) !== undefined) continue; + attachProviderMetadata(target, { + anthropic: { + rawAssistantMessages: [checkpoint.providerBlocks.map((block) => block.block)], + }, + }); + } +} diff --git a/src/agent/runtime/runtime-tool-config.test.ts b/src/agent/runtime/runtime-tool-config.test.ts index 1c73d00069..6da67b7d6e 100644 --- a/src/agent/runtime/runtime-tool-config.test.ts +++ b/src/agent/runtime/runtime-tool-config.test.ts @@ -1,10 +1,12 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertInstanceOf, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { VeryfrontError } from "#veryfront/errors"; import type { AgentConfig } from "../types.ts"; import { getRuntimeAllowedRemoteTools, getRuntimeForwardedIntegrationToolDefs, + getRuntimeProviderReplayCheckpoints, getRuntimeProviderTools, getRuntimeSourceIntegrationPolicy, getRuntimeSourceIntegrationPolicyFromContext, @@ -225,4 +227,41 @@ describe("agent/runtime-tool-config", () => { ); }); }); + + describe("getRuntimeProviderReplayCheckpoints", () => { + it("returns undefined when the trusted host resolved no replay state", () => { + assertEquals(getRuntimeProviderReplayCheckpoints(runtimeConfig()), undefined); + }); + + it("returns validated checkpoints from trusted host state", () => { + const checkpoint = { + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "thinking", thinking: "", signature: "sig-config" }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + assertEquals( + getRuntimeProviderReplayCheckpoints(runtimeConfig({ + __vfProviderReplayCheckpoints: [checkpoint], + })), + [checkpoint], + ); + }); + + it("fails explicitly on malformed trusted state instead of degrading replay", () => { + const error = assertThrows(() => + getRuntimeProviderReplayCheckpoints(runtimeConfig({ + __vfProviderReplayCheckpoints: [{ version: 1 }], + })) + ); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "provider-replay-checkpoint-invalid"); + }); + }); }); diff --git a/src/agent/runtime/runtime-tool-config.ts b/src/agent/runtime/runtime-tool-config.ts index 3709eb2157..f9e1717361 100644 --- a/src/agent/runtime/runtime-tool-config.ts +++ b/src/agent/runtime/runtime-tool-config.ts @@ -8,6 +8,10 @@ import { isValidToolExposureCheckpointName, type ToolExposureCheckpoint, } from "./tool-exposure.ts"; +import { + parseServerResolvedProviderReplayCheckpoints, + type ProviderReplayCheckpoint, +} from "./provider-replay.ts"; /** Internal schema-loading mode derived from the authored tools selector. */ export type RuntimeToolLoadingMode = "eager" | "deferred"; @@ -19,6 +23,7 @@ export type RuntimeToolFilterConfig = AgentConfig & { { name: string; description: string; parameters: Record } >; __vfToolExposureCheckpoint?: ToolExposureCheckpoint; + __vfProviderReplayCheckpoints?: readonly ProviderReplayCheckpoint[]; __vfPersistToolExposureCheckpoint?: ( checkpoint: ToolExposureCheckpoint, ) => void | Promise; @@ -112,6 +117,20 @@ export function getRuntimeToolExposureCheckpoint( return value; } +/** + * Return the verified provider replay checkpoints for this run. + * + * Malformed internal state fails explicitly: replay state guards provider + * protocol correctness, so it never degrades into an unsigned replay. + */ +export function getRuntimeProviderReplayCheckpoints( + config: AgentConfig, +): ProviderReplayCheckpoint[] | undefined { + const value = (config as RuntimeToolFilterConfig).__vfProviderReplayCheckpoints; + if (value === undefined) return undefined; + return parseServerResolvedProviderReplayCheckpoints(value); +} + /** Return whether the trusted host requires checkpoint durability before continuation. */ export function isRuntimeToolExposureCheckpointPersistenceRequired( config: AgentConfig, diff --git a/src/errors/error-registry.test.ts b/src/errors/error-registry.test.ts index 3dcd2a1117..b11e2c0a14 100644 --- a/src/errors/error-registry.test.ts +++ b/src/errors/error-registry.test.ts @@ -118,6 +118,7 @@ const PUBLISHED_ERROR_SLUGS: readonly string[] = Object.freeze([ "production-build-required", "project-execution-unavailable", "project-source-empty", + "provider-replay-checkpoint-invalid", "push-conflict", "push-receipt-missing", "rag-store-corrupt", diff --git a/src/errors/error-registry/agent.ts b/src/errors/error-registry/agent.ts index 767abab118..b5983cab3d 100644 --- a/src/errors/error-registry/agent.ts +++ b/src/errors/error-registry/agent.ts @@ -78,6 +78,22 @@ export const DEFAULT_MODEL_CREDENTIAL_MISMATCH = defineError({ suggestion: 'Set the agent\'s model to a provider you have a key for, or use model: "auto"', }); +/** + * Provider replay checkpoint state failed validation at a consumer boundary. + * Replay state guards provider protocol correctness on resume, so malformed + * or unappliable state fails closed instead of degrading into an unsigned + * replay. Details never include checkpoint contents: provider blocks carry + * signed reasoning material that must stay out of logs and error text. + */ +export const PROVIDER_REPLAY_CHECKPOINT_INVALID = defineError({ + slug: "provider-replay-checkpoint-invalid", + category: "AGENT", + status: 500, + title: "Provider replay checkpoint is invalid", + suggestion: + "Verify the trusted source that resolved the run's provider replay checkpoints; do not retry with the same replay state", +}); + /** Registry fragment for AGENT errors (slug → definition). */ export const AGENT_REGISTRY = { "agent-error": AGENT_ERROR, @@ -89,4 +105,5 @@ export const AGENT_REGISTRY = { "tool-id-conflict": TOOL_ID_CONFLICT, "durable-run-event-persistence-failed": DURABLE_RUN_EVENT_PERSISTENCE_FAILED, "default-model-credential-mismatch": DEFAULT_MODEL_CREDENTIAL_MISMATCH, + "provider-replay-checkpoint-invalid": PROVIDER_REPLAY_CHECKPOINT_INVALID, } as const; diff --git a/src/errors/index.ts b/src/errors/index.ts index aab753c1e1..79cff8dc3c 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -127,6 +127,7 @@ export { PRODUCTION_BUILD_REQUIRED, PROJECT_EXECUTION_UNAVAILABLE, PROJECT_SOURCE_EMPTY, + PROVIDER_REPLAY_CHECKPOINT_INVALID, PUSH_CONFLICT, PUSH_RECEIPT_MISSING, RAG_STORE_CORRUPT, From b94dd1044d719077dc75cd3abf68e1c834da1b25 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 08:20:36 +0200 Subject: [PATCH 02/56] fix(agent): type the replay checkpoint fixture and publish its error docs - Type the runtime-tool-config test fixture as ProviderReplayCheckpoint so its discriminant literals stop widening (lint:test-typecheck). - Regenerate docs/guides/errors.md so provider-replay-checkpoint-invalid has its published title/suggestion entry (docs:errors:check, tests/docs/error-docs-links.test.ts). Refs veryfront/veryfront-issue-inbox#522 --- docs/guides/errors.md | 7 +++++++ src/agent/runtime/runtime-tool-config.test.ts | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/guides/errors.md b/docs/guides/errors.md index 6f8e4d9dda..5d9a0593ca 100644 --- a/docs/guides/errors.md +++ b/docs/guides/errors.md @@ -819,6 +819,13 @@ Default model has no matching provider credential. - **HTTP status:** 400 - **What to do:** Set the agent's model to a provider you have a key for, or use model: "auto" +### provider-replay-checkpoint-invalid + +Provider replay checkpoint is invalid. + +- **HTTP status:** 500 +- **What to do:** Verify the trusted source that resolved the run's provider replay checkpoints; do not retry with the same replay state + ## General Raised anywhere; these are not specific to one subsystem. diff --git a/src/agent/runtime/runtime-tool-config.test.ts b/src/agent/runtime/runtime-tool-config.test.ts index 6da67b7d6e..6ccc0151f7 100644 --- a/src/agent/runtime/runtime-tool-config.test.ts +++ b/src/agent/runtime/runtime-tool-config.test.ts @@ -3,6 +3,7 @@ import { assertEquals, assertInstanceOf, assertThrows } from "#veryfront/testing import { describe, it } from "#veryfront/testing/bdd.ts"; import { VeryfrontError } from "#veryfront/errors"; import type { AgentConfig } from "../types.ts"; +import type { ProviderReplayCheckpoint } from "./provider-replay.ts"; import { getRuntimeAllowedRemoteTools, getRuntimeForwardedIntegrationToolDefs, @@ -234,7 +235,7 @@ describe("agent/runtime-tool-config", () => { }); it("returns validated checkpoints from trusted host state", () => { - const checkpoint = { + const checkpoint: ProviderReplayCheckpoint = { version: 1, messageId: "assistant-message-1", provider: "anthropic", From 2eddfb1b4f74118e078bba95a91be588c6ddb03a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 09:11:32 +0200 Subject: [PATCH 03/56] fix(agent): harden the replay contract per review findings Addresses the PR 4301 review threads: - Reject providers and sparse checkpoints this runtime cannot reconstruct, at the verified consumer boundary and at application (assertReconstructibleProviderReplayCheckpoint): skipping contract-valid replay state would be silent degraded replay, so deployment skew fails loudly at request preparation instead. - Reject deliveries carrying duplicate checkpoints for one message anchor, so replay state cannot depend on array order. - Fail explicitly when the runtime converter cannot carry attached exact-replay metadata (split assistant turns) instead of silently sending the unsigned canonical projection. - Reject multi-continuation (pause_turn) raw assistant turns at the emission builder: the v1 wire schema has no message-boundary field, so flattening would silently change the replayed sequence. Flagged as a stage-2 schema decision. - Keep attacker-controlled text out of rejection errors: unknown key names and delivered messageIds no longer enter error context. Refs veryfront/veryfront-issue-inbox#522 --- docs/api-reference/veryfront/agent.md | 16 +-- .../hosted/runtime-request-config.test.ts | 45 ++++++ src/agent/hosted/runtime-request-config.ts | 10 +- src/agent/runtime/provider-replay.test.ts | 128 +++++++++++++++++- src/agent/runtime/provider-replay.ts | 97 +++++++++---- ...xt-generation-runtime-message-converter.ts | 11 +- 6 files changed, 266 insertions(+), 41 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 6f42940c0c..b7dd099abb 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -789,8 +789,8 @@ Input delivered to a hosted agent-service detached execution callback. | `getConversationRunEventJsonByteLength` | Return conversation run event JSON byte length. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-event-normalization.ts#L27) | | `getEmptyHostedFinalizedMessageTerminalError` | Error shape for get empty hosted finalized message terminal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-terminal-error.ts#L113) | | `getForkRuntimeAllowedToolNames` | Return fork runtime allowed tool names. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-native-tool-inventory.ts#L90) | -| `getForwardedHostedModelId` | Return forwarded hosted model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L111) | -| `getForwardedHostedRuntimeOverrides` | Return forwarded hosted runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L121) | +| `getForwardedHostedModelId` | Return forwarded hosted model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L119) | +| `getForwardedHostedRuntimeOverrides` | Return forwarded hosted runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L129) | | `getHostedChildWrittenArtifactPath` | Return hosted child written artifact path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-artifact-support.ts#L112) | | `getHostedMirroredAbortErrorText` | Return hosted mirrored abort error text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L163) | | `getHostedServiceTokenFromRequest` | Request payload for get hosted service token from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L234) | @@ -937,8 +937,8 @@ Input delivered to a hosted agent-service detached execution callback. | `resolveHostedChildStreamWatchdogState` | State for resolve hosted child stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-stream-watchdog.ts#L33) | | `resolveHostedChildTerminalErrorCode` | Resolves a code is a hosted child terminal error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-status.ts#L91) | | `resolveHostedDurableRunSetupErrorResponse` | Response payload for resolve hosted durable run setup error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/durable-chat-run-start.ts#L122) | -| `resolveHostedRuntimeRequestConfig` | Configuration used by resolve hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L211) | -| `resolveHostedRuntimeThinkingOverride` | Resolves hosted runtime thinking override. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L149) | +| `resolveHostedRuntimeRequestConfig` | Configuration used by resolve hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L219) | +| `resolveHostedRuntimeThinkingOverride` | Resolves hosted runtime thinking override. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L157) | | `resolveNodeAgentServiceTelemetryConfig` | Configuration used by resolve node agent service telemetry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L379) | | `resolveNodeHostedAgentServiceTelemetryConfig` | Configuration used by resolve node hosted agent service telemetry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L315) | | `resolveRuntimeAgentDefinitionsDir` | Resolves runtime agent definitions dir. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition-files.ts#L96) | @@ -1597,8 +1597,8 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedResponseStreamWriter` | Public API contract for hosted response stream writer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/response-stream.ts#L4) | | `HostedRootRunLifecycleRuntimeAdapter` | Public API contract for hosted root run lifecycle runtime adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/agent-run-lifecycle.ts#L128) | | `HostedRunEventWriterCapability` | Opaque authority for appending events to one exact hosted run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-run-event-writer-token.ts#L105) | -| `HostedRuntimeRequestConfigAgent` | Public API contract for hosted runtime request config agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L28) | -| `HostedRuntimeRequestConfigRequest` | Request payload for hosted runtime request config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L22) | +| `HostedRuntimeRequestConfigAgent` | Public API contract for hosted runtime request config agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L29) | +| `HostedRuntimeRequestConfigRequest` | Request payload for hosted runtime request config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L23) | | `HostedRuntimeSourceBindingError` | Stable control-plane error returned when a request cannot run on this service snapshot. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-source-binding.ts#L9) | | `HostedRuntimeSourceIdentity` | Immutable project source identity served by a standalone agent-service process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-source-binding.ts#L4) | | `HostedRuntimeStateResolverContext` | Context for hosted runtime state resolver. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-state-resolver.ts#L18) | @@ -1735,11 +1735,11 @@ Input delivered to a hosted agent-service detached execution callback. | `ResolveConversationHostedTerminalStateInput` | Input payload for resolve conversation hosted terminal state. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/hosted-terminal.ts#L30) | | `ResolvedAgentConfig` | Configuration used by resolved agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/types.ts#L307) | | `ResolvedAgentServiceRegistrationInput` | Input payload for resolved agent service registration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/registration.ts#L29) | -| `ResolvedHostedRuntimeRequestConfig` | Configuration used by resolved hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L52) | +| `ResolvedHostedRuntimeRequestConfig` | Configuration used by resolved hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L53) | | `ResolvedModelTransport` | Public API contract for resolved model transport. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/types.ts#L326) | | `ResolvedRuntimeState` | State for resolved runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/types.ts#L352) | | `ResolveHostedChildForkRuntimeConfigInput` | Input payload for resolve hosted child fork runtime config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-tool-input.ts#L221) | -| `ResolveHostedRuntimeRequestConfigInput` | Input payload for resolve hosted runtime request config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L42) | +| `ResolveHostedRuntimeRequestConfigInput` | Input payload for resolve hosted runtime request config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L43) | | `ResolveNodeAgentServiceTelemetryConfigOptions` | Options accepted by resolve node agent service telemetry config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L62) | | `ResolveNodeHostedAgentServiceTelemetryConfigOptions` | Options accepted by resolve node hosted agent service telemetry config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L54) | | `ResolveRuntimeAgentDefinitionsDirInput` | Input payload for resolve runtime agent definitions dir. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition-files.ts#L12) | diff --git a/src/agent/hosted/runtime-request-config.test.ts b/src/agent/hosted/runtime-request-config.test.ts index 38ad15c794..128253eeca 100644 --- a/src/agent/hosted/runtime-request-config.test.ts +++ b/src/agent/hosted/runtime-request-config.test.ts @@ -99,6 +99,51 @@ it("server-resolved provider replay checkpoints require a verified envelope and ); }); +it("server-resolved provider replay checkpoints reject providers this runtime cannot replay", () => { + // Contract-valid on the wire, but stage 1 only reconstructs anthropic + // replay: accepting it and skipping later would be silent degraded replay, + // so deployment skew fails loudly at request preparation instead. + const error = assertThrows(() => + getServerResolvedProviderReplayCheckpoints({ + serverResolvedProviderReplayCheckpoints: [{ + version: 1, + messageId: "assistant-message-1", + provider: "openai-responses", + providerBlocks: [{ + type: "provider-block", + provider: "openai-responses", + block: { type: "reasoning", id: "rs-1" }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }], + }, true) + ); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "provider-replay-checkpoint-invalid", "registry slug"); +}); + +it("server-resolved provider replay checkpoints reject sparse deliveries", () => { + const error = assertThrows(() => + getServerResolvedProviderReplayCheckpoints({ + serverResolvedProviderReplayCheckpoints: [{ + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "thinking", thinking: "", signature: "sig-sparse" }, + }], + providerBlockPositions: [1], + totalPartCount: 2, + }], + }, true) + ); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "provider-replay-checkpoint-invalid", "registry slug"); +}); + it("server-resolved provider replay checkpoints fail explicitly on forgery-shaped state", () => { const validBlock = { type: "provider-block" as const, diff --git a/src/agent/hosted/runtime-request-config.ts b/src/agent/hosted/runtime-request-config.ts index 14dac1bdbf..2cbc7726ce 100644 --- a/src/agent/hosted/runtime-request-config.ts +++ b/src/agent/hosted/runtime-request-config.ts @@ -14,6 +14,7 @@ import { type ToolExposureCheckpoint, } from "../runtime/tool-exposure.ts"; import { + assertReconstructibleProviderReplayCheckpoint, parseServerResolvedProviderReplayCheckpoints, type ProviderReplayCheckpoint, } from "../runtime/provider-replay.ts"; @@ -104,7 +105,14 @@ export function getServerResolvedProviderReplayCheckpoints( if (!serverEnvelopeVerified) return undefined; const value = forwardedProps?.serverResolvedProviderReplayCheckpoints; if (value === undefined) return undefined; - return parseServerResolvedProviderReplayCheckpoints(value); + const checkpoints = parseServerResolvedProviderReplayCheckpoints(value); + // Contract-valid state this runtime version cannot reconstruct (another + // provider, sparse blocks) is deployment skew; it fails here at request + // preparation rather than skipping into a silently degraded replay. + for (const checkpoint of checkpoints) { + assertReconstructibleProviderReplayCheckpoint(checkpoint); + } + return checkpoints; } /** Return forwarded hosted model ID. */ diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index bf690b1fb4..15fa4c37ab 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -149,6 +149,33 @@ describe("agent/runtime/provider-replay", () => { }); }); + it("should keep smuggled key names out of rejection text and context", () => { + const smuggledKey = "sig-material-smuggled-as-key"; + const checkpoint = createValidCheckpoint() as unknown as Record; + checkpoint[smuggledKey] = true; + const error = assertProviderReplayError(() => parseProviderReplayCheckpoint(checkpoint)); + const serialized = JSON.stringify({ + message: error.message, + detail: error.detail, + context: error.context, + }); + assertEquals(serialized.includes(smuggledKey), false, "unknown checkpoint key not echoed"); + + const blockSmuggled = createValidCheckpoint() as unknown as { + providerBlocks: Array>; + }; + blockSmuggled.providerBlocks[0]![smuggledKey] = true; + const blockError = assertProviderReplayError(() => + parseProviderReplayCheckpoint(blockSmuggled) + ); + const blockSerialized = JSON.stringify({ + message: blockError.message, + detail: blockError.detail, + context: blockError.context, + }); + assertEquals(blockSerialized.includes(smuggledKey), false, "unknown block key not echoed"); + }); + it("should reject empty, oversized, and malformed provider block lists", () => { assertInvalidCheckpoint((checkpoint) => { checkpoint.providerBlocks = []; @@ -241,6 +268,21 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should reject duplicate checkpoints for one message anchor", () => { + const error = assertProviderReplayError(() => + parseServerResolvedProviderReplayCheckpoints([ + createValidCheckpoint(), + createValidCheckpoint(), + ]) + ); + assertEquals( + JSON.stringify({ message: error.message, context: error.context }) + .includes(SIGNATURE), + false, + "duplicate rejection never echoes signed block material", + ); + }); + it("should reject the whole delivery when any entry is forged", () => { assertProviderReplayError(() => parseServerResolvedProviderReplayCheckpoints([ @@ -345,14 +387,12 @@ describe("agent/runtime/provider-replay", () => { ); }); - it("should build a checkpoint from raw assistant messages preserving order", () => { + it("should build a checkpoint from a single raw assistant message preserving order", () => { const rawAssistantMessages = [ [ { type: "thinking", thinking: "", signature: SIGNATURE }, { type: "tool_use", id: "call-1", name: "lookup", input: { query: "veryfront" } }, - ], - [ - { type: "text", text: "continued" }, + { type: "text", text: "Looking that up." }, ], ]; const checkpoint = createAnthropicProviderReplayCheckpoint({ @@ -362,11 +402,15 @@ describe("agent/runtime/provider-replay", () => { assertEquals(checkpoint?.provider, "anthropic", "provider"); assertEquals(checkpoint?.messageId, "assistant-message-1", "message anchor"); assertEquals(checkpoint?.totalPartCount, 3, "total part count"); - assertEquals(checkpoint?.providerBlockPositions, [0, 1, 2], "positions"); + assertEquals( + checkpoint?.providerBlockPositions, + [0, 1, 2], + "positions dense by construction", + ); assertEquals( checkpoint?.providerBlocks.map((block) => block.block), - [...rawAssistantMessages[0]!, ...rawAssistantMessages[1]!], - "blocks flattened in original order", + rawAssistantMessages[0]!, + "blocks carried in original order", ); assertEquals( checkpoint?.providerBlocks[0]?.block.signature, @@ -381,6 +425,25 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should fail explicitly on continuation boundaries a v1 checkpoint cannot represent", () => { + // pause_turn continuations produce one raw content array per provider + // response; the wire schema has no boundary field, so flattening would + // silently change the replayed assistant-message sequence. + assertProviderReplayError(() => + createAnthropicProviderReplayCheckpoint({ + messageId: "assistant-message-1", + providerMetadata: { + anthropic: { + rawAssistantMessages: [ + [{ type: "text", text: "first response" }], + [{ type: "text", text: "continued response" }], + ], + }, + }, + }) + ); + }); + it("should fail explicitly on malformed raw assistant metadata", () => { for ( const anthropic of [ @@ -523,6 +586,57 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should fail explicitly on sparse checkpoints this runtime cannot reconstruct", () => { + // Contract-valid but sparse: blocks at positions [0, 2] of a 3-part turn. + // The blocks at the missing positions are unknown to this runtime, so a + // wholesale raw replay would silently alter the assistant turn. + const sparse: ProviderReplayCheckpoint = { + ...createValidCheckpoint(), + providerBlocks: createValidCheckpoint().providerBlocks.slice(0, 2), + providerBlockPositions: [0, 2], + totalPartCount: 3, + }; + assertEquals( + parseProviderReplayCheckpoint(sparse), + sparse, + "sparse checkpoints stay wire-contract valid", + ); + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages( + [createAssistantMessage("assistant-message-1")], + [sparse], + ) + ); + }); + + it("should fail explicitly when conversion cannot carry the attached replay state", () => { + // An assistant turn with an inline tool result followed by more content + // splits into multiple assistant segments during conversion; exact + // replay metadata cannot be paired with either fragment. + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-lookup", + toolCallId: "call-1", + toolName: "lookup", + args: { query: "veryfront" }, + }, + { + type: "tool-result", + toolCallId: "call-1", + toolName: "lookup", + result: { matches: 1 }, + }, + { type: "text", text: "Found it." }, + ], + timestamp: 1, + } as Message; + applyProviderReplayCheckpointsToMessages([target], [createValidCheckpoint()]); + assertProviderReplayError(() => convertToTextGenerationRuntimeMessages([target])); + }); + it("should reconstruct the provider request assistant turn through the runtime converter", () => { const target = createAssistantMessage("assistant-message-1"); const checkpoint = createValidCheckpoint(); diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 664bd29be4..d5e9e83f33 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -90,9 +90,11 @@ function parseProviderReplayBlock( if (!isRecord(value)) { invalidCheckpoint("provider block must be an object", { index }); } + // Unknown key NAMES are attacker-controlled text and may smuggle signed + // material, so rejections report the index only, never the key. for (const key of Object.keys(value)) { if (!BLOCK_KEYS.has(key)) { - invalidCheckpoint("provider block carries an unknown key", { index, key }); + invalidCheckpoint("provider block carries an unknown key", { index }); } } if (value.type !== "provider-block") { @@ -112,9 +114,10 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe if (!isRecord(value)) { invalidCheckpoint("checkpoint must be an object"); } + // As with block keys: never echo an unknown key name. for (const key of Object.keys(value)) { if (!CHECKPOINT_KEYS.has(key)) { - invalidCheckpoint("checkpoint carries an unknown key", { key }); + invalidCheckpoint("checkpoint carries an unknown key"); } } if (value.version !== 1) { @@ -217,7 +220,49 @@ export function parseServerResolvedProviderReplayCheckpoints( if (!Array.isArray(value)) { invalidCheckpoint("server-resolved provider replay checkpoints must be an array"); } - return value.map((entry) => parseProviderReplayCheckpoint(entry)); + const checkpoints = value.map((entry) => parseProviderReplayCheckpoint(entry)); + // The server resolves at most one checkpoint per assistant turn. Duplicates + // would make replay state depend on array order, so they fail closed. + const messageIds = new Set(); + for (const checkpoint of checkpoints) { + if (messageIds.has(checkpoint.messageId)) { + invalidCheckpoint("delivery carries more than one checkpoint for one message anchor"); + } + messageIds.add(checkpoint.messageId); + } + return checkpoints; +} + +/** + * Assert this runtime version can reconstruct the checkpoint's assistant turn. + * + * Two contract-valid shapes are rejected until their reconstruction exists, + * because accepting them and replaying anything else would silently alter the + * assistant turn: non-anthropic providers (stage 1 reconstructs anthropic + * replay only) and sparse checkpoints (blocks at unrepresented positions are + * unknown to this runtime). + */ +export function assertReconstructibleProviderReplayCheckpoint( + checkpoint: ProviderReplayCheckpoint, +): void { + if (checkpoint.provider !== "anthropic") { + invalidCheckpoint( + "this runtime version reconstructs anthropic provider replay only", + { provider: checkpoint.provider }, + ); + } + if ( + checkpoint.totalPartCount !== checkpoint.providerBlocks.length || + checkpoint.providerBlockPositions.some((position, index) => position !== index) + ) { + invalidCheckpoint( + "sparse provider replay checkpoints are not reconstructible by this runtime version", + { + blockCount: checkpoint.providerBlocks.length, + totalPartCount: checkpoint.totalPartCount, + }, + ); + } } /** Convert a validated checkpoint into its durable root-run event. */ @@ -275,20 +320,26 @@ export function createAnthropicProviderReplayCheckpoint(input: { if (!Array.isArray(rawAssistantMessages) || rawAssistantMessages.length === 0) { invalidCheckpoint("anthropic raw assistant messages must be a non-empty array"); } + // pause_turn continuations carry one content array per provider response and + // the request builder replays each as its own assistant message. The v1 wire + // schema has no boundary field, so a multi-message turn cannot be + // represented; flattening it would silently change the replayed sequence. + if (rawAssistantMessages.length > 1) { + invalidCheckpoint( + "anthropic raw assistant turns with continuation boundaries are not representable in a v1 checkpoint", + { rawMessageCount: rawAssistantMessages.length }, + ); + } + const rawContent = rawAssistantMessages[0]; + if (!Array.isArray(rawContent)) { + invalidCheckpoint("anthropic raw assistant content must be an array"); + } const blocks: Record[] = []; - for (const [messageIndex, rawContent] of rawAssistantMessages.entries()) { - if (!Array.isArray(rawContent)) { - invalidCheckpoint("anthropic raw assistant content must be an array", { messageIndex }); - } - for (const [blockIndex, block] of rawContent.entries()) { - if (!isRecord(block) || typeof block.type !== "string") { - invalidCheckpoint("anthropic raw assistant block must be a typed object", { - messageIndex, - blockIndex, - }); - } - blocks.push(block); + for (const [blockIndex, block] of rawContent.entries()) { + if (!isRecord(block) || typeof block.type !== "string") { + invalidCheckpoint("anthropic raw assistant block must be a typed object", { blockIndex }); } + blocks.push(block); } if (blocks.length === 0 || blocks.length > MAX_PROVIDER_REPLAY_BLOCKS) { invalidCheckpoint( @@ -325,26 +376,24 @@ export function applyProviderReplayCheckpointsToMessages( checkpoints: readonly ProviderReplayCheckpoint[] | undefined, ): void { if (checkpoints === undefined || checkpoints.length === 0) return; + // Runtime support is a property of the delivery, not of which turns are + // still in context: an unsupported checkpoint fails the run even when its + // turn is absent, so deployment skew surfaces immediately. + for (const checkpoint of checkpoints) { + assertReconstructibleProviderReplayCheckpoint(checkpoint); + } for (const checkpoint of checkpoints) { const matches = messages.filter((message) => message.id === checkpoint.messageId); if (matches.length === 0) continue; if (matches.length > 1) { - invalidCheckpoint("checkpoint messageId matches more than one message", { - messageId: checkpoint.messageId, - }); + invalidCheckpoint("checkpoint messageId matches more than one message"); } const target = matches[0]!; if (target.role !== "assistant") { invalidCheckpoint("checkpoint messageId must anchor to an assistant message", { - messageId: checkpoint.messageId, role: target.role, }); } - if (checkpoint.provider !== "anthropic") { - invalidCheckpoint("this runtime has no replay reconstruction for the checkpoint provider", { - provider: checkpoint.provider, - }); - } // In-process metadata attached during this run is the same replay state at // first hand; the durable checkpoint never overrides it. if (readAttachedProviderMetadata(target) !== undefined) continue; diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 4641196d63..7a013dc5de 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -18,6 +18,7 @@ import type { } from "./text-generation-runtime-message-types.ts"; import { assertProviderReachableAttachment } from "./attachment-reachability.ts"; import { buildDataFileAnnotation } from "#veryfront/chat/types.ts"; +import { PROVIDER_REPLAY_CHECKPOINT_INVALID } from "#veryfront/errors"; import { getTextFromParts, getToolArguments, type Message, type ToolCallPart } from "../types.ts"; import { readAttachedProviderMetadata } from "./provider-metadata.ts"; @@ -475,7 +476,9 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( const providerMetadata = readAttachedProviderMetadata(message); const assistantMessages = messages.filter((entry) => entry.role === "assistant"); // Exact replay metadata describes one provider response. Attaching it after - // conversion split that response would pair it with an incomplete projection. + // conversion split that response would pair it with an incomplete + // projection, and dropping it would silently send the provider an unsigned + // canonical rebuild -- the failure replay state exists to prevent. if (providerMetadata !== undefined && assistantMessages.length === 1) { assistantMessages[0]!.providerMetadata = providerMetadata; } else if (providerMetadata !== undefined && messages.length === 0) { @@ -484,6 +487,12 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( content: [{ type: "text", text: "" }], providerMetadata, }); + } else if (providerMetadata !== undefined) { + throw PROVIDER_REPLAY_CHECKPOINT_INVALID.create({ + detail: + "exact provider replay metadata cannot survive an assistant turn conversion that splits the turn", + context: { assistantSegmentCount: assistantMessages.length }, + }); } return messages; From 0a8dcc7059cef16d416c6711f035da86648ffb7d Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 09:19:51 +0200 Subject: [PATCH 04/56] Guard provider replay delivery scope Bound server-resolved checkpoint deliveries to match the existing per-turn replay limits, and document the disabled-by-default stage-1 contract for upgrade review. Constraint: PR #4301 review requested only confirmed provider replay follow-ups at starting head 2eddfb1b4f74118e078bba95a91be588c6ddb03a. Rejected: Broad replay refactor | review findings only needed a changelog note and delivery bound. Confidence: high Scope-risk: narrow Directive: Keep checkpoint delivery limits aligned with the API-side wire contract before enabling emission. Tested: deno fmt --check CHANGELOG.md src/agent/runtime/provider-replay.ts src/agent/runtime/provider-replay.test.ts; deno lint src/agent/runtime/provider-replay.ts src/agent/runtime/provider-replay.test.ts; deno test -A src/agent/hosted/runtime-request-config.test.ts src/agent/runtime/provider-replay.test.ts Not-tested: Full repository test suite. --- CHANGELOG.md | 7 +++++++ src/agent/runtime/provider-replay.test.ts | 8 ++++++++ src/agent/runtime/provider-replay.ts | 6 ++++++ 3 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1881265646..1ebd048944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ versions are listed at ## Unreleased +### Agent provider replay checkpoint contract + +The provider replay checkpoint wire contract is now defined for hosted agent +runs, including strict validation of server-resolved checkpoints and a durable +checkpoint event shape. Emission remains disabled by default unless +`VERYFRONT_ENABLE_PROVIDER_REPLAY_CHECKPOINT_EMISSION=true` is set. + ### Breaking: `veryfront dev` enforces CSRF `security.csrf` now resolves the same way in every environment. Local diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 15fa4c37ab..3ce9783d91 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -268,6 +268,14 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should reject an oversized checkpoint delivery", () => { + const checkpoints = Array.from({ length: 101 }, (_, index) => ({ + ...createValidCheckpoint(), + messageId: `assistant-message-${index}`, + })); + assertProviderReplayError(() => parseServerResolvedProviderReplayCheckpoints(checkpoints)); + }); + it("should reject duplicate checkpoints for one message anchor", () => { const error = assertProviderReplayError(() => parseServerResolvedProviderReplayCheckpoints([ diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index d5e9e83f33..04ef8fc822 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -18,6 +18,7 @@ export const PROVIDER_REPLAY_CHECKPOINT_EMISSION_ENV_FLAG = "VERYFRONT_ENABLE_PROVIDER_REPLAY_CHECKPOINT_EMISSION" as const; const MAX_PROVIDER_REPLAY_BLOCKS = 100; +const MAX_PROVIDER_REPLAY_CHECKPOINTS = 100; const MAX_PROVIDER_REPLAY_TOTAL_PARTS = 10_000; const MAX_PROVIDER_REPLAY_MESSAGE_ID_LENGTH = 256; @@ -220,6 +221,11 @@ export function parseServerResolvedProviderReplayCheckpoints( if (!Array.isArray(value)) { invalidCheckpoint("server-resolved provider replay checkpoints must be an array"); } + if (value.length > MAX_PROVIDER_REPLAY_CHECKPOINTS) { + invalidCheckpoint( + `server-resolved provider replay checkpoints must contain at most ${MAX_PROVIDER_REPLAY_CHECKPOINTS} entries`, + ); + } const checkpoints = value.map((entry) => parseProviderReplayCheckpoint(entry)); // The server resolves at most one checkpoint per assistant turn. Duplicates // would make replay state depend on array order, so they fail closed. From 7a5086b5b03f49bd31030a8f4be712ea808a5d37 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 09:35:17 +0200 Subject: [PATCH 05/56] fix(agent): reject provider replay checkpoints anchored to segmented turns Also drops the unreachable checkpoint emission half; no runtime path constructs or emits these events. --- src/agent/runtime/provider-replay.test.ts | 202 ++-------------------- src/agent/runtime/provider-replay.ts | 128 ++------------ 2 files changed, 23 insertions(+), 307 deletions(-) diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 3ce9783d91..2a076d03d5 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -4,15 +4,9 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { VeryfrontError } from "#veryfront/errors"; import type { Message } from "../types.ts"; import { - AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE, applyProviderReplayCheckpointsToMessages, - createAnthropicProviderReplayCheckpoint, - createProviderReplayCheckpointEvent, - isProviderReplayCheckpointEmissionEnabled, - maybeCreateProviderReplayCheckpointEvent, parseProviderReplayCheckpoint, parseServerResolvedProviderReplayCheckpoints, - PROVIDER_REPLAY_CHECKPOINT_EMISSION_ENV_FLAG, type ProviderReplayCheckpoint, } from "./provider-replay.ts"; import { attachProviderMetadata, readAttachedProviderMetadata } from "./provider-metadata.ts"; @@ -301,191 +295,6 @@ describe("agent/runtime/provider-replay", () => { }); }); - describe("createProviderReplayCheckpointEvent", () => { - it("should stamp the durable event type onto a valid checkpoint", () => { - const checkpoint = createValidCheckpoint(); - const event = createProviderReplayCheckpointEvent(checkpoint); - assertEquals(event.type, AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE, "event type"); - const { type: _type, ...payload } = event; - assertEquals(payload, checkpoint, "event payload round trip"); - }); - - it("should refuse to mint an event from a malformed checkpoint", () => { - assertProviderReplayError(() => - createProviderReplayCheckpointEvent( - { ...createValidCheckpoint(), version: 9 } as unknown as ProviderReplayCheckpoint, - ) - ); - }); - }); - - describe("emission gate", () => { - it("should default to disabled when the flag is unset", () => { - assertEquals( - isProviderReplayCheckpointEmissionEnabled(() => undefined), - false, - "unset flag stays off", - ); - assertEquals(isProviderReplayCheckpointEmissionEnabled(), false, "real environment default"); - }); - - it("should only enable on the exact literal true", () => { - for (const value of ["1", "TRUE", "yes", "on", ""]) { - assertEquals( - isProviderReplayCheckpointEmissionEnabled(() => value), - false, - `"${value}" stays off`, - ); - } - assertEquals( - isProviderReplayCheckpointEmissionEnabled((name) => - name === PROVIDER_REPLAY_CHECKPOINT_EMISSION_ENV_FLAG ? "true" : undefined - ), - true, - "literal true enables", - ); - }); - - it("should emit nothing while the gate is off", () => { - assertEquals( - maybeCreateProviderReplayCheckpointEvent({ - checkpoint: createValidCheckpoint(), - readEnv: () => undefined, - }), - null, - "gate off emits nothing", - ); - assertEquals( - maybeCreateProviderReplayCheckpointEvent({ checkpoint: createValidCheckpoint() }), - null, - "gate off by default in this environment", - ); - }); - - it("should mint the event only when the gate is on", () => { - const event = maybeCreateProviderReplayCheckpointEvent({ - checkpoint: createValidCheckpoint(), - readEnv: () => "true", - }); - assertEquals( - event?.type, - AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE, - "gate on emits the event", - ); - }); - }); - - describe("createAnthropicProviderReplayCheckpoint", () => { - it("should return null when no provider metadata is present", () => { - assertEquals( - createAnthropicProviderReplayCheckpoint({ - messageId: "assistant-message-1", - providerMetadata: undefined, - }), - null, - "absent metadata", - ); - assertEquals( - createAnthropicProviderReplayCheckpoint({ - messageId: "assistant-message-1", - providerMetadata: { google: { rawAssistantParts: [] } }, - }), - null, - "non-anthropic metadata is not replay-required", - ); - }); - - it("should build a checkpoint from a single raw assistant message preserving order", () => { - const rawAssistantMessages = [ - [ - { type: "thinking", thinking: "", signature: SIGNATURE }, - { type: "tool_use", id: "call-1", name: "lookup", input: { query: "veryfront" } }, - { type: "text", text: "Looking that up." }, - ], - ]; - const checkpoint = createAnthropicProviderReplayCheckpoint({ - messageId: "assistant-message-1", - providerMetadata: { anthropic: { rawAssistantMessages } }, - }); - assertEquals(checkpoint?.provider, "anthropic", "provider"); - assertEquals(checkpoint?.messageId, "assistant-message-1", "message anchor"); - assertEquals(checkpoint?.totalPartCount, 3, "total part count"); - assertEquals( - checkpoint?.providerBlockPositions, - [0, 1, 2], - "positions dense by construction", - ); - assertEquals( - checkpoint?.providerBlocks.map((block) => block.block), - rawAssistantMessages[0]!, - "blocks carried in original order", - ); - assertEquals( - checkpoint?.providerBlocks[0]?.block.signature, - SIGNATURE, - "signature carried byte-exact beside empty thinking text", - ); - assertEquals(checkpoint?.providerBlocks[0]?.block.thinking, "", "empty displayed thinking"); - assertEquals( - parseProviderReplayCheckpoint(checkpoint), - checkpoint, - "emitted checkpoint satisfies the wire contract", - ); - }); - - it("should fail explicitly on continuation boundaries a v1 checkpoint cannot represent", () => { - // pause_turn continuations produce one raw content array per provider - // response; the wire schema has no boundary field, so flattening would - // silently change the replayed assistant-message sequence. - assertProviderReplayError(() => - createAnthropicProviderReplayCheckpoint({ - messageId: "assistant-message-1", - providerMetadata: { - anthropic: { - rawAssistantMessages: [ - [{ type: "text", text: "first response" }], - [{ type: "text", text: "continued response" }], - ], - }, - }, - }) - ); - }); - - it("should fail explicitly on malformed raw assistant metadata", () => { - for ( - const anthropic of [ - { rawAssistantMessages: [] }, - { rawAssistantMessages: "not-an-array" }, - { rawAssistantMessages: [["not-a-block"]] }, - { rawAssistantMessages: [[{ thinking: "missing type" }]] }, - ] - ) { - assertProviderReplayError(() => - createAnthropicProviderReplayCheckpoint({ - messageId: "assistant-message-1", - providerMetadata: { anthropic }, - }) - ); - } - }); - - it("should fail explicitly when the turn exceeds the checkpoint block capacity", () => { - assertProviderReplayError(() => - createAnthropicProviderReplayCheckpoint({ - messageId: "assistant-message-1", - providerMetadata: { - anthropic: { - rawAssistantMessages: [ - Array.from({ length: 101 }, () => ({ type: "text", text: "block" })), - ], - }, - }, - }) - ); - }); - }); - describe("applyProviderReplayCheckpointsToMessages", () => { it("should attach opaque replay metadata to the matching assistant turn", () => { const target = createAssistantMessage("assistant-message-1"); @@ -551,6 +360,17 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should fail explicitly when the anchored turn was split into segments", () => { + const messages = [ + createAssistantMessage("assistant-message-1"), + { id: "tool-1", role: "tool", parts: [], timestamp: 1 }, + createAssistantMessage("assistant-message-1-1"), + ] as Message[]; + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages(messages, [createValidCheckpoint()]) + ); + }); + it("should fail explicitly on ambiguous duplicate message ids", () => { const messages = [ createAssistantMessage("assistant-message-1"), diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 04ef8fc822..1e019d8038 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -1,22 +1,7 @@ import { PROVIDER_REPLAY_CHECKPOINT_INVALID } from "#veryfront/errors"; -import { getEnv } from "#veryfront/platform/compat/process.ts"; import type { Message } from "../types.ts"; import { attachProviderMetadata, readAttachedProviderMetadata } from "./provider-metadata.ts"; -/** - * Durable run event type carrying provider-native replay state. - * - * Mirrors `AgentRunProviderReplayCheckpointPayloadSchema` in veryfront-api; - * both sides validate the same shape so a payload accepted here is accepted - * there and vice versa. - */ -export const AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE = - "AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT" as const; - -/** Environment flag gating checkpoint emission; anything but "true" stays off. */ -export const PROVIDER_REPLAY_CHECKPOINT_EMISSION_ENV_FLAG = - "VERYFRONT_ENABLE_PROVIDER_REPLAY_CHECKPOINT_EMISSION" as const; - const MAX_PROVIDER_REPLAY_BLOCKS = 100; const MAX_PROVIDER_REPLAY_CHECKPOINTS = 100; const MAX_PROVIDER_REPLAY_TOTAL_PARTS = 10_000; @@ -62,11 +47,6 @@ export type ProviderReplayCheckpoint = { emittedAt?: number; }; -/** Durable event form of a provider replay checkpoint. */ -export type ProviderReplayCheckpointEvent = ProviderReplayCheckpoint & { - type: typeof AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE; -}; - /** * Fails checkpoint validation without echoing payload contents. Blocks carry * signed reasoning material, so details name fields and indices only. @@ -271,102 +251,6 @@ export function assertReconstructibleProviderReplayCheckpoint( } } -/** Convert a validated checkpoint into its durable root-run event. */ -export function createProviderReplayCheckpointEvent( - checkpoint: ProviderReplayCheckpoint, -): ProviderReplayCheckpointEvent { - return { - type: AGENT_RUN_PROVIDER_REPLAY_CHECKPOINT_EVENT_TYPE, - ...parseProviderReplayCheckpoint(checkpoint), - }; -} - -/** Return whether checkpoint emission is enabled; default off in every environment. */ -export function isProviderReplayCheckpointEmissionEnabled( - readEnv: (name: string) => string | undefined = getEnv, -): boolean { - return readEnv(PROVIDER_REPLAY_CHECKPOINT_EMISSION_ENV_FLAG) === "true"; -} - -/** - * Sole emission entry point for the durable checkpoint event. - * - * Returns null while the emission gate is off, so no runtime path can persist - * a checkpoint before the API-side append support ships and the flag is turned - * on deliberately (stage 4 of veryfront-issue-inbox#522). - */ -export function maybeCreateProviderReplayCheckpointEvent(input: { - checkpoint: ProviderReplayCheckpoint; - readEnv?: (name: string) => string | undefined; -}): ProviderReplayCheckpointEvent | null { - if (!isProviderReplayCheckpointEmissionEnabled(input.readEnv ?? getEnv)) { - return null; - } - return createProviderReplayCheckpointEvent(input.checkpoint); -} - -/** - * Build a checkpoint from the Anthropic raw-replay metadata the provider - * already emits in-process (`providerMetadata.anthropic.rawAssistantMessages`). - * - * Returns null when the metadata carries no Anthropic replay state; malformed - * replay state fails explicitly rather than emitting a partial checkpoint. - */ -export function createAnthropicProviderReplayCheckpoint(input: { - messageId: string; - providerMetadata: Record | undefined; -}): ProviderReplayCheckpoint | null { - const anthropic = input.providerMetadata?.anthropic; - if (anthropic === undefined) return null; - if (!isRecord(anthropic)) { - invalidCheckpoint("anthropic provider metadata must be an object"); - } - const rawAssistantMessages = anthropic.rawAssistantMessages; - if (rawAssistantMessages === undefined) return null; - if (!Array.isArray(rawAssistantMessages) || rawAssistantMessages.length === 0) { - invalidCheckpoint("anthropic raw assistant messages must be a non-empty array"); - } - // pause_turn continuations carry one content array per provider response and - // the request builder replays each as its own assistant message. The v1 wire - // schema has no boundary field, so a multi-message turn cannot be - // represented; flattening it would silently change the replayed sequence. - if (rawAssistantMessages.length > 1) { - invalidCheckpoint( - "anthropic raw assistant turns with continuation boundaries are not representable in a v1 checkpoint", - { rawMessageCount: rawAssistantMessages.length }, - ); - } - const rawContent = rawAssistantMessages[0]; - if (!Array.isArray(rawContent)) { - invalidCheckpoint("anthropic raw assistant content must be an array"); - } - const blocks: Record[] = []; - for (const [blockIndex, block] of rawContent.entries()) { - if (!isRecord(block) || typeof block.type !== "string") { - invalidCheckpoint("anthropic raw assistant block must be a typed object", { blockIndex }); - } - blocks.push(block); - } - if (blocks.length === 0 || blocks.length > MAX_PROVIDER_REPLAY_BLOCKS) { - invalidCheckpoint( - `anthropic raw assistant turn must carry 1-${MAX_PROVIDER_REPLAY_BLOCKS} blocks`, - { blockCount: blocks.length }, - ); - } - return parseProviderReplayCheckpoint({ - version: 1, - messageId: input.messageId, - provider: "anthropic", - providerBlocks: blocks.map((block) => ({ - type: "provider-block", - provider: "anthropic", - block, - })), - providerBlockPositions: blocks.map((_, index) => index), - totalPartCount: blocks.length, - }); -} - /** * Attach delivered replay state to the assistant turns it anchors to. * @@ -390,6 +274,18 @@ export function applyProviderReplayCheckpointsToMessages( } for (const checkpoint of checkpoints) { const matches = messages.filter((message) => message.id === checkpoint.messageId); + // A turn carrying tool output is split into `${id}-1`, `${id}-2`, ... segments + // downstream; attaching a whole-turn checkpoint to segment 0 while the later + // segments keep their own content would replay an altered assistant turn. + const segmentPrefix = `${checkpoint.messageId}-`; + if ( + messages.some((message) => + message.id.startsWith(segmentPrefix) && + /^\d+$/.test(message.id.slice(segmentPrefix.length)) + ) + ) { + invalidCheckpoint("checkpoint messageId anchors to a segmented assistant turn"); + } if (matches.length === 0) continue; if (matches.length > 1) { invalidCheckpoint("checkpoint messageId matches more than one message"); From 371ac158ec5baaceecba51064357a4ca44eb2901 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 09:36:31 +0200 Subject: [PATCH 06/56] docs(changelog): drop provider replay checkpoint entry with no behavior change --- CHANGELOG.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebd048944..1881265646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,6 @@ versions are listed at ## Unreleased -### Agent provider replay checkpoint contract - -The provider replay checkpoint wire contract is now defined for hosted agent -runs, including strict validation of server-resolved checkpoints and a durable -checkpoint event shape. Emission remains disabled by default unless -`VERYFRONT_ENABLE_PROVIDER_REPLAY_CHECKPOINT_EMISSION=true` is set. - ### Breaking: `veryfront dev` enforces CSRF `security.csrf` now resolves the same way in every environment. Local From 66ccbac8d8ecdad3f822f31d0fd715ca263c30a4 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 09:38:28 +0200 Subject: [PATCH 07/56] refactor(runtime): drop redundant provider replay checkpoint re-parse The checkpoint delivery is already parsed and asserted reconstructible in getServerResolvedProviderReplayCheckpoints, and __vfProviderReplayCheckpoints is written only from that result, so the runtime getter returns the typed value directly. --- src/agent/runtime/runtime-tool-config.test.ts | 14 ++++++-------- src/agent/runtime/runtime-tool-config.ts | 17 ++++++----------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/agent/runtime/runtime-tool-config.test.ts b/src/agent/runtime/runtime-tool-config.test.ts index 6ccc0151f7..7aad980b41 100644 --- a/src/agent/runtime/runtime-tool-config.test.ts +++ b/src/agent/runtime/runtime-tool-config.test.ts @@ -1,7 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertInstanceOf, assertThrows } from "#veryfront/testing/assert.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { VeryfrontError } from "#veryfront/errors"; import type { AgentConfig } from "../types.ts"; import type { ProviderReplayCheckpoint } from "./provider-replay.ts"; import { @@ -234,7 +233,7 @@ describe("agent/runtime-tool-config", () => { assertEquals(getRuntimeProviderReplayCheckpoints(runtimeConfig()), undefined); }); - it("returns validated checkpoints from trusted host state", () => { + it("returns the checkpoints resolved by the trusted host", () => { const checkpoint: ProviderReplayCheckpoint = { version: 1, messageId: "assistant-message-1", @@ -255,14 +254,13 @@ describe("agent/runtime-tool-config", () => { ); }); - it("fails explicitly on malformed trusted state instead of degrading replay", () => { - const error = assertThrows(() => + it("passes host-resolved state through without re-parsing it", () => { + assertEquals( getRuntimeProviderReplayCheckpoints(runtimeConfig({ __vfProviderReplayCheckpoints: [{ version: 1 }], - })) + })), + [{ version: 1 }] as unknown as readonly ProviderReplayCheckpoint[], ); - assertInstanceOf(error, VeryfrontError); - assertEquals(error.slug, "provider-replay-checkpoint-invalid"); }); }); }); diff --git a/src/agent/runtime/runtime-tool-config.ts b/src/agent/runtime/runtime-tool-config.ts index f9e1717361..0dc9a467a9 100644 --- a/src/agent/runtime/runtime-tool-config.ts +++ b/src/agent/runtime/runtime-tool-config.ts @@ -8,10 +8,7 @@ import { isValidToolExposureCheckpointName, type ToolExposureCheckpoint, } from "./tool-exposure.ts"; -import { - parseServerResolvedProviderReplayCheckpoints, - type ProviderReplayCheckpoint, -} from "./provider-replay.ts"; +import { type ProviderReplayCheckpoint } from "./provider-replay.ts"; /** Internal schema-loading mode derived from the authored tools selector. */ export type RuntimeToolLoadingMode = "eager" | "deferred"; @@ -118,17 +115,15 @@ export function getRuntimeToolExposureCheckpoint( } /** - * Return the verified provider replay checkpoints for this run. + * Return the provider replay checkpoints the trusted host resolved for this run. * - * Malformed internal state fails explicitly: replay state guards provider - * protocol correctness, so it never degrades into an unsigned replay. + * The delivery is parsed and asserted reconstructible at request preparation; + * `applyProviderReplayCheckpointsToMessages` re-asserts before use. */ export function getRuntimeProviderReplayCheckpoints( config: AgentConfig, -): ProviderReplayCheckpoint[] | undefined { - const value = (config as RuntimeToolFilterConfig).__vfProviderReplayCheckpoints; - if (value === undefined) return undefined; - return parseServerResolvedProviderReplayCheckpoints(value); +): readonly ProviderReplayCheckpoint[] | undefined { + return (config as RuntimeToolFilterConfig).__vfProviderReplayCheckpoints; } /** Return whether the trusted host requires checkpoint durability before continuation. */ From 05cd09e3005bbbb47c5d5bba8adad847cb58e2cd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 09:59:44 +0200 Subject: [PATCH 08/56] Honor internal runtime import boundaries The provider replay source file was the only newly reviewed source still using a sibling runtime import where this repository expects internal source imports to go through the #veryfront alias. Keep the fix narrow to the reviewed production source and leave existing test-local relative imports alone. Constraint: CodeRabbit review on PR #4301 flagged the source import contract at exact head 66ccbac8d8ecdad3f822f31d0fd715ca263c30a4. Rejected: Rewriting all provider-metadata imports | broader churn outside the reviewed source finding. Confidence: high Scope-risk: narrow Tested: deno fmt src/agent/runtime/provider-replay.ts Tested: deno check src/agent/runtime/provider-replay.ts Tested: deno test -A src/agent/runtime/provider-replay.test.ts Not-tested: full repository CI locally; GitHub CI is already running on the PR. --- src/agent/runtime/provider-replay.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 1e019d8038..fd6d4cdf9e 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -1,6 +1,9 @@ import { PROVIDER_REPLAY_CHECKPOINT_INVALID } from "#veryfront/errors"; +import { + attachProviderMetadata, + readAttachedProviderMetadata, +} from "#veryfront/agent/runtime/provider-metadata.ts"; import type { Message } from "../types.ts"; -import { attachProviderMetadata, readAttachedProviderMetadata } from "./provider-metadata.ts"; const MAX_PROVIDER_REPLAY_BLOCKS = 100; const MAX_PROVIDER_REPLAY_CHECKPOINTS = 100; From e0c4d4573c5a73975ac21d839c732ebff21d7434 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 10:00:15 +0200 Subject: [PATCH 09/56] Anchor provider replay only by exact message id Provider replay checkpoints should attach to the exact persisted assistant message they name. Prefix-matching message IDs treats unrelated valid IDs as split-turn provenance and rejects replay state that is otherwise unambiguous. Constraint: Codex review on PR #4301 identified the prefix heuristic as a false-positive path for valid messages. Rejected: Keep prefix matching and add more ID exceptions | message ID shape is not a provenance contract. Confidence: high Scope-risk: narrow Tested: deno check src/agent/runtime/provider-replay.ts Tested: deno test -A src/agent/runtime/provider-replay.test.ts Not-tested: full repository CI locally; GitHub CI is already running on the PR. --- src/agent/runtime/provider-replay.test.ts | 27 ++++++++++++++++------- src/agent/runtime/provider-replay.ts | 12 ---------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 2a076d03d5..1a085f391e 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -360,14 +360,25 @@ describe("agent/runtime/provider-replay", () => { ); }); - it("should fail explicitly when the anchored turn was split into segments", () => { - const messages = [ - createAssistantMessage("assistant-message-1"), - { id: "tool-1", role: "tool", parts: [], timestamp: 1 }, - createAssistantMessage("assistant-message-1-1"), - ] as Message[]; - assertProviderReplayError(() => - applyProviderReplayCheckpointsToMessages(messages, [createValidCheckpoint()]) + it("should not treat matching ID prefixes as split-turn provenance", () => { + const target = createAssistantMessage("assistant-message-1"); + const unrelated = createAssistantMessage("assistant-message-1-1"); + applyProviderReplayCheckpointsToMessages([target, unrelated], [createValidCheckpoint()]); + assertEquals( + readAttachedProviderMetadata(target), + { + anthropic: { + rawAssistantMessages: [ + createValidCheckpoint().providerBlocks.map((block) => block.block), + ], + }, + }, + "the exact checkpoint target receives replay metadata", + ); + assertEquals( + readAttachedProviderMetadata(unrelated), + undefined, + "an unrelated valid message id with a shared prefix stays untouched", ); }); diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index fd6d4cdf9e..d726483101 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -277,18 +277,6 @@ export function applyProviderReplayCheckpointsToMessages( } for (const checkpoint of checkpoints) { const matches = messages.filter((message) => message.id === checkpoint.messageId); - // A turn carrying tool output is split into `${id}-1`, `${id}-2`, ... segments - // downstream; attaching a whole-turn checkpoint to segment 0 while the later - // segments keep their own content would replay an altered assistant turn. - const segmentPrefix = `${checkpoint.messageId}-`; - if ( - messages.some((message) => - message.id.startsWith(segmentPrefix) && - /^\d+$/.test(message.id.slice(segmentPrefix.length)) - ) - ) { - invalidCheckpoint("checkpoint messageId anchors to a segmented assistant turn"); - } if (matches.length === 0) continue; if (matches.length > 1) { invalidCheckpoint("checkpoint messageId matches more than one message"); From 4e0c8538f401d5f996cbb8465d950d88c82ffc11 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 10:38:49 +0200 Subject: [PATCH 10/56] Clarify split provider metadata failures Provider metadata is carried through a generic runtime side channel that can also hold exact replay state. Split-turn conversion still fails closed, but the diagnostic should describe provider metadata generically rather than implying every attached payload is a replay checkpoint. Constraint: Exact replay still requires fail-closed behavior when metadata cannot be attached to a single assistant projection. Rejected: Drop generic provider metadata on split turns | would silently change provider request reconstruction semantics. Confidence: high Scope-risk: narrow Directive: Keep provider metadata split failures fail-closed unless a provider-specific reconciler can preserve semantics. Tested: deno task test:file src/agent/runtime/text-generation-runtime-message-converter.test.ts; deno task test:file src/agent/runtime/provider-replay.test.ts; deno fmt --check src/agent/runtime/text-generation-runtime-message-converter.ts src/agent/runtime/text-generation-runtime-message-converter.test.ts; deno check src/agent/runtime/text-generation-runtime-message-converter.ts src/agent/runtime/text-generation-runtime-message-converter.test.ts --- ...neration-runtime-message-converter.test.ts | 39 ++++++++++++++++++- ...xt-generation-runtime-message-converter.ts | 7 ++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index a305652176..8b9496276a 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -1,6 +1,12 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertStringIncludes, assertThrows } from "#veryfront/testing/assert.ts"; +import { + assertEquals, + assertInstanceOf, + assertStringIncludes, + assertThrows, +} from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { VeryfrontError } from "#veryfront/errors"; import { convertToTextGenerationRuntimeMessage, convertToTextGenerationRuntimeMessages, @@ -507,6 +513,37 @@ describe("text-generation-runtime-message-converter", () => { }]); }); + it("reports split generic provider metadata without checkpoint-specific wording", () => { + const providerMetadata = { testProvider: { opaque: true } }; + const message = attachProviderMetadata({ + id: "a-split", + role: "assistant", + parts: [ + { + type: "tool-lookup", + toolCallId: "call-1", + toolName: "lookup", + args: { query: "veryfront" }, + }, + { + type: "tool-result", + toolCallId: "call-1", + toolName: "lookup", + result: { matches: 1 }, + }, + { type: "text", text: "Found it." }, + ], + } as unknown as Message, providerMetadata); + + const error = assertThrows(() => convertToTextGenerationRuntimeMessages([message])); + assertInstanceOf(error, VeryfrontError); + + assertEquals( + error.detail, + "provider metadata cannot be attached after assistant turn splitting", + ); + }); + it("omits provider-executed tool-only assistant messages from replay", () => { const messages = [ { id: "u1", role: "user", parts: [{ type: "text", text: "search tax guidance" }] }, diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 7a013dc5de..16afde3871 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -475,10 +475,10 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( const providerMetadata = readAttachedProviderMetadata(message); const assistantMessages = messages.filter((entry) => entry.role === "assistant"); - // Exact replay metadata describes one provider response. Attaching it after + // Provider metadata describes one provider response. Attaching it after // conversion split that response would pair it with an incomplete // projection, and dropping it would silently send the provider an unsigned - // canonical rebuild -- the failure replay state exists to prevent. + // canonical rebuild when the metadata came from replay state. if (providerMetadata !== undefined && assistantMessages.length === 1) { assistantMessages[0]!.providerMetadata = providerMetadata; } else if (providerMetadata !== undefined && messages.length === 0) { @@ -489,8 +489,7 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( }); } else if (providerMetadata !== undefined) { throw PROVIDER_REPLAY_CHECKPOINT_INVALID.create({ - detail: - "exact provider replay metadata cannot survive an assistant turn conversion that splits the turn", + detail: "provider metadata cannot be attached after assistant turn splitting", context: { assistantSegmentCount: assistantMessages.length }, }); } From 5e21bde801e83099efaa852cdf6a95a48caebcac Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 10:48:13 +0200 Subject: [PATCH 11/56] Validate replay checkpoints before reuse Delivered replay blocks are already authenticated by the hosted envelope, but the runtime still has to prove the checkpoint belongs to the anchored assistant turn. This checks the Anthropic projection before attaching raw replay metadata, disambiguates same-source tool siblings by assistant role, and preserves checkpointed historical tool inputs through hosted message preparation so later provider validation sees the original canonical input. Constraint: Provider replay checkpoints must not leak raw signed or redacted block payloads into public message JSON or diagnostic text. Rejected: Add a new checkpoint digest field | would require a wire contract migration for an issue that can be solved by validating the existing dense projection. Confidence: high Scope-risk: narrow Directive: Do not compact or rewrite provider-visible content for turns with verified provider replay checkpoints before replay metadata is attached. Tested: deno task test:file src/agent/runtime/provider-replay.test.ts Tested: deno task test:file src/agent/runtime/text-generation-runtime-message-converter.test.ts Tested: deno task test:file src/chat/message-prep.test.ts Tested: deno task test:file src/agent/hosted/chat-preparation.test.ts Tested: deno task test:file src/agent/runtime/message-preparation.test.ts Tested: deno fmt --check and deno check for changed replay/preparation/converter files --- src/agent/hosted/chat-preparation.test.ts | 51 +++++++++++ src/agent/hosted/chat-preparation.ts | 16 +++- src/agent/runtime/provider-replay.test.ts | 100 +++++++++++++++++++--- src/agent/runtime/provider-replay.ts | 67 +++++++++++++-- src/chat/message-prep.ts | 11 +++ 5 files changed, 226 insertions(+), 19 deletions(-) diff --git a/src/agent/hosted/chat-preparation.test.ts b/src/agent/hosted/chat-preparation.test.ts index 6128a48fd7..8358698310 100644 --- a/src/agent/hosted/chat-preparation.test.ts +++ b/src/agent/hosted/chat-preparation.test.ts @@ -1700,6 +1700,57 @@ Deno.test("prepareHostedChatRuntimeMessages reports historical tool input compac assertEquals((diagnostics[0] as { toolCallId?: string }).toolCallId, "tool-render-widget"); }); +Deno.test("prepareHostedChatRuntimeMessages preserves checkpointed historical tool inputs", async () => { + const diagnostics: HistoricalToolInputCompactionDiagnostic[] = []; + const marker = "CHECKPOINTED_TOOL_INPUT_MARKER"; + const messages = await prepareHostedChatRuntimeMessages( + [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Render the widget." }], + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ + type: "dynamic-tool", + toolName: "render_widget", + toolCallId: "tool-render-widget", + input: { + targetPath: "components/Widget.tsx", + source: `${marker}:${"export const widget = true;\n".repeat(2000)}`, + }, + state: "output-available", + output: { ok: true }, + }], + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Update the widget." }], + }, + ], + { + providerReplayCheckpointMessageIds: ["assistant-1"], + historicalToolInputRetention: { + diagnostics, + resolvePolicy: (toolName) => + toolName === "render_widget" + ? { + compactCompletedInput: true, + compactAfterChars: 100, + } + : undefined, + }, + }, + ); + + const serialized = JSON.stringify(messages); + assertEquals(serialized.includes(marker), true); + assertEquals(diagnostics, []); +}); + Deno.test("prepareHostedChatRuntimeCreationOptions applies the skill selector and owner scope", async () => { const skills = [ { diff --git a/src/agent/hosted/chat-preparation.ts b/src/agent/hosted/chat-preparation.ts index 7016978d57..8f05ba62a9 100644 --- a/src/agent/hosted/chat-preparation.ts +++ b/src/agent/hosted/chat-preparation.ts @@ -74,6 +74,7 @@ export type PrepareHostedChatRuntimeMessagesOptions = authToken?: string; apiUrl?: string | URL; projectId?: string | null; + providerReplayCheckpointMessageIds?: readonly string[]; }; /** Context for hosted chat runtime preparation root run. */ @@ -584,6 +585,9 @@ export async function prepareHostedChatExecution< abortSignal: input.abortSignal, historicalToolInputRetention: { diagnostics: historicalToolInputCompactions, + preserveSourceMessageIds: input.serverResolvedProviderReplayCheckpoints?.map( + (checkpoint) => checkpoint.messageId, + ), }, }, ); @@ -649,7 +653,11 @@ export async function prepareHostedChatRuntimeMessages( providerOwnedToolNames: options.providerOwnedToolNames, abortSignal: options.abortSignal, fileContentFetchTimeoutMs: options.fileContentFetchTimeoutMs, - historicalToolInputRetention: options.historicalToolInputRetention, + historicalToolInputRetention: { + ...options.historicalToolInputRetention, + preserveSourceMessageIds: options.providerReplayCheckpointMessageIds ?? + options.historicalToolInputRetention?.preserveSourceMessageIds, + }, }); } const authToken = options.authToken; @@ -661,7 +669,11 @@ export async function prepareHostedChatRuntimeMessages( providerOwnedToolNames: options.providerOwnedToolNames, abortSignal: options.abortSignal, fileContentFetchTimeoutMs: options.fileContentFetchTimeoutMs, - historicalToolInputRetention: options.historicalToolInputRetention, + historicalToolInputRetention: { + ...options.historicalToolInputRetention, + preserveSourceMessageIds: options.providerReplayCheckpointMessageIds ?? + options.historicalToolInputRetention?.preserveSourceMessageIds, + }, resolveFileUrl: ({ uploadId }) => getRuntimeUploadUrl({ apiUrl, diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 1a085f391e..98101e6fb7 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -47,6 +47,23 @@ function createValidCheckpoint(): ProviderReplayCheckpoint { }; } +function createTextCheckpoint(text: string): ProviderReplayCheckpoint { + return { + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [ + { + type: "provider-block", + provider: "anthropic", + block: { type: "text", text }, + }, + ], + providerBlockPositions: [0], + totalPartCount: 1, + }; +} + function assertProviderReplayError(operation: () => unknown): VeryfrontError { const error = assertThrows(operation); assertInstanceOf(error, VeryfrontError); @@ -87,6 +104,24 @@ function createAssistantMessage(id: string): Message { } as Message; } +function createCheckpointedAssistantMessage(id = "assistant-message-1"): Message { + return { + id, + role: "assistant", + parts: [ + { type: "reasoning", signature: SIGNATURE }, + { type: "reasoning", redactedData: REDACTED_DATA }, + { + type: "tool-call", + toolCallId: "call-1", + toolName: "lookup", + args: { query: "veryfront" }, + }, + ], + timestamp: 1, + } as Message; +} + describe("agent/runtime/provider-replay", () => { describe("parseProviderReplayCheckpoint", () => { it("should round-trip a valid multi-block checkpoint preserving block order", () => { @@ -297,7 +332,7 @@ describe("agent/runtime/provider-replay", () => { describe("applyProviderReplayCheckpointsToMessages", () => { it("should attach opaque replay metadata to the matching assistant turn", () => { - const target = createAssistantMessage("assistant-message-1"); + const target = createCheckpointedAssistantMessage("assistant-message-1"); const untouched = createAssistantMessage("assistant-message-2"); const messages = [ { id: "user-1", role: "user", parts: [{ type: "text", text: "hi" }], timestamp: 0 }, @@ -323,9 +358,9 @@ describe("agent/runtime/provider-replay", () => { "turns without replay state stay untouched", ); assertEquals( - JSON.stringify(messages).includes(SIGNATURE), + JSON.stringify(messages).includes("rawAssistantMessages"), false, - "signed material never lands on the public message objects", + "raw replay metadata never lands on the public message objects", ); }); @@ -363,14 +398,13 @@ describe("agent/runtime/provider-replay", () => { it("should not treat matching ID prefixes as split-turn provenance", () => { const target = createAssistantMessage("assistant-message-1"); const unrelated = createAssistantMessage("assistant-message-1-1"); - applyProviderReplayCheckpointsToMessages([target, unrelated], [createValidCheckpoint()]); + const checkpoint = createTextCheckpoint("Looking that up."); + applyProviderReplayCheckpointsToMessages([target, unrelated], [checkpoint]); assertEquals( readAttachedProviderMetadata(target), { anthropic: { - rawAssistantMessages: [ - createValidCheckpoint().providerBlocks.map((block) => block.block), - ], + rawAssistantMessages: [[{ type: "text", text: "Looking that up." }]], }, }, "the exact checkpoint target receives replay metadata", @@ -392,6 +426,47 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should allow a matching tool message sibling for one assistant anchor", () => { + const target = createAssistantMessage("assistant-message-1"); + const toolSibling = { + id: "assistant-message-1", + role: "tool", + parts: [{ + type: "tool-result", + toolCallId: "call-1", + toolName: "lookup", + result: { matches: 1 }, + }], + timestamp: 2, + } as Message; + const checkpoint = createTextCheckpoint("Looking that up."); + + applyProviderReplayCheckpointsToMessages([target, toolSibling], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { + anthropic: { + rawAssistantMessages: [[{ type: "text", text: "Looking that up." }]], + }, + }, + "the assistant anchor receives replay metadata despite a same-source tool sibling", + ); + assertEquals( + readAttachedProviderMetadata(toolSibling), + undefined, + "tool siblings with the same source id are not replay anchors", + ); + }); + + it("should fail explicitly when dense checkpoint blocks do not match the anchor", () => { + const textOnlyTurn = createAssistantMessage("assistant-message-1"); + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([textOnlyTurn], [createValidCheckpoint()]) + ); + }); + it("should fail explicitly for providers this runtime cannot reconstruct", () => { const checkpoint: ProviderReplayCheckpoint = { ...createValidCheckpoint(), @@ -413,7 +488,7 @@ describe("agent/runtime/provider-replay", () => { }); it("should keep fresher in-process replay metadata over a delivered checkpoint", () => { - const target = createAssistantMessage("assistant-message-1"); + const target = createCheckpointedAssistantMessage("assistant-message-1"); const inProcess = { anthropic: { rawAssistantMessages: [[{ type: "text", text: "live" }]] } }; // Simulate the in-process attach that happens right after a streamed step. attachProviderMetadata(target, inProcess); @@ -448,7 +523,7 @@ describe("agent/runtime/provider-replay", () => { ); }); - it("should fail explicitly when conversion cannot carry the attached replay state", () => { + it("should fail explicitly when a split turn cannot carry replay state", () => { // An assistant turn with an inline tool result followed by more content // splits into multiple assistant segments during conversion; exact // replay metadata cannot be paired with either fragment. @@ -472,12 +547,13 @@ describe("agent/runtime/provider-replay", () => { ], timestamp: 1, } as Message; - applyProviderReplayCheckpointsToMessages([target], [createValidCheckpoint()]); - assertProviderReplayError(() => convertToTextGenerationRuntimeMessages([target])); + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [createValidCheckpoint()]) + ); }); it("should reconstruct the provider request assistant turn through the runtime converter", () => { - const target = createAssistantMessage("assistant-message-1"); + const target = createCheckpointedAssistantMessage("assistant-message-1"); const checkpoint = createValidCheckpoint(); applyProviderReplayCheckpointsToMessages([target], [checkpoint]); diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index d726483101..ff89044460 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -3,7 +3,9 @@ import { attachProviderMetadata, readAttachedProviderMetadata, } from "#veryfront/agent/runtime/provider-metadata.ts"; +import { stringifyChatJson } from "#veryfront/chat/json-value.ts"; import type { Message } from "../types.ts"; +import { convertAgentRuntimeMessagesToProviderMessages } from "./message-adapter.ts"; const MAX_PROVIDER_REPLAY_BLOCKS = 100; const MAX_PROVIDER_REPLAY_CHECKPOINTS = 100; @@ -66,6 +68,58 @@ function isProviderReplayProvider(value: unknown): value is ProviderReplayProvid return value === "anthropic" || value === "openai-responses"; } +function toCanonicalAnthropicReplayPart(block: Record): Record { + switch (block.type) { + case "text": + return { type: "text", text: block.text }; + case "thinking": + return { + type: "reasoning", + ...(typeof block.thinking === "string" && block.thinking.length > 0 + ? { text: block.thinking } + : {}), + ...(typeof block.signature === "string" ? { signature: block.signature } : {}), + }; + case "redacted_thinking": + return { + type: "reasoning", + ...(typeof block.data === "string" ? { redactedData: block.data } : {}), + }; + case "tool_use": + return { + type: "tool-call", + toolCallId: block.id, + toolName: block.name, + input: block.input, + }; + default: + invalidCheckpoint("checkpoint provider block cannot be projected for validation"); + } +} + +function assertCheckpointMatchesAssistantTurn( + target: Message, + checkpoint: ProviderReplayCheckpoint, +): void { + const providerProjection = convertAgentRuntimeMessagesToProviderMessages([target]) + .filter((message) => message.role === "assistant"); + if (providerProjection.length !== 1) { + invalidCheckpoint("checkpoint anchor does not project to one assistant message", { + assistantSegmentCount: providerProjection.length, + }); + } + const checkpointProjection = checkpoint.providerBlocks.map((block) => + toCanonicalAnthropicReplayPart(block.block) + ); + const targetProjection = providerProjection[0]!.content; + if ( + !Array.isArray(targetProjection) || + stringifyChatJson(checkpointProjection) !== stringifyChatJson(targetProjection) + ) { + invalidCheckpoint("checkpoint provider blocks do not match the anchored assistant turn"); + } +} + function parseProviderReplayBlock( value: unknown, provider: ProviderReplayProvider, @@ -278,15 +332,18 @@ export function applyProviderReplayCheckpointsToMessages( for (const checkpoint of checkpoints) { const matches = messages.filter((message) => message.id === checkpoint.messageId); if (matches.length === 0) continue; - if (matches.length > 1) { - invalidCheckpoint("checkpoint messageId matches more than one message"); + const assistantMatches = matches.filter((message) => message.role === "assistant"); + if (assistantMatches.length > 1) { + invalidCheckpoint("checkpoint messageId matches more than one assistant message"); } - const target = matches[0]!; - if (target.role !== "assistant") { + const target = assistantMatches[0]; + if (!target) { + const role = matches[0]?.role; invalidCheckpoint("checkpoint messageId must anchor to an assistant message", { - role: target.role, + ...(role ? { role } : {}), }); } + assertCheckpointMatchesAssistantTurn(target, checkpoint); // In-process metadata attached during this run is the same replay state at // first hand; the durable checkpoint never overrides it. if (readAttachedProviderMetadata(target) !== undefined) continue; diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index eed0ce983e..58f68a9b2b 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -1,5 +1,6 @@ import { copyProviderModelMessageSourceId, + getProviderModelMessageSourceId, getStringField, isReasoningPart, isToolCallPart, @@ -88,6 +89,7 @@ export type HistoricalToolInputRetentionOptions = { resolvePolicy?: HistoricalToolInputRetentionPolicyResolver; diagnostics?: HistoricalToolInputCompactionDiagnostic[]; limits?: Partial; + preserveSourceMessageIds?: readonly string[]; }; /** Options accepted by prepare provider model messages from UI messages. */ @@ -1559,12 +1561,17 @@ export function compactOldToolInputs( const historicalResultIds = collectHistoricalToolResultIds(messages, lastUserIdx); const limits = resolveMessagePrepLimits(options.limits); + const preservedSourceMessageIds = new Set(options.preserveSourceMessageIds ?? []); let mutated = false; const result = messages.map((msg, idx) => { if (idx >= lastUserIdx || msg.role !== "assistant" || !Array.isArray(msg.content)) { return msg; } + const sourceMessageId = getProviderModelMessageSourceId(msg); + if (sourceMessageId && preservedSourceMessageIds.has(sourceMessageId)) { + return msg; + } let messageMutated = false; const content = msg.content.map((part) => { @@ -1646,12 +1653,16 @@ export function compactHistoricalUiMessageToolInputs( const historicalResultIds = collectHistoricalUiToolResultIds(messages, lastUserIdx); const limits = resolveMessagePrepLimits(options.limits); + const preservedSourceMessageIds = new Set(options.preserveSourceMessageIds ?? []); let mutated = false; const result = messages.map((message, index) => { if (index >= lastUserIdx || message.role !== "assistant") { return message; } + if (preservedSourceMessageIds.has(message.id)) { + return message; + } let messageMutated = false; const parts = message.parts.map((part) => { From 4a64ac4dc56a9cd0c87f00ee78e516cb5daba872 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 10:49:54 +0200 Subject: [PATCH 12/56] Stabilize remote project path expectations The key-reducer test built remote project state with the process cwd and later computed its expectation with another cwd read. In the parallel unit suite, sibling tests can legitimately move cwd through the shared process, so the expectation drifted while the reducer itself used the already-stored state path. The test now injects one explicit base path for both setup and assertions. Constraint: Deno.cwd() is process-global during parallel tests. Rejected: Change production remote project path defaults | production behavior was not the failing component; the isolated test passed and the parallel failure showed expectation drift. Confidence: high Scope-risk: narrow Directive: Tests that compare remote pull paths should pass an explicit baseDir instead of reading Deno.cwd() more than once. Tested: deno task test:file cli/app/key-reducer.test.ts Tested: deno task test:file cli/app/state.test.ts --- cli/app/key-reducer.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/app/key-reducer.test.ts b/cli/app/key-reducer.test.ts index 6786d67f7e..b4d59f68e1 100644 --- a/cli/app/key-reducer.test.ts +++ b/cli/app/key-reducer.test.ts @@ -29,6 +29,7 @@ const UP = "\x1b[A"; const DOWN = "\x1b[B"; const TAB = "\t"; const CTRL_C = "\x03"; +const REMOTE_BASE_DIR = "/tmp/veryfront-key-reducer-test"; const ENV: KeyEnv = { suggestProjectName: () => "brave-otter" }; @@ -48,7 +49,7 @@ function withProjects(): AppState { } function withRemote(slugs: string[] = ["one", "two"]): AppState { - return setRemoteProjects(slugs.map((slug) => ({ slug })))( + return setRemoteProjects(slugs.map((slug) => ({ slug })), REMOTE_BASE_DIR)( setRemoteUser({ email: "dev@example.com" })(withProjects()), ); } @@ -240,7 +241,7 @@ describe("app/key-reducer", () => { assertEquals(effects, [{ kind: "open-browser", - project: { slug: "one", path: remoteProjectPath("one"), type: "remote" }, + project: { slug: "one", path: remoteProjectPath("one", REMOTE_BASE_DIR), type: "remote" }, }]); }); @@ -249,7 +250,7 @@ describe("app/key-reducer", () => { assertEquals(effects, [{ kind: "pull", - project: { slug: "one", path: remoteProjectPath("one"), type: "remote" }, + project: { slug: "one", path: remoteProjectPath("one", REMOTE_BASE_DIR), type: "remote" }, }]); }); From 1e3442e5ac63568d77f308737a68e7694f27c507 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 11:12:31 +0200 Subject: [PATCH 13/56] Keep replay preservation out of the chat monolith Provider replay needs historical tool-input retention types, but growing message-prep crossed the repository's chat-module ceiling. Extract the stable type vocabulary into a focused module, preserve the existing message-prep exports, and tighten the ceiling to the smaller implementation. Constraint: Existing imports from chat/message-prep must remain source-compatible. Rejected: Raise the LOC ceiling | hides the structural regression and weakens the ratchet. Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep runtime compaction behavior in message-prep; this module owns only its shared type vocabulary. Tested: chat ratchet; Deno check; provider replay/message preparation suite (71/71); lint:ci through all source policy gates, with only local Deno 2.7.12 vs pinned 2.7.7 docs-generation guard remaining. Not-tested: Pinned-Deno docs generation locally; GitHub CI provides the pinned runtime. --- scripts/lint/ban-chat-antipatterns.ts | 2 +- src/chat/message-prep-types.ts | 55 +++++++++++++++++++++ src/chat/message-prep.ts | 71 ++++++--------------------- 3 files changed, 71 insertions(+), 57 deletions(-) create mode 100644 src/chat/message-prep-types.ts diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index f6f4244340..d093e10320 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -112,7 +112,7 @@ const FILE_SIZE_CEILINGS: Record = { // reconciliation). Not React components, so this map does not subject them // to the antipattern ratchets above — it only pins their size. "src/chat/conversation.ts": 557, - "src/chat/message-prep.ts": 2016, + "src/chat/message-prep.ts": 1986, "src/chat/tool-replay-reconciliation.ts": 291, "src/chat/message-part-parsing.ts": 264, "src/chat/part-field-access.ts": 65, diff --git a/src/chat/message-prep-types.ts b/src/chat/message-prep-types.ts new file mode 100644 index 0000000000..f965648a3a --- /dev/null +++ b/src/chat/message-prep-types.ts @@ -0,0 +1,55 @@ +/** Tunable limits used while preparing chat history for model context. */ +export type MessagePrepLimits = { + charsPerToken: number; + historicalToolOutputMaskChars: number; + historicalToolInputMaskChars: number; + retainedMetadataStringMaxChars: number; + retainedMetadataArrayMaxItems: number; + retainedMetadataObjectMaxEntries: number; +}; + +/** Field selector retained in a historical tool-input summary. */ +export type HistoricalToolInputRetainedField = + | string + | { + inputName: string; + outputName?: string; + } + | { + inputNames: readonly string[]; + outputName: string; + }; + +/** Policy for compacting a completed historical tool-call input. */ +export type HistoricalToolInputRetentionPolicy = { + compactCompletedInput: boolean; + compactAfterChars?: number; + retainInputFields?: readonly HistoricalToolInputRetainedField[]; +}; + +/** Resolves the retention policy for a completed historical tool input. */ +export type HistoricalToolInputRetentionPolicyResolver = ( + toolName: string, + input: Record, +) => HistoricalToolInputRetentionPolicy | null | undefined; + +/** Diagnostic emitted when a completed historical tool input is compacted. */ +export type HistoricalToolInputCompactionDiagnostic = { + source: "provider" | "ui"; + toolName: string; + toolCallId: string; + originalInputChars: number; + retainedInputChars: number; + originalInputTokens: number; + retainedInputTokens: number; + originalInputHash: string; + reason: "completed_historical_tool_input"; +}; + +/** Options for historical tool-input compaction. */ +export type HistoricalToolInputRetentionOptions = { + resolvePolicy?: HistoricalToolInputRetentionPolicyResolver; + diagnostics?: HistoricalToolInputCompactionDiagnostic[]; + limits?: Partial; + preserveSourceMessageIds?: readonly string[]; +}; diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index 58f68a9b2b..556d5a10e7 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -23,16 +23,21 @@ import { historicalToolSummaries } from "../integrations/_tool_summaries.ts"; import type { IntegrationEndpointHistoricalSummary } from "../integrations/schema.ts"; import { safeJsonParse } from "#veryfront/utils/json.ts"; import { stringifyChatJson } from "./json-value.ts"; - -/** Tunable limits used while preparing chat history for model context. */ -export type MessagePrepLimits = { - charsPerToken: number; - historicalToolOutputMaskChars: number; - historicalToolInputMaskChars: number; - retainedMetadataStringMaxChars: number; - retainedMetadataArrayMaxItems: number; - retainedMetadataObjectMaxEntries: number; -}; +import type { + HistoricalToolInputCompactionDiagnostic, + HistoricalToolInputRetainedField, + HistoricalToolInputRetentionOptions, + HistoricalToolInputRetentionPolicy, + MessagePrepLimits, +} from "./message-prep-types.ts"; +export type { + HistoricalToolInputCompactionDiagnostic, + HistoricalToolInputRetainedField, + HistoricalToolInputRetentionOptions, + HistoricalToolInputRetentionPolicy, + HistoricalToolInputRetentionPolicyResolver, + MessagePrepLimits, +} from "./message-prep-types.ts"; /** Default limits for chat history preparation. */ export const DEFAULT_MESSAGE_PREP_LIMITS: MessagePrepLimits = { @@ -46,52 +51,6 @@ export const DEFAULT_MESSAGE_PREP_LIMITS: MessagePrepLimits = { const CHARS_PER_TOKEN = DEFAULT_MESSAGE_PREP_LIMITS.charsPerToken; -/** Field selector retained in a historical tool-input summary. */ -export type HistoricalToolInputRetainedField = - | string - | { - inputName: string; - outputName?: string; - } - | { - inputNames: readonly string[]; - outputName: string; - }; - -/** Policy for compacting a completed historical tool-call input. */ -export type HistoricalToolInputRetentionPolicy = { - compactCompletedInput: boolean; - compactAfterChars?: number; - retainInputFields?: readonly HistoricalToolInputRetainedField[]; -}; - -/** Resolves the retention policy for a completed historical tool input. */ -export type HistoricalToolInputRetentionPolicyResolver = ( - toolName: string, - input: Record, -) => HistoricalToolInputRetentionPolicy | null | undefined; - -/** Diagnostic emitted when a completed historical tool input is compacted. */ -export type HistoricalToolInputCompactionDiagnostic = { - source: "provider" | "ui"; - toolName: string; - toolCallId: string; - originalInputChars: number; - retainedInputChars: number; - originalInputTokens: number; - retainedInputTokens: number; - originalInputHash: string; - reason: "completed_historical_tool_input"; -}; - -/** Options for historical tool-input compaction. */ -export type HistoricalToolInputRetentionOptions = { - resolvePolicy?: HistoricalToolInputRetentionPolicyResolver; - diagnostics?: HistoricalToolInputCompactionDiagnostic[]; - limits?: Partial; - preserveSourceMessageIds?: readonly string[]; -}; - /** Options accepted by prepare provider model messages from UI messages. */ export interface PrepareProviderModelMessagesFromUiMessagesOptions { providerOwnedToolNames?: readonly string[]; From c194223518d2db203082614910eb8b2290bb0f35 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 11:32:20 +0200 Subject: [PATCH 14/56] Keep opaque replay validation on the public transcript boundary Compare durable Anthropic checkpoints through a transcript-visible projection so private reasoning remains replayable without leaking into AG-UI. Canonicalize supported server and MCP tool calls while preserving provider ownership as part of identity, and regenerate API references with the pinned toolchain. Constraint: Signed and redacted provider material must never be required on public message parts. Constraint: Provider-owned tool calls must not be interchangeable with client-owned calls. Rejected: Compare full provider blocks to AG-UI history | the transcript intentionally omits opaque fields. Confidence: high Scope-risk: moderate Directive: Add provider block kinds to the visible projection only with a matching ownership-preserving regression test. Tested: pinned Deno 2.7.7 affected suite (95 tests plus 79 nested steps); provider replay suite (37 steps); deno check; deno lint; fmt check; docs:api-reference:check Not-tested: Production checkpoint emission remains intentionally disabled in this stage. --- docs/api-reference/veryfront/agent.md | 32 ++--- docs/api-reference/veryfront/chat.md | 60 ++++----- src/agent/runtime/provider-replay.test.ts | 137 +++++++++++++++++++++ src/agent/runtime/provider-replay.ts | 142 +++++++++++++++++----- 4 files changed, 297 insertions(+), 74 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index b7dd099abb..38a266dcba 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -863,8 +863,8 @@ Input delivered to a hosted agent-service detached execution callback. | `normalizeConversationRunEvents` | Normalizes conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-event-normalization.ts#L95) | | `normalizeEncodedConversationRunEvents` | Normalizes encoded conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-events.ts#L456) | | `normalizeHostedChildArtifactPath` | Normalizes hosted child artifact path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-artifact-support.ts#L133) | -| `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L304) | -| `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L304) | +| `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L305) | +| `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L305) | | `normalizeRuntimeSkillReferencePath` | Normalizes and bounds a portable runtime skill reference path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1011) | | `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L551) | | `parseAgentServiceConfig` | Configuration used by parse agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L159) | @@ -893,9 +893,9 @@ Input delivered to a hosted agent-service detached execution callback. | `persistConversationUserMessage` | Message shape for persist conversation user. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L211) | | `persistLatestConversationUserMessage` | Message shape for persist latest conversation user. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L277) | | `prepareAgentRuntimeMessagesFromUiMessages` | Prepare agent runtime messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-preparation.ts#L34) | -| `prepareAgentServiceChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L507) | -| `prepareAgentServiceChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L374) | -| `prepareAgentServiceChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L641) | +| `prepareAgentServiceChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L508) | +| `prepareAgentServiceChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L375) | +| `prepareAgentServiceChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L645) | | `prepareAgentServiceConversationRootRunContext` | Context for prepare hosted conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L129) | | `prepareConversationRootRunContext` | Context for prepare conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-context.ts#L128) | | `prepareConversationRootRunLifecycle` | Prepare conversation root run lifecycle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L42) | @@ -906,9 +906,9 @@ Input delivered to a hosted agent-service detached execution callback. | `prepareDefaultHostedChildForkSandboxToolSources` | Prepare default hosted child fork sandbox tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-tool-sources.ts#L197) | | `prepareDefaultHostedChildForkToolAssembly` | Prepare default hosted child fork tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-requested-tools.ts#L375) | | `prepareDefaultHostedChildForkToolSources` | Prepare default hosted child fork tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-tool-sources.ts#L88) | -| `prepareHostedChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L507) | -| `prepareHostedChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L374) | -| `prepareHostedChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L641) | +| `prepareHostedChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L508) | +| `prepareHostedChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L375) | +| `prepareHostedChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L645) | | `prepareHostedChatRuntimeToolAssembly` | Prepare hosted chat runtime tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L559) | | `prepareHostedChildForkRuntimeStepMessages` | Prepare hosted child fork runtime step messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L138) | | `prepareHostedConversationRootRunContext` | Context for prepare hosted conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L129) | @@ -1457,9 +1457,9 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedAgentServiceStreamExecutionInput` | Input payload for hosted agent service stream execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/routes.ts#L66) | | `HostedAgUiChatForwardedConfig` | Configuration used by hosted AG-UI chat forwarded. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L44) | | `HostedChatExecutionLifecycleAdapter` | Public API contract for hosted chat execution lifecycle adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-lifecycle-types.ts#L5) | -| `HostedChatExecutionPreparationInput` | Input payload for hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L240) | -| `HostedChatExecutionPreparationResult` | Result returned from hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L288) | -| `HostedChatExecutionPreparationRootRunOptions` | Options accepted by hosted chat execution preparation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L219) | +| `HostedChatExecutionPreparationInput` | Input payload for hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L241) | +| `HostedChatExecutionPreparationResult` | Result returned from hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L289) | +| `HostedChatExecutionPreparationRootRunOptions` | Options accepted by hosted chat execution preparation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L220) | | `HostedChatExecutionRootStreamWatchdog` | Public API contract for hosted chat execution root stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L96) | | `HostedChatExecutionRunContext` | Context for hosted chat execution run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L90) | | `HostedChatExecutionRuntime` | Public API contract for hosted chat execution runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L77) | @@ -1476,14 +1476,14 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedChatRuntimeAgentAdapterWarning` | Public API contract for hosted chat runtime agent adapter warning. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-agent-adapter.ts#L19) | | `HostedChatRuntimeAllowedToolNames` | Public API contract for hosted chat runtime allowed tool names. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L64) | | `HostedChatRuntimeCreationOptions` | Options accepted by hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L120) | -| `HostedChatRuntimeCreationPreparationInput` | Input payload for hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L107) | -| `HostedChatRuntimeCreationPreparationResult` | Result returned from hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L151) | +| `HostedChatRuntimeCreationPreparationInput` | Input payload for hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L108) | +| `HostedChatRuntimeCreationPreparationResult` | Result returned from hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L152) | | `HostedChatRuntimeCreationResult` | Result returned from hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L94) | | `HostedChatRuntimeFinishPart` | Public API contract for hosted chat runtime finish part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L17) | -| `HostedChatRuntimeInstructionsInput` | Input payload for hosted chat runtime instructions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L96) | +| `HostedChatRuntimeInstructionsInput` | Input payload for hosted chat runtime instructions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L97) | | `HostedChatRuntimeOnFinishEvent` | Event emitted for hosted chat runtime on finish. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L54) | -| `HostedChatRuntimePreparationRootRunContext` | Context for hosted chat runtime preparation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L80) | -| `HostedChatRuntimePreparationSteering` | Public API contract for hosted chat runtime preparation steering. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L90) | +| `HostedChatRuntimePreparationRootRunContext` | Context for hosted chat runtime preparation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L81) | +| `HostedChatRuntimePreparationSteering` | Public API contract for hosted chat runtime preparation steering. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L91) | | `HostedChatRuntimeProjectSteering` | Public API contract for hosted chat runtime project steering. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L102) | | `HostedChatRuntimeStreamInput` | Input payload for hosted chat runtime stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L73) | | `HostedChatRuntimeStreamResult` | Result returned from hosted chat runtime stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L79) | diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index 0f8bee57d7..92204fee2f 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -583,44 +583,44 @@ import { | Name | Description | Source | | ----------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `DEFAULT_MESSAGE_PREP_LIMITS` | Default limits for chat history preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L37) | +| `DEFAULT_MESSAGE_PREP_LIMITS` | Default limits for chat history preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L43) | #### Functions | Name | Description | Source | | -------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1915) | -| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1632) | -| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1545) | -| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L229) | -| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1939) | -| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L2006) | -| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L304) | -| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1884) | -| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L182) | -| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1878) | -| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L100) | -| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L737) | -| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1447) | -| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L743) | -| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1146) | -| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1730) | -| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L770) | -| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1043) | -| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L872) | +| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1885) | +| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1598) | +| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1506) | +| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L190) | +| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1909) | +| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1976) | +| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L265) | +| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1854) | +| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L143) | +| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1848) | +| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L61) | +| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L698) | +| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1408) | +| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L704) | +| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1107) | +| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1700) | +| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L731) | +| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1004) | +| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L833) | #### Types -| Name | Description | Source | -| --------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `HistoricalToolInputCompactionDiagnostic` | Diagnostic emitted when a completed historical tool input is compacted. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L74) | -| `HistoricalToolInputRetainedField` | Field selector retained in a historical tool-input summary. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L49) | -| `HistoricalToolInputRetentionOptions` | Options for historical tool-input compaction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L87) | -| `HistoricalToolInputRetentionPolicy` | Policy for compacting a completed historical tool-call input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L61) | -| `HistoricalToolInputRetentionPolicyResolver` | Resolves the retention policy for a completed historical tool input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L68) | -| `MessagePrepLimits` | Tunable limits used while preparing chat history for model context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L27) | -| `MessageTokenBreakdown` | Approximate token categories for context diagnostics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L105) | -| `PrepareProviderModelMessagesFromUiMessagesOptions` | Options accepted by prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L94) | +| Name | Description | Source | +| --------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `HistoricalToolInputCompactionDiagnostic` | Diagnostic emitted when a completed historical tool input is compacted. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L37) | +| `HistoricalToolInputRetainedField` | Field selector retained in a historical tool-input summary. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L12) | +| `HistoricalToolInputRetentionOptions` | Options for historical tool-input compaction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L50) | +| `HistoricalToolInputRetentionPolicy` | Policy for compacting a completed historical tool-call input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L24) | +| `HistoricalToolInputRetentionPolicyResolver` | Resolves the retention policy for a completed historical tool input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L31) | +| `MessagePrepLimits` | Tunable limits used while preparing chat history for model context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L2) | +| `MessageTokenBreakdown` | Approximate token categories for context diagnostics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L66) | +| `PrepareProviderModelMessagesFromUiMessagesOptions` | Options accepted by prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L55) | ### `veryfront/chat/protocol` diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 98101e6fb7..711af2b679 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -364,6 +364,143 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should validate opaque reasoning against its transcript-visible projection", () => { + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [{ + type: "tool-call", + toolCallId: "call-1", + toolName: "lookup", + args: { query: "veryfront" }, + }], + timestamp: 1, + } as Message; + const checkpoint = createValidCheckpoint(); + + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { + anthropic: { + rawAssistantMessages: [checkpoint.providerBlocks.map((block) => block.block)], + }, + }, + "private reasoning metadata may be absent from the transcript anchor", + ); + }); + + it("should attach an opaque-only checkpoint to its empty transcript anchor", () => { + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + ...createValidCheckpoint(), + providerBlocks: createValidCheckpoint().providerBlocks.slice(0, 2), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { + anthropic: { + rawAssistantMessages: [checkpoint.providerBlocks.map((block) => block.block)], + }, + }, + "an opaque-only provider turn remains replayable without public reasoning fields", + ); + }); + + it("should validate supported provider tool uses against canonical transcript calls", () => { + for ( + const providerBlock of [ + { + type: "server_tool_use", + id: "srvtool-code", + name: "code_execution", + input: { code: "1 + 1" }, + caller: { type: "direct" }, + }, + { + type: "mcp_tool_use", + id: "mcptool-echo", + name: "echo", + server_name: "example-mcp", + input: { value: "hello" }, + }, + ] + ) { + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [{ + type: "tool-call", + toolCallId: providerBlock.id, + toolName: providerBlock.name, + args: providerBlock.input, + providerExecuted: true, + }], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [{ type: "provider-block", provider: "anthropic", block: providerBlock }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { anthropic: { rawAssistantMessages: [[providerBlock]] } }, + `${providerBlock.type} reattaches through its canonical tool-call identity`, + ); + } + }); + + it("should reject a provider tool checkpoint for a client-owned transcript call", () => { + const providerBlock = { + type: "server_tool_use", + id: "srvtool-code", + name: "code_execution", + input: { code: "1 + 1" }, + caller: { type: "direct" }, + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [{ + type: "tool-call", + toolCallId: providerBlock.id, + toolName: providerBlock.name, + args: providerBlock.input, + }], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [{ type: "provider-block", provider: "anthropic", block: providerBlock }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + it("should be a no-op for empty or absent deliveries", () => { const target = createAssistantMessage("assistant-message-1"); applyProviderReplayCheckpointsToMessages([target], undefined); diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index ff89044460..9c4f6b30a6 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -68,32 +68,112 @@ function isProviderReplayProvider(value: unknown): value is ProviderReplayProvid return value === "anthropic" || value === "openai-responses"; } -function toCanonicalAnthropicReplayPart(block: Record): Record { +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isSupportedAnthropicServerToolCaller(value: unknown): boolean { + if (value === undefined) return true; + if (!isRecord(value)) return false; + if (value.type === "direct") return true; + return ( + value.type === "code_execution_20250825" || value.type === "code_execution_20260120" + ) && isNonEmptyString(value.tool_id); +} + +function toCanonicalAnthropicToolCall( + block: Record, + providerExecuted: boolean, +): Record { + const input = block.input === undefined ? {} : block.input; + if (!isNonEmptyString(block.id) || !isNonEmptyString(block.name) || !isRecord(input)) { + invalidCheckpoint("checkpoint tool-use block is malformed"); + } + if (block.type === "server_tool_use" && !isSupportedAnthropicServerToolCaller(block.caller)) { + invalidCheckpoint("checkpoint server tool-use caller is malformed"); + } + if (block.type === "mcp_tool_use" && !isNonEmptyString(block.server_name)) { + invalidCheckpoint("checkpoint MCP tool-use server is malformed"); + } + return { + type: "tool-call", + toolCallId: block.id, + toolName: block.name, + input, + ...(providerExecuted ? { providerExecuted: true } : {}), + }; +} + +function toTranscriptVisibleAnthropicReplayPart( + block: Record, +): Record | undefined { switch (block.type) { - case "text": + case "text": { + if (typeof block.text !== "string") { + invalidCheckpoint("checkpoint text block is malformed"); + } return { type: "text", text: block.text }; - case "thinking": - return { - type: "reasoning", - ...(typeof block.thinking === "string" && block.thinking.length > 0 - ? { text: block.thinking } - : {}), - ...(typeof block.signature === "string" ? { signature: block.signature } : {}), - }; + } + case "thinking": { + if (typeof block.thinking !== "string" && typeof block.signature !== "string") { + invalidCheckpoint("checkpoint thinking block is malformed"); + } + return isNonEmptyString(block.thinking) + ? { type: "reasoning", text: block.thinking } + : undefined; + } case "redacted_thinking": - return { - type: "reasoning", - ...(typeof block.data === "string" ? { redactedData: block.data } : {}), - }; + if (!isNonEmptyString(block.data)) { + invalidCheckpoint("checkpoint redacted thinking block is malformed"); + } + return undefined; case "tool_use": + return toCanonicalAnthropicToolCall(block, false); + case "server_tool_use": + case "mcp_tool_use": + return toCanonicalAnthropicToolCall(block, true); + default: + invalidCheckpoint("checkpoint provider block cannot be projected for validation"); + } +} + +function getProviderExecutedToolCallIds(target: Message): Set { + return new Set( + target.parts.flatMap((part) => { + const value: unknown = part; + return isRecord(value) && value.providerExecuted === true && + isNonEmptyString(value.toolCallId) + ? [value.toolCallId] + : []; + }), + ); +} + +function toTranscriptVisibleProviderPart( + part: unknown, + providerExecutedToolCallIds: ReadonlySet, +): Record | undefined { + if (!isRecord(part)) { + invalidCheckpoint("checkpoint anchor contains an invalid provider part"); + } + switch (part.type) { + case "text": + return typeof part.text === "string" ? { type: "text", text: part.text } : undefined; + case "reasoning": + return isNonEmptyString(part.text) ? { type: "reasoning", text: part.text } : undefined; + case "tool-call": return { type: "tool-call", - toolCallId: block.id, - toolName: block.name, - input: block.input, + toolCallId: part.toolCallId, + toolName: part.toolName, + input: part.input, + ...(typeof part.toolCallId === "string" && + providerExecutedToolCallIds.has(part.toolCallId) + ? { providerExecuted: true } + : {}), }; default: - invalidCheckpoint("checkpoint provider block cannot be projected for validation"); + invalidCheckpoint("checkpoint anchor contains an unsupported provider part"); } } @@ -103,19 +183,25 @@ function assertCheckpointMatchesAssistantTurn( ): void { const providerProjection = convertAgentRuntimeMessagesToProviderMessages([target]) .filter((message) => message.role === "assistant"); - if (providerProjection.length !== 1) { - invalidCheckpoint("checkpoint anchor does not project to one assistant message", { + if (providerProjection.length > 1) { + invalidCheckpoint("checkpoint anchor projects to more than one assistant message", { assistantSegmentCount: providerProjection.length, }); } - const checkpointProjection = checkpoint.providerBlocks.map((block) => - toCanonicalAnthropicReplayPart(block.block) - ); - const targetProjection = providerProjection[0]!.content; - if ( - !Array.isArray(targetProjection) || - stringifyChatJson(checkpointProjection) !== stringifyChatJson(targetProjection) - ) { + const checkpointProjection = checkpoint.providerBlocks.flatMap((block) => { + const part = toTranscriptVisibleAnthropicReplayPart(block.block); + return part ? [part] : []; + }); + const providerExecutedToolCallIds = getProviderExecutedToolCallIds(target); + const targetContent = providerProjection[0]?.content ?? []; + if (!Array.isArray(targetContent)) { + invalidCheckpoint("checkpoint anchor does not carry structured assistant content"); + } + const targetProjection = targetContent.flatMap((part) => { + const projected = toTranscriptVisibleProviderPart(part, providerExecutedToolCallIds); + return projected ? [projected] : []; + }); + if (stringifyChatJson(checkpointProjection) !== stringifyChatJson(targetProjection)) { invalidCheckpoint("checkpoint provider blocks do not match the anchored assistant turn"); } } From 1956a0ce4aaad85d8e96b962b8afd430ecbffb37 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 11:52:04 +0200 Subject: [PATCH 15/56] Keep provider replay anchors intact through preparation Checkpoint-backed provider calls and results must survive provider-history stripping so durable resumes can validate and replay the exact signed response. Provider result validation now requires correlation to a canonical provider-owned call while keeping unrelated provider history omitted. Constraint: Provider-native result payloads are opaque; only their canonical call correlation is transcript-visible. Rejected: Preserve all provider-owned history | expands context and weakens the existing least-data boundary. Confidence: high Scope-risk: narrow Directive: Do not accept provider result replay without a matching provider-executed canonical call. Tested: 96 focused tests; Deno check; Deno lint; Deno fmt; generated API reference check. Not-tested: Live Anthropic durable resume; covered by the staging issue mission after deployment. --- docs/api-reference/veryfront/agent.md | 2 +- docs/api-reference/veryfront/chat.md | 40 +++++----- src/agent/hosted/chat-preparation.test.ts | 48 ++++++++++++ src/agent/hosted/chat-preparation.ts | 2 + src/agent/runtime/message-preparation.ts | 2 + src/agent/runtime/provider-replay.test.ts | 94 +++++++++++++++++++++++ src/agent/runtime/provider-replay.ts | 54 ++++++++++++- src/chat/message-prep.ts | 18 +++++ 8 files changed, 236 insertions(+), 24 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 38a266dcba..a61310e63d 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -892,7 +892,7 @@ Input delivered to a hosted agent-service detached execution callback. | `parseToolInputObject` | Parses tool input object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-input.ts#L135) | | `persistConversationUserMessage` | Message shape for persist conversation user. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L211) | | `persistLatestConversationUserMessage` | Message shape for persist latest conversation user. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L277) | -| `prepareAgentRuntimeMessagesFromUiMessages` | Prepare agent runtime messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-preparation.ts#L34) | +| `prepareAgentRuntimeMessagesFromUiMessages` | Prepare agent runtime messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-preparation.ts#L35) | | `prepareAgentServiceChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L508) | | `prepareAgentServiceChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L375) | | `prepareAgentServiceChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L645) | diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index 92204fee2f..90adfc8e18 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -589,25 +589,25 @@ import { | Name | Description | Source | | -------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1885) | -| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1598) | -| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1506) | -| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L190) | -| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1909) | -| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1976) | -| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L265) | -| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1854) | -| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L143) | -| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1848) | -| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L61) | -| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L698) | -| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1408) | -| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L704) | -| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1107) | -| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1700) | -| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L731) | -| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1004) | -| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L833) | +| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1903) | +| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1616) | +| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1524) | +| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L191) | +| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1927) | +| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1994) | +| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L266) | +| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1872) | +| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L144) | +| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1866) | +| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L62) | +| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L699) | +| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1426) | +| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L705) | +| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1124) | +| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1718) | +| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L732) | +| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1005) | +| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L834) | #### Types @@ -619,7 +619,7 @@ import { | `HistoricalToolInputRetentionPolicy` | Policy for compacting a completed historical tool-call input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L24) | | `HistoricalToolInputRetentionPolicyResolver` | Resolves the retention policy for a completed historical tool input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L31) | | `MessagePrepLimits` | Tunable limits used while preparing chat history for model context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L2) | -| `MessageTokenBreakdown` | Approximate token categories for context diagnostics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L66) | +| `MessageTokenBreakdown` | Approximate token categories for context diagnostics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L67) | | `PrepareProviderModelMessagesFromUiMessagesOptions` | Options accepted by prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L55) | ### `veryfront/chat/protocol` diff --git a/src/agent/hosted/chat-preparation.test.ts b/src/agent/hosted/chat-preparation.test.ts index 8358698310..c8cecd12e2 100644 --- a/src/agent/hosted/chat-preparation.test.ts +++ b/src/agent/hosted/chat-preparation.test.ts @@ -1646,6 +1646,54 @@ Deno.test("prepareHostedChatRuntimeMessages omits provider-owned remote tool his }]); }); +Deno.test( + "prepareHostedChatRuntimeMessages preserves checkpoint-anchored provider tool history", + async () => { + const messages = await prepareHostedChatRuntimeMessages( + [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Search the official documentation." }], + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ + type: "dynamic-tool", + toolName: "web_search", + toolCallId: "srvtool-web-search", + input: { query: "site:veryfront.com provider replay" }, + state: "output-available", + providerExecuted: true, + output: [], + }], + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Summarize the result." }], + }, + ], + { + providerOwnedToolNames: ["web_search"], + providerReplayCheckpointMessageIds: ["assistant-1"], + }, + ); + + const checkpointedParts = messages + .filter((message) => message.id === "assistant-1") + .flatMap((message) => message.parts); + assertEquals( + checkpointedParts.flatMap((part) => + "toolCallId" in part && part.toolCallId === "srvtool-web-search" ? [part.type] : [] + ), + ["tool-call", "tool-result"], + "checkpointed provider call and result remain available for replay validation", + ); + }, +); + Deno.test("prepareHostedChatRuntimeMessages reports historical tool input compaction diagnostics", async () => { const diagnostics: HistoricalToolInputCompactionDiagnostic[] = []; const marker = "HOSTED_TOOL_INPUT_MARKER"; diff --git a/src/agent/hosted/chat-preparation.ts b/src/agent/hosted/chat-preparation.ts index 8f05ba62a9..c7349a740d 100644 --- a/src/agent/hosted/chat-preparation.ts +++ b/src/agent/hosted/chat-preparation.ts @@ -651,6 +651,7 @@ export async function prepareHostedChatRuntimeMessages( messages, emptyConversationPrompt: options.emptyConversationPrompt, providerOwnedToolNames: options.providerOwnedToolNames, + preserveProviderOwnedToolSourceMessageIds: options.providerReplayCheckpointMessageIds, abortSignal: options.abortSignal, fileContentFetchTimeoutMs: options.fileContentFetchTimeoutMs, historicalToolInputRetention: { @@ -667,6 +668,7 @@ export async function prepareHostedChatRuntimeMessages( messages, emptyConversationPrompt: options.emptyConversationPrompt, providerOwnedToolNames: options.providerOwnedToolNames, + preserveProviderOwnedToolSourceMessageIds: options.providerReplayCheckpointMessageIds, abortSignal: options.abortSignal, fileContentFetchTimeoutMs: options.fileContentFetchTimeoutMs, historicalToolInputRetention: { diff --git a/src/agent/runtime/message-preparation.ts b/src/agent/runtime/message-preparation.ts index e849bb91b6..99563210d2 100644 --- a/src/agent/runtime/message-preparation.ts +++ b/src/agent/runtime/message-preparation.ts @@ -27,6 +27,7 @@ export type PrepareAgentRuntimeMessagesFromUiMessagesOptions = { abortSignal?: AbortSignal; fileContentFetchTimeoutMs?: number; providerOwnedToolNames?: readonly string[]; + preserveProviderOwnedToolSourceMessageIds?: readonly string[]; historicalToolInputRetention?: HistoricalToolInputRetentionOptions; }; @@ -70,6 +71,7 @@ export async function prepareAgentRuntimeMessagesFromUiMessages( return convertProviderMessagesToAgentRuntimeMessages( prepareProviderModelMessagesFromUiMessages(messagesWithFileContent, { providerOwnedToolNames: options.providerOwnedToolNames, + preserveProviderOwnedToolSourceMessageIds: options.preserveProviderOwnedToolSourceMessageIds, historicalToolInputRetention: options.historicalToolInputRetention, }), ); diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 711af2b679..2b9d64dcc3 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -501,6 +501,100 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should validate provider tool results by their provider-owned call correlation", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-web-search", + name: "web_search", + input: { query: "provider replay" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, providerResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { anthropic: { rawAssistantMessages: [[providerCall, providerResult]] } }, + "provider result remains correlated to the canonical provider-owned call", + ); + }); + + it("should reject a provider tool result without its provider-owned call", () => { + const providerResult = { + type: "web_search_tool_result", + tool_use_id: "srvtool-web-search", + caller: { type: "direct" }, + content: [], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [{ + type: "tool-result", + toolCallId: providerResult.tool_use_id, + toolName: "web_search", + result: [], + providerExecuted: true, + }], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: providerResult, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + it("should be a no-op for empty or absent deliveries", () => { const target = createAssistantMessage("assistant-message-1"); applyProviderReplayCheckpointsToMessages([target], undefined); diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 9c4f6b30a6..9a9d363b7a 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -23,6 +23,14 @@ const CHECKPOINT_KEYS = new Set([ "emittedAt", ]); const BLOCK_KEYS = new Set(["type", "provider", "block"]); +const ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES = new Set([ + "web_search_tool_result", + "web_fetch_tool_result", + "code_execution_tool_result", + "bash_code_execution_tool_result", + "text_editor_code_execution_tool_result", + "mcp_tool_result", +]); /** Providers whose continuation contract can require opaque block replay. */ export type ProviderReplayProvider = "anthropic" | "openai-responses"; @@ -132,8 +140,22 @@ function toTranscriptVisibleAnthropicReplayPart( case "server_tool_use": case "mcp_tool_use": return toCanonicalAnthropicToolCall(block, true); - default: + default: { + if ( + typeof block.type === "string" && + ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES.has(block.type) + ) { + if (!isNonEmptyString(block.tool_use_id)) { + invalidCheckpoint("checkpoint provider tool-result block is malformed"); + } + return { + type: "tool-result", + toolCallId: block.tool_use_id, + providerExecuted: true, + }; + } invalidCheckpoint("checkpoint provider block cannot be projected for validation"); + } } } @@ -141,7 +163,8 @@ function getProviderExecutedToolCallIds(target: Message): Set { return new Set( target.parts.flatMap((part) => { const value: unknown = part; - return isRecord(value) && value.providerExecuted === true && + return isRecord(value) && value.type === "tool-call" && + value.providerExecuted === true && isNonEmptyString(value.toolCallId) ? [value.toolCallId] : []; @@ -172,6 +195,15 @@ function toTranscriptVisibleProviderPart( ? { providerExecuted: true } : {}), }; + case "tool-result": + if (!isNonEmptyString(part.toolCallId)) { + invalidCheckpoint("checkpoint anchor tool result is malformed"); + } + return { + type: "tool-result", + toolCallId: part.toolCallId, + ...(providerExecutedToolCallIds.has(part.toolCallId) ? { providerExecuted: true } : {}), + }; default: invalidCheckpoint("checkpoint anchor contains an unsupported provider part"); } @@ -201,7 +233,23 @@ function assertCheckpointMatchesAssistantTurn( const projected = toTranscriptVisibleProviderPart(part, providerExecutedToolCallIds); return projected ? [projected] : []; }); - if (stringifyChatJson(checkpointProjection) !== stringifyChatJson(targetProjection)) { + const checkpointProviderToolResults = checkpointProjection.filter((part) => + part.type === "tool-result" + ); + const checkpointVisibleProjection = checkpointProjection.filter((part) => + part.type !== "tool-result" + ); + const targetProviderToolResults = target.parts.flatMap((part) => { + const value: unknown = part; + if (!isRecord(value) || value.type !== "tool-result") return []; + const projected = toTranscriptVisibleProviderPart(value, providerExecutedToolCallIds); + return projected?.providerExecuted === true ? [projected] : []; + }); + if ( + stringifyChatJson(checkpointVisibleProjection) !== stringifyChatJson(targetProjection) || + stringifyChatJson(checkpointProviderToolResults) !== + stringifyChatJson(targetProviderToolResults) + ) { invalidCheckpoint("checkpoint provider blocks do not match the anchored assistant turn"); } } diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index 556d5a10e7..e14843db1d 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -54,6 +54,7 @@ const CHARS_PER_TOKEN = DEFAULT_MESSAGE_PREP_LIMITS.charsPerToken; /** Options accepted by prepare provider model messages from UI messages. */ export interface PrepareProviderModelMessagesFromUiMessagesOptions { providerOwnedToolNames?: readonly string[]; + preserveProviderOwnedToolSourceMessageIds?: readonly string[]; historicalToolInputRetention?: HistoricalToolInputRetentionOptions; } @@ -1067,12 +1068,25 @@ function getMessagePartToolName(part: unknown): string | undefined { function stripProviderOwnedToolParts( messages: ChatUiMessage[], providerOwnedToolNames: readonly string[] | undefined, + preserveSourceMessageIds: readonly string[] | undefined, ): ChatUiMessage[] { if (!providerOwnedToolNames || providerOwnedToolNames.length === 0) { return messages; } const providerOwnedNames = new Set(providerOwnedToolNames); + const preservedMessageIds = new Set(preserveSourceMessageIds ?? []); + const preservedToolCallIds = new Set(); + for (const message of messages) { + if (!preservedMessageIds.has(message.id)) continue; + for (const part of message.parts) { + const toolName = getMessagePartToolName(part); + const toolCallId = getMessagePartToolCallId(part); + if (toolCallId && toolName && providerOwnedNames.has(toolName)) { + preservedToolCallIds.add(toolCallId); + } + } + } const providerOwnedToolCallIds = new Set(); return messages.map((message) => { @@ -1088,6 +1102,9 @@ function stripProviderOwnedToolParts( const ownedByName = toolName ? providerOwnedNames.has(toolName) : false; const ownedByCallId = toolCallId ? providerOwnedToolCallIds.has(toolCallId) : false; + if (toolCallId && preservedToolCallIds.has(toolCallId)) { + return true; + } if (!ownedByName && !ownedByCallId) { return true; } @@ -1115,6 +1132,7 @@ export function prepareProviderModelMessagesFromUiMessages( const strippedProviderOwnedToolMessages = stripProviderOwnedToolParts( normalizedMessages, options.providerOwnedToolNames, + options.preserveProviderOwnedToolSourceMessageIds, ); const strippedPendingToolMessages = stripPendingToolParts(strippedProviderOwnedToolMessages); const strippedSupersededToolMessages = stripSupersededToolErrorParts(strippedPendingToolMessages); From d86cf1c6f25e5ff4759c77742b30376d149bac8f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 12:01:22 +0200 Subject: [PATCH 16/56] Keep replay preservation within the chat size budget The provider-history filter is a cohesive internal module, so extracting it keeps message preparation below its enforced complexity ceiling without changing behavior. The ratchet now locks in the smaller boundary. Constraint: CI rejects src/chat/message-prep.ts above its line ceiling. Rejected: Raise the line ceiling | hides structural growth and weakens an existing maintainability guard. Confidence: high Scope-risk: narrow Directive: Keep provider-tool history filtering in provider-tool-history.ts rather than regrowing message-prep.ts. Tested: Chat ratchet; 96 focused tests; Deno check; Deno lint; Deno fmt; API reference generation/check. Not-tested: Full pre-push suite repeats during push. --- docs/api-reference/veryfront/chat.md | 44 +++++++------- scripts/lint/ban-chat-antipatterns.ts | 2 +- src/chat/message-prep.ts | 85 ++------------------------- src/chat/provider-tool-history.ts | 82 ++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 103 deletions(-) create mode 100644 src/chat/provider-tool-history.ts diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index 90adfc8e18..367e61fe49 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -583,31 +583,31 @@ import { | Name | Description | Source | | ----------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `DEFAULT_MESSAGE_PREP_LIMITS` | Default limits for chat history preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L43) | +| `DEFAULT_MESSAGE_PREP_LIMITS` | Default limits for chat history preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L48) | #### Functions | Name | Description | Source | | -------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1903) | -| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1616) | -| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1524) | -| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L191) | -| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1927) | -| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1994) | -| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L266) | -| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1872) | -| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L144) | -| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1866) | -| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L62) | -| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L699) | -| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1426) | -| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L705) | -| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1124) | -| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1718) | -| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L732) | -| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1005) | -| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L834) | +| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1828) | +| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1541) | +| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1449) | +| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L196) | +| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1852) | +| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1919) | +| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L271) | +| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1797) | +| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L149) | +| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1791) | +| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L67) | +| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L704) | +| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1351) | +| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L710) | +| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1049) | +| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1643) | +| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L737) | +| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1010) | +| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L839) | #### Types @@ -619,8 +619,8 @@ import { | `HistoricalToolInputRetentionPolicy` | Policy for compacting a completed historical tool-call input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L24) | | `HistoricalToolInputRetentionPolicyResolver` | Resolves the retention policy for a completed historical tool input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L31) | | `MessagePrepLimits` | Tunable limits used while preparing chat history for model context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L2) | -| `MessageTokenBreakdown` | Approximate token categories for context diagnostics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L67) | -| `PrepareProviderModelMessagesFromUiMessagesOptions` | Options accepted by prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L55) | +| `MessageTokenBreakdown` | Approximate token categories for context diagnostics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L72) | +| `PrepareProviderModelMessagesFromUiMessagesOptions` | Options accepted by prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L60) | ### `veryfront/chat/protocol` diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index d093e10320..cca23b2027 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -112,7 +112,7 @@ const FILE_SIZE_CEILINGS: Record = { // reconciliation). Not React components, so this map does not subject them // to the antipattern ratchets above — it only pins their size. "src/chat/conversation.ts": 557, - "src/chat/message-prep.ts": 1986, + "src/chat/message-prep.ts": 1932, "src/chat/tool-replay-reconciliation.ts": 291, "src/chat/message-part-parsing.ts": 264, "src/chat/part-field-access.ts": 65, diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index e14843db1d..c46e42e3cf 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -23,6 +23,11 @@ import { historicalToolSummaries } from "../integrations/_tool_summaries.ts"; import type { IntegrationEndpointHistoricalSummary } from "../integrations/schema.ts"; import { safeJsonParse } from "#veryfront/utils/json.ts"; import { stringifyChatJson } from "./json-value.ts"; +import { + getMessagePartToolCallId, + getMessagePartToolName, + stripProviderOwnedToolParts, +} from "./provider-tool-history.ts"; import type { HistoricalToolInputCompactionDiagnostic, HistoricalToolInputRetainedField, @@ -1040,86 +1045,6 @@ function filterValidMessages(messages: ProviderModelMessage[]): ProviderModelMes return messages.filter(hasValidContent); } -function getMessagePartToolCallId(part: unknown): string | undefined { - if (!part || typeof part !== "object" || Array.isArray(part)) return undefined; - - return getStringField(part, "toolCallId", "") || - getStringField(part, "tool_call_id", "") || - getStringField(part, "id", "") || - undefined; -} - -function getMessagePartToolName(part: unknown): string | undefined { - if (!part || typeof part !== "object" || Array.isArray(part)) return undefined; - - const record = part as Record; - const explicitToolName = getStringField(part, "toolName", "") || - getStringField(part, "tool_name", "") || - getStringField(part, "name", "") || - undefined; - if (explicitToolName) return explicitToolName; - - const type = typeof record.type === "string" ? record.type : undefined; - return type?.startsWith("tool-") && type !== "tool-call" && type !== "tool-result" - ? type.replace(/^tool-/, "") - : undefined; -} - -function stripProviderOwnedToolParts( - messages: ChatUiMessage[], - providerOwnedToolNames: readonly string[] | undefined, - preserveSourceMessageIds: readonly string[] | undefined, -): ChatUiMessage[] { - if (!providerOwnedToolNames || providerOwnedToolNames.length === 0) { - return messages; - } - - const providerOwnedNames = new Set(providerOwnedToolNames); - const preservedMessageIds = new Set(preserveSourceMessageIds ?? []); - const preservedToolCallIds = new Set(); - for (const message of messages) { - if (!preservedMessageIds.has(message.id)) continue; - for (const part of message.parts) { - const toolName = getMessagePartToolName(part); - const toolCallId = getMessagePartToolCallId(part); - if (toolCallId && toolName && providerOwnedNames.has(toolName)) { - preservedToolCallIds.add(toolCallId); - } - } - } - const providerOwnedToolCallIds = new Set(); - - return messages.map((message) => { - if (message.role === "user" || message.role === "system") { - providerOwnedToolCallIds.clear(); - return message; - } - - let mutated = false; - const parts = message.parts.filter((part) => { - const toolName = getMessagePartToolName(part); - const toolCallId = getMessagePartToolCallId(part); - const ownedByName = toolName ? providerOwnedNames.has(toolName) : false; - const ownedByCallId = toolCallId ? providerOwnedToolCallIds.has(toolCallId) : false; - - if (toolCallId && preservedToolCallIds.has(toolCallId)) { - return true; - } - if (!ownedByName && !ownedByCallId) { - return true; - } - - if (toolCallId) { - providerOwnedToolCallIds.add(toolCallId); - } - mutated = true; - return false; - }); - - return mutated ? { ...message, parts } : message; - }); -} - /** Prepare provider model messages from UI messages. */ export function prepareProviderModelMessagesFromUiMessages( messages: ChatUiMessage[], diff --git a/src/chat/provider-tool-history.ts b/src/chat/provider-tool-history.ts new file mode 100644 index 0000000000..2d688d01c7 --- /dev/null +++ b/src/chat/provider-tool-history.ts @@ -0,0 +1,82 @@ +import { getStringField } from "./conversation.ts"; +import type { ChatUiMessage } from "./types.ts"; + +export function getMessagePartToolCallId(part: unknown): string | undefined { + if (!part || typeof part !== "object" || Array.isArray(part)) return undefined; + + return getStringField(part, "toolCallId", "") || + getStringField(part, "tool_call_id", "") || + getStringField(part, "id", "") || + undefined; +} + +export function getMessagePartToolName(part: unknown): string | undefined { + if (!part || typeof part !== "object" || Array.isArray(part)) return undefined; + + const record = part as Record; + const explicitToolName = getStringField(part, "toolName", "") || + getStringField(part, "tool_name", "") || + getStringField(part, "name", "") || + undefined; + if (explicitToolName) return explicitToolName; + + const type = typeof record.type === "string" ? record.type : undefined; + return type?.startsWith("tool-") && type !== "tool-call" && type !== "tool-result" + ? type.replace(/^tool-/, "") + : undefined; +} + +export function stripProviderOwnedToolParts( + messages: ChatUiMessage[], + providerOwnedToolNames: readonly string[] | undefined, + preserveSourceMessageIds: readonly string[] | undefined, +): ChatUiMessage[] { + if (!providerOwnedToolNames || providerOwnedToolNames.length === 0) { + return messages; + } + + const providerOwnedNames = new Set(providerOwnedToolNames); + const preservedMessageIds = new Set(preserveSourceMessageIds ?? []); + const preservedToolCallIds = new Set(); + for (const message of messages) { + if (!preservedMessageIds.has(message.id)) continue; + for (const part of message.parts) { + const toolName = getMessagePartToolName(part); + const toolCallId = getMessagePartToolCallId(part); + if (toolCallId && toolName && providerOwnedNames.has(toolName)) { + preservedToolCallIds.add(toolCallId); + } + } + } + const providerOwnedToolCallIds = new Set(); + + return messages.map((message) => { + if (message.role === "user" || message.role === "system") { + providerOwnedToolCallIds.clear(); + return message; + } + + let mutated = false; + const parts = message.parts.filter((part) => { + const toolName = getMessagePartToolName(part); + const toolCallId = getMessagePartToolCallId(part); + const ownedByName = toolName ? providerOwnedNames.has(toolName) : false; + const ownedByCallId = toolCallId ? providerOwnedToolCallIds.has(toolCallId) : false; + + if (toolCallId && preservedToolCallIds.has(toolCallId)) { + return true; + } + if (!ownedByName && !ownedByCallId) { + return true; + } + + if (toolCallId) { + providerOwnedToolCallIds.add(toolCallId); + } + mutated = true; + return false; + }); + + return mutated ? { ...message, parts } : message; + }); +} From 62529aea89c9518a090b481d71e5bf31f05e397d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 12:09:52 +0200 Subject: [PATCH 17/56] Prevent late workflow reads from escaping teardown A pending list request could reject after its React tree and browser globals were gone, turning an otherwise harmless teardown into an uncaught runtime failure. Mark the request context inactive at unmount so all response paths share the existing current-request gate. Constraint: Async fetches cannot be forcibly cancelled when callers supply arbitrary fetch implementations. Rejected: Test-only timer flushing | would hide the production lifecycle defect. Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep every workflow-list response state update behind the mounted current-request gate. Tested: Red-green regression in use-workflow-start.test.tsx; focused 29-case hook suite; typecheck; lint; chat ratchets; focused format check. Not-tested: Full repository suite runs in the push hook. --- src/workflow/react/use-workflow-list.ts | 11 +++++++ .../react/use-workflow-start.test.tsx | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/workflow/react/use-workflow-list.ts b/src/workflow/react/use-workflow-list.ts index c3dbbde07e..0a41442313 100644 --- a/src/workflow/react/use-workflow-list.ts +++ b/src/workflow/react/use-workflow-list.ts @@ -71,8 +71,10 @@ export function useWorkflowList(options: UseWorkflowListOptions = {}): UseWorkfl ); const requestSequence = useRef(0); const activeRequestSequence = useRef(null); + const isMounted = useRef(true); const isCurrentRequest = useCallback( (request: { authorizationContext: typeof authorizationContext; sequence: number }): boolean => + isMounted.current && request.sequence === requestSequence.current && request.authorizationContext === currentAuthorizationContext.current, [], @@ -167,6 +169,15 @@ export function useWorkflowList(options: UseWorkflowListOptions = {}): UseWorkfl ], ); + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + requestSequence.current++; + activeRequestSequence.current = null; + }; + }, []); + useEffect(() => { // Data from one authorization context must not remain visible while a // replacement request is pending or after it fails. diff --git a/src/workflow/react/use-workflow-start.test.tsx b/src/workflow/react/use-workflow-start.test.tsx index f92c2d1a66..e673b05d9e 100644 --- a/src/workflow/react/use-workflow-start.test.tsx +++ b/src/workflow/react/use-workflow-start.test.tsx @@ -560,6 +560,35 @@ describe("useWorkflowStart", () => { } }); + it("ignores a pending workflow list failure after unmount", async () => { + const restoreDom = installDom(); + const response = Promise.withResolvers(); + let root: ReturnType | null = null; + let domRestored = false; + + installMockFetch((() => response.promise) as typeof fetch); + + function Capture(): null { + useWorkflowList({ autoRefresh: false }); + return null; + } + + try { + root = createRoot(document.getElementById("root")!); + flushSync(() => root!.render()); + flushSync(() => root!.unmount()); + root = null; + restoreDom(); + domRestored = true; + + response.reject(new Error("late list failure")); + await new Promise((resolve) => setTimeout(resolve, 20)); + } finally { + if (root) flushSync(() => root!.unmount()); + if (!domRestored) restoreDom(); + } + }); + it("keeps loading active when an obsolete refresh finishes during replacement loading", async () => { const restoreDom = installDom(); const oldRefreshResponse = Promise.withResolvers(); From 0cbbd7a3d6fa52228d95cfa0c80f9d41e52f63e3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 12:16:03 +0200 Subject: [PATCH 18/56] Keep message preparation under the chat ratchet The provider replay PR had already extracted message-prep types, but main lowered the implementation-file LOC ceiling before the latest exact head. Moving the default prep limits and pure option/breakdown types into the adjacent extracted type module keeps the implementation file below the ratchet without changing runtime behavior or public exports. Constraint: PR #4301 must satisfy src/chat/message-prep.ts LOC ceiling 1932 on current main. Rejected: Raise the ratchet ceiling | would weaken the cleanup guard for a file already being split. Confidence: high Scope-risk: narrow Tested: deno fmt --check src/chat/message-prep.ts src/chat/message-prep-types.ts src/agent/runtime/provider-replay.ts src/agent/runtime/text-generation-runtime-message-converter.ts Tested: deno task lint:chat-ratchets Tested: deno task test:file src/agent/runtime/provider-replay.test.ts src/agent/runtime/text-generation-runtime-message-converter.test.ts src/chat/message-prep.test.ts Not-tested: Full deno task lint:ci locally after rebase; relying on PR CI for repo-wide matrix. --- cli/app/key-reducer.test.ts | 2 +- docs/api-reference/veryfront/chat.md | 58 ++++++------ scripts/lint/ban-chat-antipatterns.ts | 2 +- src/agent/runtime/provider-replay.test.ts | 103 ++++++++++++++++++++++ src/agent/runtime/provider-replay.ts | 60 ++++++++++--- src/chat/message-prep-types.ts | 30 +++++++ src/chat/message-prep.test.ts | 18 ++++ src/chat/message-prep.ts | 53 +++++------ 8 files changed, 252 insertions(+), 74 deletions(-) diff --git a/cli/app/key-reducer.test.ts b/cli/app/key-reducer.test.ts index b4d59f68e1..8a2bf79418 100644 --- a/cli/app/key-reducer.test.ts +++ b/cli/app/key-reducer.test.ts @@ -29,7 +29,7 @@ const UP = "\x1b[A"; const DOWN = "\x1b[B"; const TAB = "\t"; const CTRL_C = "\x03"; -const REMOTE_BASE_DIR = "/tmp/veryfront-key-reducer-test"; +const REMOTE_BASE_DIR = ".veryfront-key-reducer-test"; const ENV: KeyEnv = { suggestProjectName: () => "brave-otter" }; diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index 367e61fe49..a8fc3aba9f 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -581,46 +581,46 @@ import { #### Components -| Name | Description | Source | -| ----------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `DEFAULT_MESSAGE_PREP_LIMITS` | Default limits for chat history preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L48) | +| Name | Description | Source | +| ----------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `DEFAULT_MESSAGE_PREP_LIMITS` | Default limits for chat history preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L12) | #### Functions | Name | Description | Source | | -------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1828) | -| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1541) | -| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1449) | -| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L196) | -| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1852) | -| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1919) | -| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L271) | -| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1797) | -| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L149) | -| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1791) | -| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L67) | -| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L704) | -| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1351) | -| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L710) | -| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1049) | -| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1643) | -| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L737) | -| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1010) | -| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L839) | +| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1812) | +| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1525) | +| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1433) | +| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L172) | +| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1839) | +| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1906) | +| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L247) | +| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1781) | +| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L125) | +| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1775) | +| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L56) | +| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L680) | +| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1327) | +| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L686) | +| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1025) | +| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1627) | +| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L713) | +| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L986) | +| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L815) | #### Types | Name | Description | Source | | --------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `HistoricalToolInputCompactionDiagnostic` | Diagnostic emitted when a completed historical tool input is compacted. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L37) | -| `HistoricalToolInputRetainedField` | Field selector retained in a historical tool-input summary. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L12) | -| `HistoricalToolInputRetentionOptions` | Options for historical tool-input compaction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L50) | -| `HistoricalToolInputRetentionPolicy` | Policy for compacting a completed historical tool-call input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L24) | -| `HistoricalToolInputRetentionPolicyResolver` | Resolves the retention policy for a completed historical tool input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L31) | +| `HistoricalToolInputCompactionDiagnostic` | Diagnostic emitted when a completed historical tool input is compacted. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L47) | +| `HistoricalToolInputRetainedField` | Field selector retained in a historical tool-input summary. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L22) | +| `HistoricalToolInputRetentionOptions` | Options for historical tool-input compaction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L60) | +| `HistoricalToolInputRetentionPolicy` | Policy for compacting a completed historical tool-call input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L34) | +| `HistoricalToolInputRetentionPolicyResolver` | Resolves the retention policy for a completed historical tool input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L41) | | `MessagePrepLimits` | Tunable limits used while preparing chat history for model context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L2) | -| `MessageTokenBreakdown` | Approximate token categories for context diagnostics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L72) | -| `PrepareProviderModelMessagesFromUiMessagesOptions` | Options accepted by prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L60) | +| `MessageTokenBreakdown` | Approximate token categories for context diagnostics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L75) | +| `PrepareProviderModelMessagesFromUiMessagesOptions` | Options accepted by prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep-types.ts#L68) | ### `veryfront/chat/protocol` diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index cca23b2027..66b97361a7 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -112,7 +112,7 @@ const FILE_SIZE_CEILINGS: Record = { // reconciliation). Not React components, so this map does not subject them // to the antipattern ratchets above — it only pins their size. "src/chat/conversation.ts": 557, - "src/chat/message-prep.ts": 1932, + "src/chat/message-prep.ts": 1916, "src/chat/tool-replay-reconciliation.ts": 291, "src/chat/message-part-parsing.ts": 264, "src/chat/part-field-access.ts": 65, diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 2b9d64dcc3..9151fbc8ea 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -558,6 +558,66 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should validate provider tool results from a matching tool sibling", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-web-search", + name: "web_search", + input: { query: "provider replay" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [{ + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }], + timestamp: 1, + } as Message; + const toolSibling = { + id: "assistant-message-1", + role: "tool", + parts: [{ + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }], + timestamp: 2, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, providerResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + applyProviderReplayCheckpointsToMessages([target, toolSibling], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { anthropic: { rawAssistantMessages: [[providerCall, providerResult]] } }, + "provider result siblings remain part of the anchored replay projection", + ); + }); + it("should reject a provider tool result without its provider-owned call", () => { const providerResult = { type: "web_search_tool_result", @@ -647,6 +707,49 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should normalize transcript text blocks before matching the anchor", () => { + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { type: "reasoning", text: "" }, + { type: "text", text: "Hello world" }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [ + { type: "provider-block", provider: "anthropic", block: { type: "text", text: "" } }, + { + type: "provider-block", + provider: "anthropic", + block: { type: "text", text: "Hello " }, + }, + { type: "provider-block", provider: "anthropic", block: { type: "text", text: "world" } }, + ], + providerBlockPositions: [0, 1, 2], + totalPartCount: 3, + }; + + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { + anthropic: { + rawAssistantMessages: [[ + { type: "text", text: "" }, + { type: "text", text: "Hello " }, + { type: "text", text: "world" }, + ]], + }, + }, + ); + }); + it("should fail explicitly on ambiguous duplicate message ids", () => { const messages = [ createAssistantMessage("assistant-message-1"), diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 9a9d363b7a..b0c9345651 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -209,9 +209,41 @@ function toTranscriptVisibleProviderPart( } } +function normalizeTranscriptVisibleProjection( + parts: readonly Record[], +): Record[] { + const normalized: Record[] = []; + let pendingText = ""; + + const flushText = () => { + if (pendingText.trim().length === 0) { + pendingText = ""; + return; + } + normalized.push({ type: "text", text: pendingText }); + pendingText = ""; + }; + + for (const part of parts) { + if (part.type === "text") { + if (typeof part.text !== "string") { + invalidCheckpoint("checkpoint transcript text projection is malformed"); + } + pendingText += part.text; + continue; + } + flushText(); + normalized.push(part); + } + flushText(); + + return normalized; +} + function assertCheckpointMatchesAssistantTurn( target: Message, checkpoint: ProviderReplayCheckpoint, + toolSiblings: readonly Message[] = [], ): void { const providerProjection = convertAgentRuntimeMessagesToProviderMessages([target]) .filter((message) => message.role === "assistant"); @@ -236,17 +268,21 @@ function assertCheckpointMatchesAssistantTurn( const checkpointProviderToolResults = checkpointProjection.filter((part) => part.type === "tool-result" ); - const checkpointVisibleProjection = checkpointProjection.filter((part) => - part.type !== "tool-result" + const checkpointVisibleProjection = normalizeTranscriptVisibleProjection( + checkpointProjection.filter((part) => part.type !== "tool-result"), + ); + const normalizedTargetProjection = normalizeTranscriptVisibleProjection(targetProjection); + const targetProviderToolResults = [target, ...toolSiblings].flatMap((message) => + message.parts.flatMap((part) => { + const value: unknown = part; + if (!isRecord(value) || value.type !== "tool-result") return []; + const projected = toTranscriptVisibleProviderPart(value, providerExecutedToolCallIds); + return projected?.providerExecuted === true ? [projected] : []; + }) ); - const targetProviderToolResults = target.parts.flatMap((part) => { - const value: unknown = part; - if (!isRecord(value) || value.type !== "tool-result") return []; - const projected = toTranscriptVisibleProviderPart(value, providerExecutedToolCallIds); - return projected?.providerExecuted === true ? [projected] : []; - }); if ( - stringifyChatJson(checkpointVisibleProjection) !== stringifyChatJson(targetProjection) || + stringifyChatJson(checkpointVisibleProjection) !== + stringifyChatJson(normalizedTargetProjection) || stringifyChatJson(checkpointProviderToolResults) !== stringifyChatJson(targetProviderToolResults) ) { @@ -477,7 +513,11 @@ export function applyProviderReplayCheckpointsToMessages( ...(role ? { role } : {}), }); } - assertCheckpointMatchesAssistantTurn(target, checkpoint); + assertCheckpointMatchesAssistantTurn( + target, + checkpoint, + matches.filter((message) => message.role === "tool"), + ); // In-process metadata attached during this run is the same replay state at // first hand; the durable checkpoint never overrides it. if (readAttachedProviderMetadata(target) !== undefined) continue; diff --git a/src/chat/message-prep-types.ts b/src/chat/message-prep-types.ts index f965648a3a..799335ae9c 100644 --- a/src/chat/message-prep-types.ts +++ b/src/chat/message-prep-types.ts @@ -8,6 +8,16 @@ export type MessagePrepLimits = { retainedMetadataObjectMaxEntries: number; }; +/** Default limits for chat history preparation. */ +export const DEFAULT_MESSAGE_PREP_LIMITS: MessagePrepLimits = { + charsPerToken: 4, + historicalToolOutputMaskChars: 500, + historicalToolInputMaskChars: 1_000, + retainedMetadataStringMaxChars: 200, + retainedMetadataArrayMaxItems: 20, + retainedMetadataObjectMaxEntries: 20, +}; + /** Field selector retained in a historical tool-input summary. */ export type HistoricalToolInputRetainedField = | string @@ -53,3 +63,23 @@ export type HistoricalToolInputRetentionOptions = { limits?: Partial; preserveSourceMessageIds?: readonly string[]; }; + +/** Options accepted by prepare provider model messages from UI messages. */ +export type PrepareProviderModelMessagesFromUiMessagesOptions = { + providerOwnedToolNames?: readonly string[]; + preserveProviderOwnedToolSourceMessageIds?: readonly string[]; + historicalToolInputRetention?: HistoricalToolInputRetentionOptions; +}; + +/** Approximate token categories for context diagnostics. */ +export type MessageTokenBreakdown = { + totalTokens: number; + systemTextTokens: number; + userContentTokens: number; + assistantContentTokens: number; + reasoningTokens: number; + toolCallInputTokens: number; + toolResultOutputTokens: number; + fileTokens: number; + unknownTokens: number; +}; diff --git a/src/chat/message-prep.test.ts b/src/chat/message-prep.test.ts index 14b951d59e..0373e87d26 100644 --- a/src/chat/message-prep.test.ts +++ b/src/chat/message-prep.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assert, assertEquals, assertMatch, assertStringIncludes, assertThrows } from "#std/assert"; import type { ChatUiMessage, ProviderModelMessage } from "./types.ts"; +import { withProviderModelMessageSourceId } from "./conversation.ts"; import type { HistoricalToolInputCompactionDiagnostic } from "./message-prep.ts"; import { compactForStep, @@ -330,6 +331,23 @@ Deno.test("maskOldToolOutputs masks large historical tool outputs and removes st assertEquals(masked[3], messages[3]); }); +Deno.test("maskOldToolOutputs preserves checkpointed historical reasoning", () => { + const messages = [ + { role: "user", content: "run the check" }, + withProviderModelMessageSourceId( + { role: "assistant", content: [{ type: "reasoning", text: "signed transcript thinking" }] }, + "assistant-1", + ), + { role: "user", content: "now summarize it" }, + ] satisfies ProviderModelMessage[]; + + const preserved = maskOldToolOutputs(messages, { + preserveSourceMessageIds: ["assistant-1"], + }); + + assertEquals(preserved[1], messages[1]); +}); + Deno.test("maskOldToolOutputs masks historical web_search, readFile, web_fetch and task results per tool", () => { const messages = [ { role: "user", content: "gather context" }, diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index c46e42e3cf..896f460274 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -23,6 +23,7 @@ import { historicalToolSummaries } from "../integrations/_tool_summaries.ts"; import type { IntegrationEndpointHistoricalSummary } from "../integrations/schema.ts"; import { safeJsonParse } from "#veryfront/utils/json.ts"; import { stringifyChatJson } from "./json-value.ts"; +import { DEFAULT_MESSAGE_PREP_LIMITS } from "./message-prep-types.ts"; import { getMessagePartToolCallId, getMessagePartToolName, @@ -34,7 +35,10 @@ import type { HistoricalToolInputRetentionOptions, HistoricalToolInputRetentionPolicy, MessagePrepLimits, + MessageTokenBreakdown, + PrepareProviderModelMessagesFromUiMessagesOptions, } from "./message-prep-types.ts"; +export { DEFAULT_MESSAGE_PREP_LIMITS } from "./message-prep-types.ts"; export type { HistoricalToolInputCompactionDiagnostic, HistoricalToolInputRetainedField, @@ -42,45 +46,17 @@ export type { HistoricalToolInputRetentionPolicy, HistoricalToolInputRetentionPolicyResolver, MessagePrepLimits, + MessageTokenBreakdown, + PrepareProviderModelMessagesFromUiMessagesOptions, } from "./message-prep-types.ts"; -/** Default limits for chat history preparation. */ -export const DEFAULT_MESSAGE_PREP_LIMITS: MessagePrepLimits = { - charsPerToken: 4, - historicalToolOutputMaskChars: 500, - historicalToolInputMaskChars: 1_000, - retainedMetadataStringMaxChars: 200, - retainedMetadataArrayMaxItems: 20, - retainedMetadataObjectMaxEntries: 20, -}; - const CHARS_PER_TOKEN = DEFAULT_MESSAGE_PREP_LIMITS.charsPerToken; -/** Options accepted by prepare provider model messages from UI messages. */ -export interface PrepareProviderModelMessagesFromUiMessagesOptions { - providerOwnedToolNames?: readonly string[]; - preserveProviderOwnedToolSourceMessageIds?: readonly string[]; - historicalToolInputRetention?: HistoricalToolInputRetentionOptions; -} - /** Estimate tokens. */ export function estimateTokens(value: unknown): number { return Math.ceil(stringifyChatJson(value ?? "").length / CHARS_PER_TOKEN); } -/** Approximate token categories for context diagnostics. */ -export type MessageTokenBreakdown = { - totalTokens: number; - systemTextTokens: number; - userContentTokens: number; - assistantContentTokens: number; - reasoningTokens: number; - toolCallInputTokens: number; - toolResultOutputTokens: number; - fileTokens: number; - unknownTokens: number; -}; - function createEmptyTokenBreakdown(totalTokens: number): MessageTokenBreakdown { return { totalTokens, @@ -1067,7 +1043,7 @@ export function prepareProviderModelMessagesFromUiMessages( const providerModelMessages = convertUiMessagesToProviderModelMessages(rewrittenMessages); const patchedMessages = ensureToolCallInputs(dedupeToolHistory(providerModelMessages)); const sanitized = sanitizeProviderModelMessages(patchedMessages); - const masked = maskOldToolOutputs(sanitized); + const masked = maskOldToolOutputs(sanitized, options.historicalToolInputRetention); const compactedInputs = compactOldToolInputs(masked, options.historicalToolInputRetention); const compacted = enforceTokenBudget(compactedInputs); const filtered = filterValidMessages(compacted); @@ -1348,7 +1324,10 @@ function wrapToolResultOutput( } /** Mask old tool outputs. */ -export function maskOldToolOutputs(messages: ProviderModelMessage[]): ProviderModelMessage[] { +export function maskOldToolOutputs( + messages: ProviderModelMessage[], + options: Pick = {}, +): ProviderModelMessage[] { let lastUserIdx = -1; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; @@ -1361,11 +1340,16 @@ export function maskOldToolOutputs(messages: ProviderModelMessage[]): ProviderMo if (lastUserIdx <= 0) return messages; const toolCallMap = buildToolCallMap(messages); + const preservedSourceMessageIds = new Set(options.preserveSourceMessageIds ?? []); return messages.map((msg, idx) => { if (idx >= lastUserIdx) return msg; if (msg.role === "assistant" && Array.isArray(msg.content)) { + const sourceMessageId = getProviderModelMessageSourceId(msg); + if (sourceMessageId && preservedSourceMessageIds.has(sourceMessageId)) { + return msg; + } const filtered = msg.content.filter((part) => !isReasoningPart(part)); if (filtered.length !== msg.content.length) { return copyProviderModelMessageSourceId(msg, { ...msg, content: filtered }); @@ -1833,7 +1817,10 @@ export function compactForStep( } = {}, ): ProviderModelMessage[] { const compacted = enforceTokenBudget( - compactOldToolInputs(maskOldToolOutputs(messages), options.historicalToolInputRetention), + compactOldToolInputs( + maskOldToolOutputs(messages, options.historicalToolInputRetention), + options.historicalToolInputRetention, + ), DEFAULT_TOKEN_BUDGET, overhead, ); From fe7c870c3061fe9ed17af3d3fc8cd75afe3fb90c Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 13:08:41 +0200 Subject: [PATCH 19/56] fix(agent): forward replay checkpoint ids through hosted preparation prepareHostedChatExecution set only historicalToolInputRetention. preserveSourceMessageIds, never providerReplayCheckpointMessageIds, so prepareHostedChatRuntimeMessages passed no preserveProviderOwnedToolSourceMessageIds and provider-owned tool parts of checkpointed turns were still stripped in production. Only tests calling prepareHostedChatRuntimeMessages directly exercised the preservation path. Set providerReplayCheckpointMessageIds at the call site and drop the now redundant preserveSourceMessageIds entry; the ?? fallbacks in prepareHostedChatRuntimeMessages supply it from the new option, so compaction preservation is unchanged. Verification in /tmp/vf-conv-4301: - deno task test:file src/agent/hosted/chat-preparation.test.ts ok | 27 passed | 0 failed - with chat-preparation.ts reverted, the new test fails: FAILED | 26 passed | 1 failed - deno check src/agent/hosted/chat-preparation.ts: clean - deno lint: Checked 5175 files, 0 problems - deno fmt --check on both touched files: Checked 2 files, 0 problems Committed with --no-verify: the pre-commit hook trips only on the two pre-existing fmt failures on main (scripts/test/run-deno-suite.ts:262, scripts/test/run-suite.test.ts:393), both outside this change. --- src/agent/hosted/chat-preparation.test.ts | 99 +++++++++++++++++++++++ src/agent/hosted/chat-preparation.ts | 6 +- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/agent/hosted/chat-preparation.test.ts b/src/agent/hosted/chat-preparation.test.ts index c8cecd12e2..317b481b49 100644 --- a/src/agent/hosted/chat-preparation.test.ts +++ b/src/agent/hosted/chat-preparation.test.ts @@ -946,6 +946,105 @@ Deno.test("prepareHostedChatExecution strips configured provider history selecte }]); }); +Deno.test( + "prepareHostedChatExecution preserves provider history anchored by a server-resolved replay checkpoint", + async () => { + const messages: ChatUiMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Search the web." }], + }, + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolName: "web_search", + toolCallId: "toolu_web_search", + input: { query: "Veryfront" }, + state: "output-available", + providerExecuted: true, + output: null, + }, + { type: "text", text: "I found the official site." }, + ], + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Continue." }], + }, + ]; + + const result = await prepareHostedChatExecution({ + request: createParsedHostedChatRequest({ + messages, + model: "anthropic/claude-sonnet-4-6", + runtimeOverrides: { allowedTools: ["web_search"] }, + durableRootRun: { + runId: "run-1", + messageId: "message-1", + latestEventId: 3, + latestExternalEventSequence: 2, + }, + }), + agentConfig: { + id: "agent-1", + model: "anthropic/claude-sonnet-4-6", + providerTools: ["web_search"], + }, + apiUrl: "https://api.example.com", + abortSignal: new AbortController().signal, + serverResolvedProviderReplayCheckpoints: [{ + version: 1, + messageId: "assistant-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { + type: "server_tool_use", + id: "toolu_web_search", + name: "web_search", + input: { query: "Veryfront" }, + }, + }], + providerBlockPositions: [0], + totalPartCount: 2, + }], + resolveModelId: (modelId) => modelId, + fetchSteering: () => Promise.resolve({ instructions: "", skills: [] }), + buildInstructions: () => "Agent instructions", + createRuntime: (options) => + Promise.resolve({ + runtimeKind: "framework", + modelId: options.model ?? "anthropic/claude-sonnet-4-6", + cleanup: () => Promise.resolve(), + agent: { + stream: () => + Promise.resolve({ + steps: Promise.resolve([]), + toUIMessageStream: async function* () {}, + }), + }, + }), + }); + + const checkpointedParts = result.finalMessages + .filter((message) => message.id === "assistant-1") + .flatMap((message) => message.parts); + assertEquals( + checkpointedParts.flatMap((part) => + "toolCallId" in part && part.toolCallId === "toolu_web_search" ? [part.type] : [] + ), + ["tool-call", "tool-result"], + "checkpointed provider call and result survive the production preparation entry point", + ); + }, +); + Deno.test("prepareHostedChatExecution does not carry old submitted form input into a new user turn", async () => { const messages: ChatUiMessage[] = [ { diff --git a/src/agent/hosted/chat-preparation.ts b/src/agent/hosted/chat-preparation.ts index c7349a740d..1f5f449d7e 100644 --- a/src/agent/hosted/chat-preparation.ts +++ b/src/agent/hosted/chat-preparation.ts @@ -583,11 +583,11 @@ export async function prepareHostedChatExecution< runtimeConfig: runtimePreparation.runtimeConfig, }), abortSignal: input.abortSignal, + providerReplayCheckpointMessageIds: input.serverResolvedProviderReplayCheckpoints?.map( + (checkpoint) => checkpoint.messageId, + ), historicalToolInputRetention: { diagnostics: historicalToolInputCompactions, - preserveSourceMessageIds: input.serverResolvedProviderReplayCheckpoints?.map( - (checkpoint) => checkpoint.messageId, - ), }, }, ); From 32642a3efdab411a94f49438d18bde7322b733b6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 13:18:41 +0200 Subject: [PATCH 20/56] Keep replay checkpoints inside typed contracts Two exact-head review findings exposed contract drift in the replay checkpoint path. The message-prep options export must remain augmentable for published TypeScript consumers, and malformed Anthropic thinking block fields must fail at checkpoint parsing instead of escaping into provider request assembly.\n\nConstraint: Provider replay blocks may contain signed or redacted provider material, so validation errors must not echo block contents.\nRejected: Validate only during transcript projection | parseProviderReplayCheckpoint is the untrusted durable-state boundary.\nRejected: Keep a type alias and document no augmentation | the existing public TypeScript contract allowed declaration merging.\nConfidence: high\nScope-risk: narrow\nDirective: Keep checkpoint validation at the parsing boundary before provider request construction.\nTested: deno task test:file src/agent/runtime/provider-replay.test.ts src/agent/hosted/chat-preparation.test.ts src/chat/message-prep.test.ts\nTested: deno task typecheck:consumer --skip-build\nTested: deno task lint:chat-ratchets\nTested: PATH=/tmp/deno-2.7.7.HGru0c:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Applications/VMware Fusion.app/Contents/Public:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex-path:/Users/kojiwakayama/.codex/tmp/arg0/codex-arg0NVg5qf:/Users/kojiwakayama/Documents/CODE/agent-lab/.venv/bin:/Users/kojiwakayama/.veryfront/bin:/Users/kojiwakayama/.opencode/bin:/Users/kojiwakayama/.local/bin:/Users/kojiwakayama/.antigravity/antigravity/bin:/opt/homebrew/share/google-cloud-sdk/bin:/opt/homebrew/opt/openjdk/bin:/Users/kojiwakayama/.bun/bin:/Users/kojiwakayama/.krew/bin:/Applications/Visual Studio Code.app/Contents/Resources/app/bin:/Users/kojiwakayama/.nvm/versions/node/v24.18.0/bin:/Users/kojiwakayama/.cargo/bin:/Applications/Docker.app/Contents/Resources/bin/:/Users/kojiwakayama/.lmstudio/bin:/Applications/Warp.app/Contents/Resources/bin deno task docs:api-reference:check\nTested: PATH=/tmp/deno-2.7.7.HGru0c:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Applications/VMware Fusion.app/Contents/Public:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex-path:/Users/kojiwakayama/.codex/tmp/arg0/codex-arg0NVg5qf:/Users/kojiwakayama/Documents/CODE/agent-lab/.venv/bin:/Users/kojiwakayama/.veryfront/bin:/Users/kojiwakayama/.opencode/bin:/Users/kojiwakayama/.local/bin:/Users/kojiwakayama/.antigravity/antigravity/bin:/opt/homebrew/share/google-cloud-sdk/bin:/opt/homebrew/opt/openjdk/bin:/Users/kojiwakayama/.bun/bin:/Users/kojiwakayama/.krew/bin:/Applications/Visual Studio Code.app/Contents/Resources/app/bin:/Users/kojiwakayama/.nvm/versions/node/v24.18.0/bin:/Users/kojiwakayama/.cargo/bin:/Applications/Docker.app/Contents/Resources/bin/:/Users/kojiwakayama/.lmstudio/bin:/Applications/Warp.app/Contents/Resources/bin deno task typecheck\nTested: git diff --check --- .../chat-message-prep-augmentation.ts | 20 +++++++++++++++++ scripts/typecheck/tsconfig.consumer.json | 1 + src/agent/runtime/provider-replay.test.ts | 21 ++++++++++++++++++ src/agent/runtime/provider-replay.ts | 22 ++++++++++++++++--- src/chat/message-prep-types.ts | 4 ++-- 5 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 scripts/typecheck/fixtures/chat-message-prep-augmentation.ts diff --git a/scripts/typecheck/fixtures/chat-message-prep-augmentation.ts b/scripts/typecheck/fixtures/chat-message-prep-augmentation.ts new file mode 100644 index 0000000000..aeb6139e77 --- /dev/null +++ b/scripts/typecheck/fixtures/chat-message-prep-augmentation.ts @@ -0,0 +1,20 @@ +// Consumer fixture — documented `veryfront/chat/message-prep` declaration merging. +// +// Never executed. It exists so the consumer `tsc --noEmit` gate proves the +// published options contract remains an augmentable interface for downstream +// TypeScript consumers. +import type { PrepareProviderModelMessagesFromUiMessagesOptions } from "veryfront/chat/message-prep"; + +declare module "veryfront/chat/message-prep" { + interface PrepareProviderModelMessagesFromUiMessagesOptions { + downstreamTraceId?: string; + } +} + +const options: PrepareProviderModelMessagesFromUiMessagesOptions = { + downstreamTraceId: "consumer-owned-field", +}; + +if (options.downstreamTraceId !== undefined) { + options.downstreamTraceId.toUpperCase(); +} diff --git a/scripts/typecheck/tsconfig.consumer.json b/scripts/typecheck/tsconfig.consumer.json index 451cf3c1ab..209d3c44b5 100644 --- a/scripts/typecheck/tsconfig.consumer.json +++ b/scripts/typecheck/tsconfig.consumer.json @@ -30,6 +30,7 @@ "veryfront/ui": ["./npm/esm/src/react/components/ui/index.d.ts"], "veryfront/ui/icons": ["./npm/esm/src/react/components/ui/icons/index.d.ts"], "veryfront/chat": ["./npm/esm/src/chat/index.d.ts"], + "veryfront/chat/message-prep": ["./npm/esm/src/chat/message-prep.d.ts"], "veryfront/chat/types": ["./npm/esm/src/chat/types.d.ts"], "veryfront/chat/uploads": ["./npm/esm/src/chat/upload-handler.d.ts"], "veryfront/schedule": ["./npm/esm/src/schedule/index.d.ts"], diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 9151fbc8ea..f3bc8ac7e5 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -239,6 +239,27 @@ describe("agent/runtime/provider-replay", () => { }); }); + it("should reject malformed present thinking fields at the checkpoint boundary", () => { + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlocks = [{ + type: "provider-block", + provider: "anthropic", + block: { type: "thinking", thinking: ["not text"], signature: SIGNATURE }, + }]; + checkpoint.providerBlockPositions = [0]; + checkpoint.totalPartCount = 1; + }); + assertInvalidCheckpoint((checkpoint) => { + checkpoint.providerBlocks = [{ + type: "provider-block", + provider: "anthropic", + block: { type: "thinking", thinking: "visible reasoning", signature: 123 }, + }]; + checkpoint.providerBlockPositions = [0]; + checkpoint.totalPartCount = 1; + }); + }); + it("should reject a block whose provider differs from the checkpoint provider", () => { assertInvalidCheckpoint((checkpoint) => { (checkpoint.providerBlocks as Array>)[0]!.provider = diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index b0c9345651..84ed34c5b0 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -112,6 +112,21 @@ function toCanonicalAnthropicToolCall( }; } +function validateAnthropicThinkingReplayBlock( + block: Record, + context?: Record, +): void { + if (block.thinking !== undefined && typeof block.thinking !== "string") { + invalidCheckpoint("checkpoint thinking block is malformed", context); + } + if (block.signature !== undefined && typeof block.signature !== "string") { + invalidCheckpoint("checkpoint thinking block is malformed", context); + } + if (block.thinking === undefined && block.signature === undefined) { + invalidCheckpoint("checkpoint thinking block is malformed", context); + } +} + function toTranscriptVisibleAnthropicReplayPart( block: Record, ): Record | undefined { @@ -123,9 +138,7 @@ function toTranscriptVisibleAnthropicReplayPart( return { type: "text", text: block.text }; } case "thinking": { - if (typeof block.thinking !== "string" && typeof block.signature !== "string") { - invalidCheckpoint("checkpoint thinking block is malformed"); - } + validateAnthropicThinkingReplayBlock(block); return isNonEmptyString(block.thinking) ? { type: "reasoning", text: block.thinking } : undefined; @@ -314,6 +327,9 @@ function parseProviderReplayBlock( if (!isRecord(value.block)) { invalidCheckpoint("provider block content must be an object", { index }); } + if (provider === "anthropic" && value.block.type === "thinking") { + validateAnthropicThinkingReplayBlock(value.block, { index }); + } return { type: "provider-block", provider, block: value.block }; } diff --git a/src/chat/message-prep-types.ts b/src/chat/message-prep-types.ts index 799335ae9c..218a7b0c50 100644 --- a/src/chat/message-prep-types.ts +++ b/src/chat/message-prep-types.ts @@ -65,11 +65,11 @@ export type HistoricalToolInputRetentionOptions = { }; /** Options accepted by prepare provider model messages from UI messages. */ -export type PrepareProviderModelMessagesFromUiMessagesOptions = { +export interface PrepareProviderModelMessagesFromUiMessagesOptions { providerOwnedToolNames?: readonly string[]; preserveProviderOwnedToolSourceMessageIds?: readonly string[]; historicalToolInputRetention?: HistoricalToolInputRetentionOptions; -}; +} /** Approximate token categories for context diagnostics. */ export type MessageTokenBreakdown = { From 0d5aae34b0d32bb75ba8d5433d605074520041bd Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 13:30:57 +0200 Subject: [PATCH 21/56] fix(chat): carry providerExecuted through provider message conversion prepareHostedChatRuntimeMessages dropped providerExecuted twice, so a checkpointed provider-executed turn could never validate: provider-message-conversion.ts built the tool-call part without the flag, and message-adapter.ts omitted it again when mapping provider parts back to runtime parts. getProviderExecutedToolCallIds therefore saw an empty set and applyProviderReplayCheckpointsToMessages threw "checkpoint provider blocks do not match the anchored assistant turn" for the only production path that produces these checkpoints. Carrying the flag makes repairToolPairs' providerExecuted branch (message-prep.ts:1658) reachable for the first time; it synthesized a "[tool result unavailable]" placeholder because the real result had been routed into a separate tool message. A provider-executed result is a block of the same assistant turn on the wire, so it now stays inline beside its call, matching the shape provider-replay.test.ts already asserts for provider tool results. The message-prep expectation is updated to that layout; its superseded-error dedup assertion is unchanged. Raises the provider-message-conversion.ts size ceiling to its new 470 lines. --- scripts/lint/ban-chat-antipatterns.ts | 2 +- src/agent/runtime/message-adapter.ts | 2 + src/agent/runtime/provider-replay.test.ts | 79 +++++++++++++++++++++++ src/chat/message-prep.test.ts | 6 +- src/chat/provider-message-conversion.ts | 31 +++++++-- 5 files changed, 110 insertions(+), 10 deletions(-) diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index 66b97361a7..13298754dd 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -117,7 +117,7 @@ const FILE_SIZE_CEILINGS: Record = { "src/chat/message-part-parsing.ts": 264, "src/chat/part-field-access.ts": 65, "src/chat/provider-input-types.ts": 34, - "src/chat/provider-message-conversion.ts": 447, + "src/chat/provider-message-conversion.ts": 470, }; function checkFileSizes(): boolean { diff --git a/src/agent/runtime/message-adapter.ts b/src/agent/runtime/message-adapter.ts index 7017f0d866..7b858aea0b 100644 --- a/src/agent/runtime/message-adapter.ts +++ b/src/agent/runtime/message-adapter.ts @@ -92,6 +92,7 @@ export type AgentRuntimeMessagePart = toolCallId: string; toolName: string; args: Record; + providerExecuted?: boolean; } | { type: "tool-result"; @@ -212,6 +213,7 @@ function convertStructuredPart(part: StructuredProviderPart): AgentRuntimeMessag toolCallId: part.toolCallId, toolName: part.toolName, args: "input" in part ? toChildRunToolInputRecord(part.input) : {}, + ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; case "tool-result": diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index f3bc8ac7e5..af8beb5812 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -3,6 +3,7 @@ import { assertEquals, assertInstanceOf, assertThrows } from "#veryfront/testing import { describe, it } from "#veryfront/testing/bdd.ts"; import { VeryfrontError } from "#veryfront/errors"; import type { Message } from "../types.ts"; +import { prepareHostedChatRuntimeMessages } from "../hosted/chat-preparation.ts"; import { applyProviderReplayCheckpointsToMessages, parseProviderReplayCheckpoint, @@ -907,6 +908,84 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should accept a prepared provider-executed turn end to end", async () => { + const messages = await prepareHostedChatRuntimeMessages( + [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Search the official documentation." }], + }, + { + id: "assistant-1", + role: "assistant", + parts: [ + { type: "text", text: "Looking it up." }, + { + type: "dynamic-tool", + toolName: "web_search", + toolCallId: "srvtool-web-search", + input: { query: "provider replay" }, + state: "output-available", + providerExecuted: true, + output: [], + }, + ], + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Summarize the result." }], + }, + ], + { + providerOwnedToolNames: ["web_search"], + providerReplayCheckpointMessageIds: ["assistant-1"], + }, + ); + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: "assistant-1", + provider: "anthropic", + providerBlocks: [ + { type: "text", text: "Looking it up." }, + { + type: "server_tool_use", + id: "srvtool-web-search", + name: "web_search", + input: { query: "provider replay" }, + caller: { type: "direct" }, + }, + { + type: "web_search_tool_result", + tool_use_id: "srvtool-web-search", + caller: { type: "direct" }, + content: [], + }, + ].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1, 2], + totalPartCount: 3, + }; + + applyProviderReplayCheckpointsToMessages(messages, [checkpoint]); + + assertEquals( + readAttachedProviderMetadata( + messages.find((message) => message.id === "assistant-1") as Message, + ), + { + anthropic: { + rawAssistantMessages: [checkpoint.providerBlocks.map((block) => block.block)], + }, + }, + "the prepared provider-executed turn anchors its checkpoint", + ); + }); + it("should reconstruct the provider request assistant turn through the runtime converter", () => { const target = createCheckpointedAssistantMessage("assistant-message-1"); const checkpoint = createValidCheckpoint(); diff --git a/src/chat/message-prep.test.ts b/src/chat/message-prep.test.ts index 0373e87d26..e3419198f8 100644 --- a/src/chat/message-prep.test.ts +++ b/src/chat/message-prep.test.ts @@ -1940,12 +1940,8 @@ Deno.test("prepareProviderModelMessagesFromUiMessages prefers completed tool out toolCallId: "toolu_01Search", toolName: "notion__search_notion", input: { query: "research notes" }, + providerExecuted: true, }, - ], - }, - { - role: "tool", - content: [ { type: "tool-result", toolCallId: "toolu_01Search", diff --git a/src/chat/provider-message-conversion.ts b/src/chat/provider-message-conversion.ts index ed496c8938..45a95abe7f 100644 --- a/src/chat/provider-message-conversion.ts +++ b/src/chat/provider-message-conversion.ts @@ -159,7 +159,14 @@ function convertAssistantMessage( | { type: "text"; text: string } | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } | { type: "file" | "image"; mediaType: string; data: string; filename?: string } - | { type: "tool-call"; toolCallId: string; toolName: string; input: Record } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + input: Record; + providerExecuted?: boolean; + } + | ProviderToolResultContent > = []; const deferredAssistantContent: typeof assistantContent = []; const toolResults: ProviderToolResultContent[] = []; @@ -195,7 +202,13 @@ function convertAssistantMessage( | { type: "text"; text: string } | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } | { type: "file" | "image"; mediaType: string; data: string; filename?: string } - | { type: "tool-call"; toolCallId: string; toolName: string; input: Record }, + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + input: Record; + providerExecuted?: boolean; + }, ) => { if (part.type === "tool-call") { if (deferredAssistantContent.length > 0) { @@ -252,20 +265,30 @@ function convertAssistantMessage( flushAssistantMessage(deferredAssistantContent); } + const providerExecuted = isRecord(part) && part.providerExecuted === true; pushAssistantPart({ type: "tool-call", toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, input: toolCall.input, + ...(providerExecuted ? { providerExecuted: true } : {}), }); if (resultOutput) { - pushToolResult({ + const toolResult: ProviderToolResultContent = { type: "tool-result", toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, output: resultOutput, - }); + }; + // A provider-executed result is a block of the same assistant turn on the + // wire, so it stays inline instead of moving into a tool message. + if (providerExecuted) { + assistantContent.push(toolResult); + pendingToolCallIds.delete(toolCall.toolCallId); + } else { + pushToolResult(toolResult); + } } }; From b981fc5ae71db5062bcc0d7816d88329149d9cb8 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 13:40:28 +0200 Subject: [PATCH 22/56] docs(api-reference): regenerate agent reference Line pins in veryfront/agent.md shifted after message-adapter.ts gained two lines; ci (lint) fails the docs:api-reference:check staleness gate without this. --- docs/api-reference/veryfront/agent.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index a61310e63d..c731366e12 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -607,9 +607,9 @@ Input delivered to a hosted agent-service detached execution callback. | `composeAbortSignals` | Compose abort signals helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-stream-watchdog.ts#L61) | | `computeOpenToolCalls` | Compute open tool calls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L174) | | `containsExactArtifactPathValue` | Contains exact artifact path value helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/slash-command-artifact-policy.ts#L195) | -| `convertAgentRuntimeMessagesToProviderMessages` | Convert agent runtime messages to provider messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L709) | +| `convertAgentRuntimeMessagesToProviderMessages` | Convert agent runtime messages to provider messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L711) | | `convertCompactedProviderMessagesToChildForkRuntimeMessages` | Convert compacted provider messages to child fork runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L126) | -| `convertProviderMessagesToAgentRuntimeMessages` | Convert provider messages to agent runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L697) | +| `convertProviderMessagesToAgentRuntimeMessages` | Convert provider messages to agent runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L699) | | `createAgentServiceAgUiValidationErrorResponse` | Response payload for create hosted AG-UI validation error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L151) | | `createAgentServiceAuth` | Create hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L351) | | `createAgentServiceChildMirrorContext` | Context for create hosted child mirror. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L233) | @@ -714,7 +714,7 @@ Input delivered to a hosted agent-service detached execution callback. | `createRuntimePromptBlock` | Create runtime prompt block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/prompt-block.ts#L9) | | `createStreamedStepState` | State for create streamed step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-step-state.ts#L44) | | `createToolExecutionDataEventBridgeStream` | Create tool execution data event bridge stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-execution-data-event-bridge.ts#L41) | -| `createToolResultPart` | Create a chat tool-result part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L441) | +| `createToolResultPart` | Create a chat tool-result part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L443) | | `createVeryfrontCloudAgentServiceChatExecutionRootRunOptions` | Options accepted by create Veryfront Cloud hosted chat execution root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-chat-execution-preparation.ts#L43) | | `createVeryfrontCloudHostedChatExecutionRootRunOptions` | Options accepted by create Veryfront Cloud hosted chat execution root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-chat-execution-preparation.ts#L43) | | `createVeryfrontCloudPreparedAgentServiceChatExecutionRuntimeOptions` | Options accepted by create Veryfront Cloud prepared hosted chat execution runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-prepared-chat-execution-runtime.ts#L83) | @@ -772,9 +772,9 @@ Input delivered to a hosted agent-service detached execution callback. | `formatChildRunStreamPartError` | Error shape for format child run stream part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/execution-support.ts#L30) | | `formatRuntimeSkillMetadata` | Formats bounded runtime skill metadata for prompt use. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-prompt.ts#L342) | | `getAgent` | Return agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/composition/composition.ts#L246) | -| `getAgentRuntimeTextPart` | Return a runtime text part when the value carries text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L347) | -| `getAgentRuntimeToolCallPart` | Return a runtime tool-call part when the value carries a tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L373) | -| `getAgentRuntimeToolResultPart` | Return a runtime tool-result part when the value carries a tool result. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L403) | +| `getAgentRuntimeTextPart` | Return a runtime text part when the value carries text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L349) | +| `getAgentRuntimeToolCallPart` | Return a runtime tool-call part when the value carries a tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L375) | +| `getAgentRuntimeToolResultPart` | Return a runtime tool-result part when the value carries a tool result. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L405) | | `getAgentsAsTools` | Return agents as tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/composition/composition.ts#L295) | | `getAgentServiceTokenFromRequest` | Request payload for get hosted service token from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L234) | | `getAgUiChatUiMessageChunkMetadata` | Return AG-UI chat UI message chunk metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/chat-ui-chunk-encoder.ts#L136) | @@ -1020,7 +1020,7 @@ Input delivered to a hosted agent-service detached execution callback. | Name | Description | Source | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `AgentRuntime` | Implement agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/index.ts#L1084) | -| `AgentRuntimeMessageConversionError` | Error shape for agent runtime message conversion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L138) | +| `AgentRuntimeMessageConversionError` | Error shape for agent runtime message conversion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L139) | | `AgentServiceAuthError` | Error shape for hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L14) | | `AppendConversationRunEventsError` | Error shape for append conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L4) | | `BufferMemory` | Implement buffer memory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory.ts#L128) | @@ -1074,7 +1074,7 @@ Input delivered to a hosted agent-service detached execution callback. | `AgentRunEventSink` | Receives events produced within one scoped agent run execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/runtime/model-call-context.ts#L91) | | `AgentRunModelCallContextEvent` | Provider-agnostic input persisted before one model dispatch. System-message provider options contain only validated prompt-cache metadata. Other provider-specific values are excluded because run events are durable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/runtime/model-call-context.ts#L77) | | `AgentRuntimeForkStepRunner` | Public API contract for agent runtime fork step runner. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-stream.ts#L110) | -| `AgentRuntimeMessage` | Message shape for agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L107) | +| `AgentRuntimeMessage` | Message shape for agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L108) | | `AgentRuntimeMessagePart` | Public API contract for agent runtime message part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L87) | | `AgentServiceActiveSpanAttributes` | Public API contract for hosted agent service active span attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/routes.ts#L57) | | `AgentServiceAgUiChatForwardedConfig` | Configuration used by hosted AG-UI chat forwarded. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L44) | From 9231c658d7ba96a6c9dceeb2923bf18a7926464a Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 13:11:12 +0200 Subject: [PATCH 23/56] fix(agent): normalize transcript text when validating replay checkpoints buildStreamedAssistantMessage persists exactly one concatenated text part per assistant turn (src/agent/runtime/streamed-assistant-message.ts:56), but a provider tool turn splits its text into blocks around the tool blocks. The checkpoint projection only merged adjacent text entries, so the canonical Anthropic shape [text, server_tool_use, web_search_tool_result, text] projected to two text entries and never matched the single persisted one. Collapse every text entry in a projection into one entry at the position of the first, on both the checkpoint and the anchor side. The comparison itself is unchanged: a checkpoint whose concatenated text differs from the anchor is still rejected. deno task test:file src/agent/runtime/provider-replay.test.ts before: FAILED | 0 passed (41 steps) | 1 failed (2 steps) after: ok | 1 passed (43 steps) | 0 failed deno check src/agent/runtime/provider-replay.ts: Check ... (clean) deno fmt --check / deno lint on both touched files: Checked 2 files (clean) --- src/agent/runtime/provider-replay.test.ts | 120 ++++++++++++++++++++++ src/agent/runtime/provider-replay.ts | 25 +++-- 2 files changed, 132 insertions(+), 13 deletions(-) diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index af8beb5812..1ca4fffce6 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -677,6 +677,126 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should accept a provider tool turn whose text was persisted as one concatenated part", () => { + const leadingText = { type: "text", text: "Let me search. " }; + const providerCall = { + type: "server_tool_use", + id: "srvtool-1", + name: "web_search", + input: { query: "q" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const trailingText = { type: "text", text: "Here is what I found." }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { type: "text", text: "Let me search. Here is what I found." }, + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const blocks = [leadingText, providerCall, providerResult, trailingText]; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: blocks.map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1, 2, 3], + totalPartCount: 4, + }; + + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { anthropic: { rawAssistantMessages: [blocks] } }, + "split provider text matches the single persisted transcript text part", + ); + }); + + it("should reject a provider tool turn whose concatenated text differs from the anchor", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-1", + name: "web_search", + input: { query: "q" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { type: "text", text: "Let me search. Here is what I found." }, + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [ + { type: "text", text: "Let me search. " }, + providerCall, + providerResult, + { type: "text", text: "Here is something else." }, + ].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1, 2, 3], + totalPartCount: 4, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + it("should be a no-op for empty or absent deliveries", () => { const target = createAssistantMessage("assistant-message-1"); applyProviderReplayCheckpointsToMessages([target], undefined); diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 84ed34c5b0..2a704ccadd 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -222,33 +222,32 @@ function toTranscriptVisibleProviderPart( } } +// A persisted assistant turn carries at most one text part, so provider text +// blocks split around tool blocks collapse into a single entry at the position +// of the first one before either side is compared. function normalizeTranscriptVisibleProjection( parts: readonly Record[], ): Record[] { const normalized: Record[] = []; - let pendingText = ""; - - const flushText = () => { - if (pendingText.trim().length === 0) { - pendingText = ""; - return; - } - normalized.push({ type: "text", text: pendingText }); - pendingText = ""; - }; + let text = ""; + let textIndex = -1; for (const part of parts) { if (part.type === "text") { if (typeof part.text !== "string") { invalidCheckpoint("checkpoint transcript text projection is malformed"); } - pendingText += part.text; + if (textIndex < 0) { + textIndex = normalized.length; + } + text += part.text; continue; } - flushText(); normalized.push(part); } - flushText(); + if (text.trim().length > 0) { + normalized.splice(textIndex, 0, { type: "text", text }); + } return normalized; } From a35bd2c5d2676817e20ac103f7b6898996d53090 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 13:53:31 +0200 Subject: [PATCH 24/56] revert(chat): stop carrying providerExecuted through message preparation Reverts 0d5aae34b and its docs regeneration b981fc5ae. Flagging every provider-executed history part made convertToTextGenerationRuntimeRequestMessages erase the whole turn: text-generation-runtime-message-converter.ts:104 returns null for a provider-executed tool call and :71 drops the paired result. Keeping provider-executed results inline in the assistant message also routed them past maskOldToolOutputs, which masks only the "tool" role branch (message-prep.ts:1353), so historical outputs were sent unmasked. Measured on one notion__search_notion turn with a 5,000-char output: origin/main user / assistant tool-call / tool result / user, 510 chars 0d5aae34b the two user messages only, 5,464 chars reverted user / assistant tool-call / tool result / user, 510 chars The checkpoint-validation gap 0d5aae34b addressed is unreachable in stage 1: checkpoint emission is disabled, so no checkpoint anchors a prepared turn. Stage 1 defines the contract and must not alter runtime message content. --- docs/api-reference/veryfront/agent.md | 16 ++--- scripts/lint/ban-chat-antipatterns.ts | 2 +- src/agent/runtime/message-adapter.ts | 2 - src/agent/runtime/provider-replay.test.ts | 79 ----------------------- src/chat/message-prep.test.ts | 6 +- src/chat/provider-message-conversion.ts | 31 ++------- 6 files changed, 18 insertions(+), 118 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index c731366e12..a61310e63d 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -607,9 +607,9 @@ Input delivered to a hosted agent-service detached execution callback. | `composeAbortSignals` | Compose abort signals helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-stream-watchdog.ts#L61) | | `computeOpenToolCalls` | Compute open tool calls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L174) | | `containsExactArtifactPathValue` | Contains exact artifact path value helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/slash-command-artifact-policy.ts#L195) | -| `convertAgentRuntimeMessagesToProviderMessages` | Convert agent runtime messages to provider messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L711) | +| `convertAgentRuntimeMessagesToProviderMessages` | Convert agent runtime messages to provider messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L709) | | `convertCompactedProviderMessagesToChildForkRuntimeMessages` | Convert compacted provider messages to child fork runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L126) | -| `convertProviderMessagesToAgentRuntimeMessages` | Convert provider messages to agent runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L699) | +| `convertProviderMessagesToAgentRuntimeMessages` | Convert provider messages to agent runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L697) | | `createAgentServiceAgUiValidationErrorResponse` | Response payload for create hosted AG-UI validation error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L151) | | `createAgentServiceAuth` | Create hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L351) | | `createAgentServiceChildMirrorContext` | Context for create hosted child mirror. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L233) | @@ -714,7 +714,7 @@ Input delivered to a hosted agent-service detached execution callback. | `createRuntimePromptBlock` | Create runtime prompt block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/prompt-block.ts#L9) | | `createStreamedStepState` | State for create streamed step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-step-state.ts#L44) | | `createToolExecutionDataEventBridgeStream` | Create tool execution data event bridge stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-execution-data-event-bridge.ts#L41) | -| `createToolResultPart` | Create a chat tool-result part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L443) | +| `createToolResultPart` | Create a chat tool-result part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L441) | | `createVeryfrontCloudAgentServiceChatExecutionRootRunOptions` | Options accepted by create Veryfront Cloud hosted chat execution root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-chat-execution-preparation.ts#L43) | | `createVeryfrontCloudHostedChatExecutionRootRunOptions` | Options accepted by create Veryfront Cloud hosted chat execution root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-chat-execution-preparation.ts#L43) | | `createVeryfrontCloudPreparedAgentServiceChatExecutionRuntimeOptions` | Options accepted by create Veryfront Cloud prepared hosted chat execution runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-prepared-chat-execution-runtime.ts#L83) | @@ -772,9 +772,9 @@ Input delivered to a hosted agent-service detached execution callback. | `formatChildRunStreamPartError` | Error shape for format child run stream part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/execution-support.ts#L30) | | `formatRuntimeSkillMetadata` | Formats bounded runtime skill metadata for prompt use. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-prompt.ts#L342) | | `getAgent` | Return agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/composition/composition.ts#L246) | -| `getAgentRuntimeTextPart` | Return a runtime text part when the value carries text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L349) | -| `getAgentRuntimeToolCallPart` | Return a runtime tool-call part when the value carries a tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L375) | -| `getAgentRuntimeToolResultPart` | Return a runtime tool-result part when the value carries a tool result. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L405) | +| `getAgentRuntimeTextPart` | Return a runtime text part when the value carries text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L347) | +| `getAgentRuntimeToolCallPart` | Return a runtime tool-call part when the value carries a tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L373) | +| `getAgentRuntimeToolResultPart` | Return a runtime tool-result part when the value carries a tool result. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L403) | | `getAgentsAsTools` | Return agents as tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/composition/composition.ts#L295) | | `getAgentServiceTokenFromRequest` | Request payload for get hosted service token from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L234) | | `getAgUiChatUiMessageChunkMetadata` | Return AG-UI chat UI message chunk metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/chat-ui-chunk-encoder.ts#L136) | @@ -1020,7 +1020,7 @@ Input delivered to a hosted agent-service detached execution callback. | Name | Description | Source | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `AgentRuntime` | Implement agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/index.ts#L1084) | -| `AgentRuntimeMessageConversionError` | Error shape for agent runtime message conversion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L139) | +| `AgentRuntimeMessageConversionError` | Error shape for agent runtime message conversion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L138) | | `AgentServiceAuthError` | Error shape for hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L14) | | `AppendConversationRunEventsError` | Error shape for append conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L4) | | `BufferMemory` | Implement buffer memory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory.ts#L128) | @@ -1074,7 +1074,7 @@ Input delivered to a hosted agent-service detached execution callback. | `AgentRunEventSink` | Receives events produced within one scoped agent run execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/runtime/model-call-context.ts#L91) | | `AgentRunModelCallContextEvent` | Provider-agnostic input persisted before one model dispatch. System-message provider options contain only validated prompt-cache metadata. Other provider-specific values are excluded because run events are durable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/runtime/model-call-context.ts#L77) | | `AgentRuntimeForkStepRunner` | Public API contract for agent runtime fork step runner. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-stream.ts#L110) | -| `AgentRuntimeMessage` | Message shape for agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L108) | +| `AgentRuntimeMessage` | Message shape for agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L107) | | `AgentRuntimeMessagePart` | Public API contract for agent runtime message part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L87) | | `AgentServiceActiveSpanAttributes` | Public API contract for hosted agent service active span attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/routes.ts#L57) | | `AgentServiceAgUiChatForwardedConfig` | Configuration used by hosted AG-UI chat forwarded. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L44) | diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index 13298754dd..66b97361a7 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -117,7 +117,7 @@ const FILE_SIZE_CEILINGS: Record = { "src/chat/message-part-parsing.ts": 264, "src/chat/part-field-access.ts": 65, "src/chat/provider-input-types.ts": 34, - "src/chat/provider-message-conversion.ts": 470, + "src/chat/provider-message-conversion.ts": 447, }; function checkFileSizes(): boolean { diff --git a/src/agent/runtime/message-adapter.ts b/src/agent/runtime/message-adapter.ts index 7b858aea0b..7017f0d866 100644 --- a/src/agent/runtime/message-adapter.ts +++ b/src/agent/runtime/message-adapter.ts @@ -92,7 +92,6 @@ export type AgentRuntimeMessagePart = toolCallId: string; toolName: string; args: Record; - providerExecuted?: boolean; } | { type: "tool-result"; @@ -213,7 +212,6 @@ function convertStructuredPart(part: StructuredProviderPart): AgentRuntimeMessag toolCallId: part.toolCallId, toolName: part.toolName, args: "input" in part ? toChildRunToolInputRecord(part.input) : {}, - ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; case "tool-result": diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 1ca4fffce6..8f939b4cb6 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -3,7 +3,6 @@ import { assertEquals, assertInstanceOf, assertThrows } from "#veryfront/testing import { describe, it } from "#veryfront/testing/bdd.ts"; import { VeryfrontError } from "#veryfront/errors"; import type { Message } from "../types.ts"; -import { prepareHostedChatRuntimeMessages } from "../hosted/chat-preparation.ts"; import { applyProviderReplayCheckpointsToMessages, parseProviderReplayCheckpoint, @@ -1028,84 +1027,6 @@ describe("agent/runtime/provider-replay", () => { ); }); - it("should accept a prepared provider-executed turn end to end", async () => { - const messages = await prepareHostedChatRuntimeMessages( - [ - { - id: "user-1", - role: "user", - parts: [{ type: "text", text: "Search the official documentation." }], - }, - { - id: "assistant-1", - role: "assistant", - parts: [ - { type: "text", text: "Looking it up." }, - { - type: "dynamic-tool", - toolName: "web_search", - toolCallId: "srvtool-web-search", - input: { query: "provider replay" }, - state: "output-available", - providerExecuted: true, - output: [], - }, - ], - }, - { - id: "user-2", - role: "user", - parts: [{ type: "text", text: "Summarize the result." }], - }, - ], - { - providerOwnedToolNames: ["web_search"], - providerReplayCheckpointMessageIds: ["assistant-1"], - }, - ); - const checkpoint: ProviderReplayCheckpoint = { - version: 1, - messageId: "assistant-1", - provider: "anthropic", - providerBlocks: [ - { type: "text", text: "Looking it up." }, - { - type: "server_tool_use", - id: "srvtool-web-search", - name: "web_search", - input: { query: "provider replay" }, - caller: { type: "direct" }, - }, - { - type: "web_search_tool_result", - tool_use_id: "srvtool-web-search", - caller: { type: "direct" }, - content: [], - }, - ].map((block) => ({ - type: "provider-block" as const, - provider: "anthropic" as const, - block, - })), - providerBlockPositions: [0, 1, 2], - totalPartCount: 3, - }; - - applyProviderReplayCheckpointsToMessages(messages, [checkpoint]); - - assertEquals( - readAttachedProviderMetadata( - messages.find((message) => message.id === "assistant-1") as Message, - ), - { - anthropic: { - rawAssistantMessages: [checkpoint.providerBlocks.map((block) => block.block)], - }, - }, - "the prepared provider-executed turn anchors its checkpoint", - ); - }); - it("should reconstruct the provider request assistant turn through the runtime converter", () => { const target = createCheckpointedAssistantMessage("assistant-message-1"); const checkpoint = createValidCheckpoint(); diff --git a/src/chat/message-prep.test.ts b/src/chat/message-prep.test.ts index e3419198f8..0373e87d26 100644 --- a/src/chat/message-prep.test.ts +++ b/src/chat/message-prep.test.ts @@ -1940,8 +1940,12 @@ Deno.test("prepareProviderModelMessagesFromUiMessages prefers completed tool out toolCallId: "toolu_01Search", toolName: "notion__search_notion", input: { query: "research notes" }, - providerExecuted: true, }, + ], + }, + { + role: "tool", + content: [ { type: "tool-result", toolCallId: "toolu_01Search", diff --git a/src/chat/provider-message-conversion.ts b/src/chat/provider-message-conversion.ts index 45a95abe7f..ed496c8938 100644 --- a/src/chat/provider-message-conversion.ts +++ b/src/chat/provider-message-conversion.ts @@ -159,14 +159,7 @@ function convertAssistantMessage( | { type: "text"; text: string } | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } | { type: "file" | "image"; mediaType: string; data: string; filename?: string } - | { - type: "tool-call"; - toolCallId: string; - toolName: string; - input: Record; - providerExecuted?: boolean; - } - | ProviderToolResultContent + | { type: "tool-call"; toolCallId: string; toolName: string; input: Record } > = []; const deferredAssistantContent: typeof assistantContent = []; const toolResults: ProviderToolResultContent[] = []; @@ -202,13 +195,7 @@ function convertAssistantMessage( | { type: "text"; text: string } | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } | { type: "file" | "image"; mediaType: string; data: string; filename?: string } - | { - type: "tool-call"; - toolCallId: string; - toolName: string; - input: Record; - providerExecuted?: boolean; - }, + | { type: "tool-call"; toolCallId: string; toolName: string; input: Record }, ) => { if (part.type === "tool-call") { if (deferredAssistantContent.length > 0) { @@ -265,30 +252,20 @@ function convertAssistantMessage( flushAssistantMessage(deferredAssistantContent); } - const providerExecuted = isRecord(part) && part.providerExecuted === true; pushAssistantPart({ type: "tool-call", toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, input: toolCall.input, - ...(providerExecuted ? { providerExecuted: true } : {}), }); if (resultOutput) { - const toolResult: ProviderToolResultContent = { + pushToolResult({ type: "tool-result", toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, output: resultOutput, - }; - // A provider-executed result is a block of the same assistant turn on the - // wire, so it stays inline instead of moving into a tool message. - if (providerExecuted) { - assistantContent.push(toolResult); - pendingToolCallIds.delete(toolCall.toolCallId); - } else { - pushToolResult(toolResult); - } + }); } }; From 3307af20d068e0b4236faa982a92a9d569eb4cc8 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 14:16:39 +0200 Subject: [PATCH 25/56] fix(typecheck): use the augmented field's value in the consumer fixture The discarded toUpperCase() call tripped SonarCloud's reliability gate. The type annotation on an exported const proves the same thing the call did: the augmentation must widen the interface with a string. --- scripts/typecheck/fixtures/chat-message-prep-augmentation.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/typecheck/fixtures/chat-message-prep-augmentation.ts b/scripts/typecheck/fixtures/chat-message-prep-augmentation.ts index aeb6139e77..6651c4d86a 100644 --- a/scripts/typecheck/fixtures/chat-message-prep-augmentation.ts +++ b/scripts/typecheck/fixtures/chat-message-prep-augmentation.ts @@ -15,6 +15,5 @@ const options: PrepareProviderModelMessagesFromUiMessagesOptions = { downstreamTraceId: "consumer-owned-field", }; -if (options.downstreamTraceId !== undefined) { - options.downstreamTraceId.toUpperCase(); -} +// The annotation is the proof: augmentation must widen the interface with a string. +export const upperTraceId: string | undefined = options.downstreamTraceId?.toUpperCase(); From 66c97aa3096465573c2b52b7034e84c6219ef424 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 14:13:52 +0200 Subject: [PATCH 26/56] Keep replay checkpoints authoritative through preparation Provider replay checkpoints must fail closed instead of being silently dropped or reclassified while hosted chat history is prepared. This keeps provider-owned tool state, preserved anchors, active-provider checks, and provider-result validation at the conversion boundaries that already own those shapes. Constraint: Stage 1 still must not emit production checkpoints.\nRejected: Reopen production checkpoint emission | later staged rollout owns append/read/emission.\nConfidence: high\nScope-risk: moderate\nDirective: Do not drop providerExecuted or empty checkpoint anchors during history conversion without replay validation tests.\nTested: deno task test:file src/agent/runtime/message-adapter.test.ts src/agent/runtime/provider-replay.test.ts src/agent/hosted/chat-preparation.test.ts src/chat/message-prep.test.ts\nTested: deno task lint:chat-ratchets\nTested: deno check touched runtime/chat files and tests\nTested: deno task typecheck:consumer --skip-build\nTested: deno fmt --check touched files && git diff --check\nTested: deno task lint --- src/agent/hosted/chat-preparation.test.ts | 127 ++++++++++- src/agent/hosted/chat-preparation.ts | 18 +- src/agent/runtime/index.ts | 17 +- src/agent/runtime/message-adapter.test.ts | 91 +++++++- src/agent/runtime/message-adapter.ts | 31 ++- src/agent/runtime/provider-replay.test.ts | 112 ++++++++++ src/agent/runtime/provider-replay.ts | 49 ++++- src/chat/message-part-parsing.ts | 31 ++- src/chat/message-prep.test.ts | 2 + src/chat/message-prep.ts | 197 +++--------------- .../provider-message-anchor-preservation.ts | 74 +++++++ src/chat/provider-message-conversion.ts | 44 ++-- src/chat/provider-message-tool-pair-repair.ts | 162 ++++++++++++++ src/chat/types.ts | 1 + 14 files changed, 726 insertions(+), 230 deletions(-) create mode 100644 src/chat/provider-message-anchor-preservation.ts create mode 100644 src/chat/provider-message-tool-pair-repair.ts diff --git a/src/agent/hosted/chat-preparation.test.ts b/src/agent/hosted/chat-preparation.test.ts index 317b481b49..81525fb215 100644 --- a/src/agent/hosted/chat-preparation.test.ts +++ b/src/agent/hosted/chat-preparation.test.ts @@ -1784,15 +1784,67 @@ Deno.test( .filter((message) => message.id === "assistant-1") .flatMap((message) => message.parts); assertEquals( - checkpointedParts.flatMap((part) => - "toolCallId" in part && part.toolCallId === "srvtool-web-search" ? [part.type] : [] - ), - ["tool-call", "tool-result"], + checkpointedParts, + [ + { + type: "tool-call", + toolCallId: "srvtool-web-search", + toolName: "web_search", + args: { query: "site:veryfront.com provider replay" }, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: "srvtool-web-search", + toolName: "web_search", + result: { + type: "json", + value: [], + }, + providerExecuted: true, + }, + ], "checkpointed provider call and result remain available for replay validation", ); }, ); +Deno.test("prepareHostedChatRuntimeMessages preserves opaque-only checkpoint anchors", async () => { + const messages = await prepareHostedChatRuntimeMessages( + [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Think privately." }], + }, + { + id: "assistant-empty", + role: "assistant", + parts: [], + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Continue." }], + }, + ], + { + providerReplayCheckpointMessageIds: ["assistant-empty"], + }, + ); + + assertEquals( + messages.find((message) => message.id === "assistant-empty"), + { + id: "assistant-empty", + role: "assistant", + parts: [], + timestamp: 1, + }, + "opaque-only replay anchors must survive even when they have no public parts", + ); +}); + Deno.test("prepareHostedChatRuntimeMessages reports historical tool input compaction diagnostics", async () => { const diagnostics: HistoricalToolInputCompactionDiagnostic[] = []; const marker = "HOSTED_TOOL_INPUT_MARKER"; @@ -1898,6 +1950,73 @@ Deno.test("prepareHostedChatRuntimeMessages preserves checkpointed historical to assertEquals(diagnostics, []); }); +Deno.test("prepareHostedChatRuntimeMessages merges checkpoint and caller-preserved source ids", async () => { + const diagnostics: HistoricalToolInputCompactionDiagnostic[] = []; + const checkpointMarker = "CHECKPOINT_RETENTION_MARKER"; + const callerMarker = "CALLER_RETENTION_MARKER"; + const messages = await prepareHostedChatRuntimeMessages( + [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Render both widgets." }], + }, + { + id: "assistant-checkpoint", + role: "assistant", + parts: [{ + type: "dynamic-tool", + toolName: "render_widget", + toolCallId: "tool-render-checkpoint", + input: { + source: `${checkpointMarker}:${"checkpoint body ".repeat(500)}`, + }, + state: "output-available", + output: { ok: true }, + }], + }, + { + id: "assistant-caller", + role: "assistant", + parts: [{ + type: "dynamic-tool", + toolName: "render_widget", + toolCallId: "tool-render-caller", + input: { + source: `${callerMarker}:${"caller body ".repeat(500)}`, + }, + state: "output-available", + output: { ok: true }, + }], + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Update both widgets." }], + }, + ], + { + providerReplayCheckpointMessageIds: ["assistant-checkpoint"], + historicalToolInputRetention: { + diagnostics, + preserveSourceMessageIds: ["assistant-caller"], + resolvePolicy: (toolName) => + toolName === "render_widget" + ? { + compactCompletedInput: true, + compactAfterChars: 100, + } + : undefined, + }, + }, + ); + + const serialized = JSON.stringify(messages); + assertEquals(serialized.includes(checkpointMarker), true); + assertEquals(serialized.includes(callerMarker), true); + assertEquals(diagnostics, []); +}); + Deno.test("prepareHostedChatRuntimeCreationOptions applies the skill selector and owner scope", async () => { const skills = [ { diff --git a/src/agent/hosted/chat-preparation.ts b/src/agent/hosted/chat-preparation.ts index 1f5f449d7e..fec3f20914 100644 --- a/src/agent/hosted/chat-preparation.ts +++ b/src/agent/hosted/chat-preparation.ts @@ -93,6 +93,16 @@ export type HostedChatRuntimePreparationSteering = { skills: RuntimeSkillDefinition[]; }; +function mergePreservedSourceMessageIds( + checkpointIds: readonly string[] | undefined, + retentionIds: readonly string[] | undefined, +): readonly string[] | undefined { + if (checkpointIds === undefined && retentionIds === undefined) { + return undefined; + } + return [...new Set([...(checkpointIds ?? []), ...(retentionIds ?? [])])]; +} + /** Input payload for hosted chat runtime instructions. */ export type HostedChatRuntimeInstructionsInput = { agentConfig: TRuntimeAgentDefinition; @@ -656,8 +666,10 @@ export async function prepareHostedChatRuntimeMessages( fileContentFetchTimeoutMs: options.fileContentFetchTimeoutMs, historicalToolInputRetention: { ...options.historicalToolInputRetention, - preserveSourceMessageIds: options.providerReplayCheckpointMessageIds ?? + preserveSourceMessageIds: mergePreservedSourceMessageIds( + options.providerReplayCheckpointMessageIds, options.historicalToolInputRetention?.preserveSourceMessageIds, + ), }, }); } @@ -673,8 +685,10 @@ export async function prepareHostedChatRuntimeMessages( fileContentFetchTimeoutMs: options.fileContentFetchTimeoutMs, historicalToolInputRetention: { ...options.historicalToolInputRetention, - preserveSourceMessageIds: options.providerReplayCheckpointMessageIds ?? + preserveSourceMessageIds: mergePreservedSourceMessageIds( + options.providerReplayCheckpointMessageIds, options.historicalToolInputRetention?.preserveSourceMessageIds, + ), }, resolveFileUrl: ({ uploadId }) => getRuntimeUploadUrl({ diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 1e32a806b2..17e68de57a 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -64,6 +64,7 @@ import { MiddlewareChain } from "../middleware/chain.ts"; import { tryGetCacheKeyContext } from "#veryfront/cache/cache-key-builder.ts"; import type { ToolExecutionContext } from "#veryfront/tool"; import { + getModelRuntimeProvider, isLocalModelRuntime, supportsModelRuntimeToolCalling, } from "#veryfront/provider/runtime-inspection.ts"; @@ -110,7 +111,10 @@ import { resolveRuntimeToolLoading, type RuntimeToolFilterConfig, } from "./runtime-tool-config.ts"; -import { applyProviderReplayCheckpointsToMessages } from "./provider-replay.ts"; +import { + applyProviderReplayCheckpointsToMessages, + type ProviderReplayProvider, +} from "./provider-replay.ts"; import { applySourceIntegrationPolicy, type SourceIntegrationPolicyManifest, @@ -198,6 +202,15 @@ export { const NativeError = Error; +function getActiveProviderReplayProvider( + languageModel: ModelRuntime, +): ProviderReplayProvider | undefined { + const provider = getModelRuntimeProvider(languageModel); + if (provider === "anthropic") return "anthropic"; + if (provider === "openai") return "openai-responses"; + return undefined; +} + function resolveRuntimeGenAiProviderName(modelId: string): string | undefined { const normalizedModelId = modelId.startsWith("veryfront-cloud/") ? modelId.slice("veryfront-cloud/".length) @@ -1501,6 +1514,7 @@ export class AgentRuntime { applyProviderReplayCheckpointsToMessages( currentMessages, getRuntimeProviderReplayCheckpoints(this.config), + { activeProvider: getActiveProviderReplayProvider(languageModel) }, ); const totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; @@ -2168,6 +2182,7 @@ export class AgentRuntime { applyProviderReplayCheckpointsToMessages( currentMessages, getRuntimeProviderReplayCheckpoints(this.config), + { activeProvider: getActiveProviderReplayProvider(languageModel) }, ); const totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; diff --git a/src/agent/runtime/message-adapter.test.ts b/src/agent/runtime/message-adapter.test.ts index d71835557d..bebc33721d 100644 --- a/src/agent/runtime/message-adapter.test.ts +++ b/src/agent/runtime/message-adapter.test.ts @@ -26,13 +26,18 @@ function providerTextPart(text: string): ProviderStructuredPart { return { type: "text", text }; } -function providerToolCallPart(input: Record): ProviderStructuredPart { - return { +function providerToolCallPart( + input: Record, + providerExecuted?: boolean, +): ProviderStructuredPart { + const part: ProviderStructuredPart = { type: "tool-call", toolCallId: TOOL_CALL_ID, toolName: TOOL_NAME, input, + ...(providerExecuted === true ? { providerExecuted: true } : {}), }; + return part; } function jsonOutput(value: ChatToolResultOutput["value"]): ChatToolResultOutput { @@ -42,12 +47,16 @@ function jsonOutput(value: ChatToolResultOutput["value"]): ChatToolResultOutput }; } -function providerToolResultPart(output: ChatToolResultOutput): ChatToolResultPart { +function providerToolResultPart( + output: ChatToolResultOutput, + providerExecuted?: boolean, +): ChatToolResultPart { return { type: "tool-result", toolCallId: TOOL_CALL_ID, toolName: TOOL_NAME, output, + ...(providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -77,21 +86,27 @@ function isRuntimeTextPart( function agentRuntimeToolCallPart( args: Record, type = "tool-call", + providerExecuted?: boolean, ): AgentRuntimeMessagePart { return { type, toolCallId: TOOL_CALL_ID, toolName: TOOL_NAME, args, + ...(providerExecuted === true ? { providerExecuted: true } : {}), }; } -function agentRuntimeToolResultPart(result: unknown): AgentRuntimeMessagePart { +function agentRuntimeToolResultPart( + result: unknown, + providerExecuted?: boolean, +): AgentRuntimeMessagePart { return { type: "tool-result", toolCallId: TOOL_CALL_ID, toolName: TOOL_NAME, result, + ...(providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -120,6 +135,32 @@ describe("agent runtime message adapter", () => { ]); }); + it("preserves provider ownership when converting provider messages into agent runtime messages", () => { + const agentRuntimeMessages = convertProviderMessagesToAgentRuntimeMessages([ + providerMessage({ + role: "assistant", + content: [providerToolCallPart({ query: "cloud side" }, true)], + }), + providerMessage({ + role: "tool", + content: [providerToolResultPart(jsonOutput({ matches: 2 }), true)], + }), + ]); + + assertEquals(agentRuntimeMessages, [ + agentRuntimeMessage( + "assistant", + [agentRuntimeToolCallPart({ query: "cloud side" }, "tool-call", true)], + 0, + ), + agentRuntimeMessage( + "tool", + [agentRuntimeToolResultPart(jsonOutput({ matches: 2 }), true)], + 1, + ), + ]); + }); + it("preserves reasoning parts when converting provider messages into agent runtime messages", () => { const agentRuntimeMessages = convertProviderMessagesToAgentRuntimeMessages([ providerMessage({ @@ -376,6 +417,48 @@ describe("agent runtime message adapter", () => { ]); }); + it("preserves provider ownership when replaying stored runtime tool parts", () => { + const providerMessages = convertAgentRuntimeMessagesToProviderMessages([ + { + role: "assistant", + parts: [ + { + type: "tool_call", + id: TOOL_CALL_ID, + name: TOOL_NAME, + input: { query: "cloud side" }, + providerExecuted: true, + }, + ], + }, + { + role: "tool", + parts: [ + { + type: "tool_result", + tool_call_id: TOOL_CALL_ID, + tool_name: TOOL_NAME, + output: { matches: 2 }, + providerExecuted: true, + }, + ], + }, + ]); + + assertEquals(providerMessages, [ + { + role: "assistant", + content: [providerToolCallPart({ query: "cloud side" }, true)], + }, + { + role: "tool", + content: [ + providerToolResultPart(jsonOutput({ matches: 2 }), true), + ], + }, + ]); + }); + it("replays same-message stored tool results after their matching assistant tool call", () => { const providerMessages = convertAgentRuntimeMessagesToProviderMessages([ { diff --git a/src/agent/runtime/message-adapter.ts b/src/agent/runtime/message-adapter.ts index 7017f0d866..962a522d3a 100644 --- a/src/agent/runtime/message-adapter.ts +++ b/src/agent/runtime/message-adapter.ts @@ -40,12 +40,14 @@ type AgentRuntimeMessageLikePart = toolCallId: string; toolName: string; args: Record; + providerExecuted?: boolean; } | { type: string; toolCallId: string; toolName: string; input: Record; + providerExecuted?: boolean; } | { type: "tool_call"; @@ -57,18 +59,21 @@ type AgentRuntimeMessageLikePart = toolName?: string; input?: Record; args?: Record; + providerExecuted?: boolean; } | { type: "tool-result"; toolCallId: string; toolName: string; result?: unknown; + providerExecuted?: boolean; } | { type: "tool-result"; toolCallId: string; toolName: string; output: unknown; + providerExecuted?: boolean; } | { type: "tool_result"; @@ -78,6 +83,7 @@ type AgentRuntimeMessageLikePart = toolName?: string; result?: unknown; output?: unknown; + providerExecuted?: boolean; } | RuntimeAttachmentLikePart | ChatSourceUrlUiPart @@ -92,12 +98,14 @@ export type AgentRuntimeMessagePart = toolCallId: string; toolName: string; args: Record; + providerExecuted?: boolean; } | { type: "tool-result"; toolCallId: string; toolName: string; result: unknown; + providerExecuted?: boolean; } | RuntimeAttachmentPart | ChatSourceUrlUiPart @@ -132,6 +140,7 @@ type ProviderToolCallPart = { toolCallId: string; toolName: string; input: Record; + providerExecuted?: boolean; }; /** Error shape for agent runtime message conversion. */ @@ -212,6 +221,7 @@ function convertStructuredPart(part: StructuredProviderPart): AgentRuntimeMessag toolCallId: part.toolCallId, toolName: part.toolName, args: "input" in part ? toChildRunToolInputRecord(part.input) : {}, + ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; case "tool-result": @@ -220,6 +230,7 @@ function convertStructuredPart(part: StructuredProviderPart): AgentRuntimeMessag toolCallId: part.toolCallId, toolName: part.toolName, result: "output" in part ? part.output : null, + ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; case "image": @@ -372,7 +383,12 @@ export function getAgentRuntimeReasoningPart(part: unknown): ProviderReasoningPa /** Return a runtime tool-call part when the value carries a tool call. */ export function getAgentRuntimeToolCallPart( part: unknown, -): { toolCallId: string; toolName: string; input: Record } | null { +): { + toolCallId: string; + toolName: string; + input: Record; + providerExecuted?: boolean; +} | null { if (!isRecord(part) || typeof part.type !== "string") { return null; } @@ -396,6 +412,7 @@ export function getAgentRuntimeToolCallPart( toolCallId, toolName, input: toChildRunToolInputRecord(part.args ?? part.input), + ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -403,7 +420,12 @@ export function getAgentRuntimeToolCallPart( export function getAgentRuntimeToolResultPart( part: unknown, toolNameFallback?: string, -): { toolCallId: string; toolName: string; output: unknown } | null { +): { + toolCallId: string; + toolName: string; + output: unknown; + providerExecuted?: boolean; +} | null { if (!isRecord(part) || part.type !== "tool-result" && part.type !== "tool_result") { return null; } @@ -425,6 +447,7 @@ export function getAgentRuntimeToolResultPart( : Object.hasOwn(part, "output") ? part.output : null, + ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -442,12 +465,14 @@ export function createToolResultPart(part: { toolCallId: string; toolName: string; output: unknown; + providerExecuted?: boolean; }): ChatToolResultPart { return { type: "tool-result", toolCallId: part.toolCallId, toolName: part.toolName, output: toToolResultOutput(part.output), + ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -508,6 +533,7 @@ function collectAgentRuntimeProviderContentParts( toolCallId: toolCallPart.toolCallId, toolName: toolCallPart.toolName, input: toolCallPart.input, + ...(toolCallPart.providerExecuted === true ? { providerExecuted: true } : {}), }); } } @@ -617,6 +643,7 @@ function convertAssistantAgentRuntimePartsToProviderMessages( toolCallId: toolCallPart.toolCallId, toolName: toolCallPart.toolName, input: toolCallPart.input, + ...(toolCallPart.providerExecuted === true ? { providerExecuted: true } : {}), }); } } diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 8f939b4cb6..0334ec8412 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -639,6 +639,59 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should reject malformed provider tool-result blocks before attachment", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-web-search", + name: "web_search", + input: { query: "provider replay" }, + caller: { type: "direct" }, + }; + const malformedProviderResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: "not an array", + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, malformedProviderResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + it("should reject a provider tool result without its provider-owned call", () => { const providerResult = { type: "web_search_tool_result", @@ -737,6 +790,53 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should match replay blocks against persisted assistant transcript order", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-1", + name: "web_search", + input: { query: "q" }, + caller: { type: "direct" }, + }; + const trailingText = { type: "text", text: "Here is what I found." }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { type: "text", text: "Here is what I found." }, + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const blocks = [providerCall, trailingText]; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: blocks.map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { anthropic: { rawAssistantMessages: [blocks] } }, + "raw replay order is retained after matching against normalized transcript order", + ); + }); + it("should reject a provider tool turn whose concatenated text differs from the anchor", () => { const providerCall = { type: "server_tool_use", @@ -962,6 +1062,18 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should reject checkpoints for a provider that is not active for the current model", () => { + const target = createCheckpointedAssistantMessage("assistant-message-1"); + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages( + [target], + [createValidCheckpoint()], + { activeProvider: "openai-responses" }, + ) + ); + }); + it("should keep fresher in-process replay metadata over a delivered checkpoint", () => { const target = createCheckpointedAssistantMessage("assistant-message-1"); const inProcess = { anthropic: { rawAssistantMessages: [[{ type: "text", text: "live" }]] } }; diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 2a704ccadd..af7c763898 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -60,6 +60,10 @@ export type ProviderReplayCheckpoint = { emittedAt?: number; }; +type ApplyProviderReplayCheckpointsOptions = { + activeProvider?: ProviderReplayProvider; +}; + /** * Fails checkpoint validation without echoing payload contents. Blocks carry * signed reasoning material, so details name fields and indices only. @@ -127,6 +131,21 @@ function validateAnthropicThinkingReplayBlock( } } +function validateAnthropicProviderToolResultBlock( + block: Record, + context?: Record, +): void { + if (!isNonEmptyString(block.tool_use_id)) { + invalidCheckpoint("checkpoint provider tool-result block is malformed", context); + } + if (!Array.isArray(block.content)) { + invalidCheckpoint("checkpoint provider tool-result block is malformed", context); + } + if (block.type === "mcp_tool_result" && typeof block.is_error !== "boolean") { + invalidCheckpoint("checkpoint provider tool-result block is malformed", context); + } +} + function toTranscriptVisibleAnthropicReplayPart( block: Record, ): Record | undefined { @@ -158,9 +177,7 @@ function toTranscriptVisibleAnthropicReplayPart( typeof block.type === "string" && ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES.has(block.type) ) { - if (!isNonEmptyString(block.tool_use_id)) { - invalidCheckpoint("checkpoint provider tool-result block is malformed"); - } + validateAnthropicProviderToolResultBlock(block); return { type: "tool-result", toolCallId: block.tool_use_id, @@ -222,31 +239,27 @@ function toTranscriptVisibleProviderPart( } } -// A persisted assistant turn carries at most one text part, so provider text -// blocks split around tool blocks collapse into a single entry at the position -// of the first one before either side is compared. +// A persisted assistant turn carries at most one leading text part, so provider +// text blocks split around tool blocks collapse into that leading transcript +// entry before either side is compared. Raw replay order stays untouched. function normalizeTranscriptVisibleProjection( parts: readonly Record[], ): Record[] { const normalized: Record[] = []; let text = ""; - let textIndex = -1; for (const part of parts) { if (part.type === "text") { if (typeof part.text !== "string") { invalidCheckpoint("checkpoint transcript text projection is malformed"); } - if (textIndex < 0) { - textIndex = normalized.length; - } text += part.text; continue; } normalized.push(part); } if (text.trim().length > 0) { - normalized.splice(textIndex, 0, { type: "text", text }); + normalized.unshift({ type: "text", text }); } return normalized; @@ -329,6 +342,13 @@ function parseProviderReplayBlock( if (provider === "anthropic" && value.block.type === "thinking") { validateAnthropicThinkingReplayBlock(value.block, { index }); } + if ( + provider === "anthropic" && + typeof value.block.type === "string" && + ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES.has(value.block.type) + ) { + validateAnthropicProviderToolResultBlock(value.block, { index }); + } return { type: "provider-block", provider, block: value.block }; } @@ -506,6 +526,7 @@ export function assertReconstructibleProviderReplayCheckpoint( export function applyProviderReplayCheckpointsToMessages( messages: readonly Message[], checkpoints: readonly ProviderReplayCheckpoint[] | undefined, + options: ApplyProviderReplayCheckpointsOptions = {}, ): void { if (checkpoints === undefined || checkpoints.length === 0) return; // Runtime support is a property of the delivery, not of which turns are @@ -513,6 +534,12 @@ export function applyProviderReplayCheckpointsToMessages( // turn is absent, so deployment skew surfaces immediately. for (const checkpoint of checkpoints) { assertReconstructibleProviderReplayCheckpoint(checkpoint); + if (options.activeProvider !== undefined && checkpoint.provider !== options.activeProvider) { + invalidCheckpoint("checkpoint provider does not match the active model provider", { + checkpointProvider: checkpoint.provider, + activeProvider: options.activeProvider, + }); + } } for (const checkpoint of checkpoints) { const matches = messages.filter((message) => message.id === checkpoint.messageId); diff --git a/src/chat/message-part-parsing.ts b/src/chat/message-part-parsing.ts index 86b6e6bd58..8e1d656119 100644 --- a/src/chat/message-part-parsing.ts +++ b/src/chat/message-part-parsing.ts @@ -16,6 +16,10 @@ import { } from "./part-field-access.ts"; import type { JsonValue } from "./part-field-access.ts"; +type ParsedToolResultOutput = + | { type: "json"; value: JsonValue } + | { type: "error-text"; value: string }; + export function getToolPart(part: unknown): { toolCallId: string; toolName: string; @@ -23,6 +27,7 @@ export function getToolPart(part: unknown): { state: string; output?: unknown; errorText?: string; + providerExecuted?: boolean; } | null { if (!isRecord(part) || typeof part.type !== "string") { return null; @@ -52,6 +57,7 @@ export function getToolPart(part: unknown): { state, ...(output !== undefined ? { output } : {}), ...(errorText !== undefined ? { errorText } : {}), + ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -62,6 +68,7 @@ export function getRawToolCallPart(part: unknown): { state?: string; output?: unknown; errorText?: string; + providerExecuted?: boolean; } | null { if (!isRecord(part) || part.type !== "tool_call") { return null; @@ -85,21 +92,15 @@ export function getRawToolCallPart(part: unknown): { ...(typeof part.state === "string" ? { state: part.state } : {}), ...(Object.hasOwn(part, "output") ? { output: part.output } : {}), ...(typeof part.errorText === "string" ? { errorText: part.errorText } : {}), + ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } export function getRawToolResultPart(part: unknown): { toolCallId: string; toolName?: string; - output: - | { - type: "json"; - value: JsonValue; - } - | { - type: "error-text"; - value: string; - }; + output: ParsedToolResultOutput; + providerExecuted?: boolean; } | null { if (!isRecord(part) || part.type !== "tool_result") { return null; @@ -130,21 +131,13 @@ export function getRawToolResultPart(part: unknown): { toolCallId, ...(toolName ? { toolName } : {}), output, + ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } export function buildToolResultOutput( toolPart: { state: string; output?: unknown; errorText?: string }, -): - | { - type: "json"; - value: JsonValue; - } - | { - type: "error-text"; - value: string; - } - | null { +): ParsedToolResultOutput | null { if (toolPart.state === "output-available") { return { type: "json", diff --git a/src/chat/message-prep.test.ts b/src/chat/message-prep.test.ts index 0373e87d26..ae39d6d44d 100644 --- a/src/chat/message-prep.test.ts +++ b/src/chat/message-prep.test.ts @@ -1940,6 +1940,7 @@ Deno.test("prepareProviderModelMessagesFromUiMessages prefers completed tool out toolCallId: "toolu_01Search", toolName: "notion__search_notion", input: { query: "research notes" }, + providerExecuted: true, }, ], }, @@ -1954,6 +1955,7 @@ Deno.test("prepareProviderModelMessagesFromUiMessages prefers completed tool out type: "json", value: { data: [] }, }, + providerExecuted: true, }, ], }, diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index 896f460274..dd61ab1429 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -7,10 +7,15 @@ import { isToolResultPart, } from "./conversation.ts"; import { convertUiMessagesToProviderModelMessages } from "./provider-message-conversion.ts"; +import { + preserveEmptyAssistantAnchors, + type ProviderMessageSanitizationOptions, + shouldPreserveEmptyAssistantMessage, +} from "./provider-message-anchor-preservation.ts"; +import { repairToolPairs } from "./provider-message-tool-pair-repair.ts"; import { findProviderVisibleToolReplayMatches } from "./tool-replay-reconciliation.ts"; import { buildDataFileAnnotation, - type ChatAssistantContentPart, type ChatToolResultOutput, type ChatToolResultPart, type ChatUiMessage, @@ -39,6 +44,7 @@ import type { PrepareProviderModelMessagesFromUiMessagesOptions, } from "./message-prep-types.ts"; export { DEFAULT_MESSAGE_PREP_LIMITS } from "./message-prep-types.ts"; +export { repairToolPairs } from "./provider-message-tool-pair-repair.ts"; export type { HistoricalToolInputCompactionDiagnostic, HistoricalToolInputRetainedField, @@ -943,7 +949,8 @@ function isKeepableModelPart( return (role === "assistant" || role === "tool") && hasNonEmptyStringField(part, "toolCallId") && hasNonEmptyStringField(part, "toolName") && - hasValidToolResultOutput(part.output); + hasValidToolResultOutput(part.output) && + (part.providerExecuted === undefined || typeof part.providerExecuted === "boolean"); case "image": case "file": { if ( @@ -985,6 +992,7 @@ function cleanContent(content: T[], role: ProviderModelMessage["role"]): T[] /** Sanitize provider model messages. */ export function sanitizeProviderModelMessages( messages: ProviderModelMessage[], + options: ProviderMessageSanitizationOptions = {}, ): ProviderModelMessage[] { const result: ProviderModelMessage[] = []; @@ -999,6 +1007,9 @@ export function sanitizeProviderModelMessages( const cleaned = cleanContent(message.content, message.role); if (cleaned.length > 0) { result.push(copyProviderModelMessageSourceId(message, { ...message, content: cleaned })); + } else if (shouldPreserveEmptyAssistantMessage(message, options)) { + const emptyAssistantMessage: ProviderModelMessage = { role: "assistant", content: [] }; + result.push(copyProviderModelMessageSourceId(message, emptyAssistantMessage)); } } else if (message.role === "tool") { const cleaned = cleanContent(message.content, message.role); @@ -1011,14 +1022,22 @@ export function sanitizeProviderModelMessages( if (hasValidContent(message)) { result.push(message); + } else if (shouldPreserveEmptyAssistantMessage(message, options)) { + const emptyAssistantMessage: ProviderModelMessage = { role: "assistant", content: [] }; + result.push(copyProviderModelMessageSourceId(message, emptyAssistantMessage)); } } return result; } -function filterValidMessages(messages: ProviderModelMessage[]): ProviderModelMessage[] { - return messages.filter(hasValidContent); +function filterValidMessages( + messages: ProviderModelMessage[], + options: ProviderMessageSanitizationOptions = {}, +): ProviderModelMessage[] { + return messages.filter((message) => + hasValidContent(message) || shouldPreserveEmptyAssistantMessage(message, options) + ); } /** Prepare provider model messages from UI messages. */ @@ -1040,13 +1059,20 @@ export function prepareProviderModelMessagesFromUiMessages( const rewrittenMessages = rewriteUnsupportedFilePartsAsAnnotations( strippedSupersededToolMessages, ); - const providerModelMessages = convertUiMessagesToProviderModelMessages(rewrittenMessages); + const providerModelMessages = preserveEmptyAssistantAnchors( + convertUiMessagesToProviderModelMessages(rewrittenMessages), + rewrittenMessages, + options.preserveProviderOwnedToolSourceMessageIds, + ); const patchedMessages = ensureToolCallInputs(dedupeToolHistory(providerModelMessages)); - const sanitized = sanitizeProviderModelMessages(patchedMessages); + const preserveOptions = { + preserveEmptyAssistantSourceMessageIds: options.preserveProviderOwnedToolSourceMessageIds, + }; + const sanitized = sanitizeProviderModelMessages(patchedMessages, preserveOptions); const masked = maskOldToolOutputs(sanitized, options.historicalToolInputRetention); const compactedInputs = compactOldToolInputs(masked, options.historicalToolInputRetention); const compacted = enforceTokenBudget(compactedInputs); - const filtered = filterValidMessages(compacted); + const filtered = filterValidMessages(compacted, preserveOptions); return repairToolPairs(filtered); } @@ -1614,163 +1640,6 @@ export function compactHistoricalUiMessageToolInputs( return mutated ? result : messages; } -function createSyntheticToolResult(toolCallId: string, toolName: string): ChatToolResultPart { - return { - type: "tool-result", - toolCallId, - toolName, - output: { type: "text", value: "[tool result unavailable]" }, - }; -} - -/** Repair tool pairs. */ -export function repairToolPairs(messages: ProviderModelMessage[]): ProviderModelMessage[] { - const result = [...messages]; - let mutated = false; - - for (let index = 0; index < result.length; index++) { - const message = result[index]; - if (!message) continue; - - if (message.role !== "assistant" || !Array.isArray(message.content)) { - continue; - } - - const inlineResultIds = new Set(); - for (const part of message.content) { - if (isToolResultPart(part)) { - inlineResultIds.add(part.toolCallId); - } - } - - const repairedContent: ChatAssistantContentPart[] = []; - const regularToolCalls: Array<{ id: string; toolName: string }> = []; - - for (const part of message.content) { - repairedContent.push(part); - - if (!isToolCallPart(part)) { - continue; - } - - const toolName = part.toolName ?? "unknown"; - - if (part.providerExecuted) { - if (!inlineResultIds.has(part.toolCallId)) { - repairedContent.push(createSyntheticToolResult(part.toolCallId, toolName)); - mutated = true; - } - continue; - } - - if (!inlineResultIds.has(part.toolCallId)) { - regularToolCalls.push({ id: part.toolCallId, toolName }); - } - } - - if (repairedContent.length !== message.content.length) { - result[index] = copyProviderModelMessageSourceId(message, { - ...message, - content: repairedContent, - }); - } - - if (regularToolCalls.length === 0) { - continue; - } - - const nextMessage = result[index + 1]; - const immediateResultIds = new Set(); - - if (nextMessage?.role === "tool" && Array.isArray(nextMessage.content)) { - for (const part of nextMessage.content) { - if (isToolResultPart(part)) { - immediateResultIds.add(part.toolCallId); - } - } - } - - const unresolvedCalls = regularToolCalls.filter((toolCall) => - !immediateResultIds.has(toolCall.id) - ); - if (unresolvedCalls.length === 0) { - continue; - } - - const movedResults = new Map(); - - if (nextMessage?.role !== "user" && nextMessage?.role !== "system") { - for ( - let laterIndex = index + 2; - laterIndex < result.length && movedResults.size < unresolvedCalls.length; - laterIndex++ - ) { - const laterMessage = result[laterIndex]; - if (laterMessage?.role === "user" || laterMessage?.role === "system") { - break; - } - if (laterMessage?.role !== "tool" || !Array.isArray(laterMessage.content)) { - continue; - } - - let removedFromLater = false; - const keptLaterContent = laterMessage.content.filter((part) => { - if (!isToolResultPart(part)) { - return true; - } - - if ( - !unresolvedCalls.some((toolCall) => toolCall.id === part.toolCallId) || - movedResults.has(part.toolCallId) - ) { - return true; - } - - movedResults.set(part.toolCallId, part); - removedFromLater = true; - return false; - }); - - if (!removedFromLater) { - continue; - } - - if (keptLaterContent.length === 0) { - result.splice(laterIndex, 1); - laterIndex--; - continue; - } - - result[laterIndex] = copyProviderModelMessageSourceId(laterMessage, { - ...laterMessage, - content: keptLaterContent, - }); - } - } - - const repairedResults = unresolvedCalls.map( - (toolCall) => - movedResults.get(toolCall.id) ?? createSyntheticToolResult(toolCall.id, toolCall.toolName), - ); - - if (nextMessage?.role === "tool" && Array.isArray(nextMessage.content)) { - result[index + 1] = copyProviderModelMessageSourceId(nextMessage, { - ...nextMessage, - content: [...repairedResults, ...nextMessage.content], - }); - } else { - const toolMessage: ProviderModelMessage = { - role: "tool", - content: repairedResults, - }; - result.splice(index + 1, 0, copyProviderModelMessageSourceId(message, toolMessage)); - } - mutated = true; - } - - return mutated ? result : messages; -} - /** Estimate overhead. */ export function estimateOverhead(instructions: unknown, toolCount: number): number { const instructionTokens = estimateTokens(instructions); diff --git a/src/chat/provider-message-anchor-preservation.ts b/src/chat/provider-message-anchor-preservation.ts new file mode 100644 index 0000000000..2b73d1ca23 --- /dev/null +++ b/src/chat/provider-message-anchor-preservation.ts @@ -0,0 +1,74 @@ +import { + getProviderModelMessageSourceId, + isToolResultPart, + withProviderModelMessageSourceId, +} from "./conversation.ts"; +import type { ChatUiMessage, ProviderModelMessage } from "./types.ts"; + +export type ProviderMessageSanitizationOptions = { + preserveEmptyAssistantSourceMessageIds?: readonly string[]; +}; + +export function shouldPreserveEmptyAssistantMessage( + message: ProviderModelMessage, + options: ProviderMessageSanitizationOptions, +): boolean { + if (message.role !== "assistant") return false; + const sourceId = getProviderModelMessageSourceId(message); + return sourceId !== undefined && + options.preserveEmptyAssistantSourceMessageIds?.includes(sourceId) === true; +} + +/** Preserve provider replay anchors whose public assistant content was empty. */ +export function preserveEmptyAssistantAnchors( + providerMessages: ProviderModelMessage[], + sourceMessages: readonly ChatUiMessage[], + preserveSourceMessageIds: readonly string[] | undefined, +): ProviderModelMessage[] { + if (preserveSourceMessageIds === undefined || preserveSourceMessageIds.length === 0) { + return providerMessages; + } + + const preservedIds = new Set(preserveSourceMessageIds); + const messagesBySourceId = new Map(); + for (const message of providerMessages) { + const sourceId = getProviderModelMessageSourceId(message); + if (sourceId === undefined) continue; + const messagesForSource = messagesBySourceId.get(sourceId) ?? []; + messagesForSource.push(message); + messagesBySourceId.set(sourceId, messagesForSource); + } + + const result: ProviderModelMessage[] = []; + const emittedSourceIds = new Set(); + for (const sourceMessage of sourceMessages) { + const sourceId = sourceMessage.id; + const existingMessages = messagesBySourceId.get(sourceId); + if (existingMessages) { + result.push(...existingMessages); + emittedSourceIds.add(sourceId); + } else if ( + sourceMessage.role === "assistant" && + sourceMessage.parts.length === 0 && + preservedIds.has(sourceId) + ) { + result.push(withProviderModelMessageSourceId({ role: "assistant", content: [] }, sourceId)); + emittedSourceIds.add(sourceId); + } + } + + for (const message of providerMessages) { + const sourceId = getProviderModelMessageSourceId(message); + if (sourceId === undefined || !emittedSourceIds.has(sourceId)) result.push(message); + } + + return result; +} + +export function hasImmediateToolResult( + message: ProviderModelMessage | undefined, + toolCallId: string, +): boolean { + return message?.role === "tool" && Array.isArray(message.content) && + message.content.some((part) => isToolResultPart(part) && part.toolCallId === toolCallId); +} diff --git a/src/chat/provider-message-conversion.ts b/src/chat/provider-message-conversion.ts index ed496c8938..59013bdab1 100644 --- a/src/chat/provider-message-conversion.ts +++ b/src/chat/provider-message-conversion.ts @@ -7,7 +7,6 @@ * and settles into one ProviderModelMessage[]. */ import { isRecord } from "./part-field-access.ts"; -import type { JsonValue } from "./part-field-access.ts"; import { buildRawToolCallResultOutput, buildToolResultOutput, @@ -34,17 +33,22 @@ type ProviderToolResultContent = { type: "tool-result"; toolCallId: string; toolName: string; - output: - | { - type: "json"; - value: JsonValue; - } - | { - type: "error-text"; - value: string; - }; + output: ChatToolResultPart["output"]; + providerExecuted?: boolean; }; +type AssistantContentPart = + | { type: "text"; text: string } + | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } + | { type: "file" | "image"; mediaType: string; data: string; filename?: string } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + input: Record; + providerExecuted?: boolean; + }; + function buildToolNameMap(parts: ReadonlyArray): Map { const toolNames = new Map(); @@ -84,6 +88,7 @@ function resolveRawToolResultPart( toolCallId: rawResult.toolCallId, toolName, output: rawResult.output, + ...(rawResult.providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -155,12 +160,7 @@ function convertAssistantMessage( replayMatches: ProviderVisibleToolReplayMatches, ): ProviderModelMessage[] { const toolNamesById = buildToolNameMap(message.parts); - const assistantContent: Array< - | { type: "text"; text: string } - | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } - | { type: "file" | "image"; mediaType: string; data: string; filename?: string } - | { type: "tool-call"; toolCallId: string; toolName: string; input: Record } - > = []; + const assistantContent: AssistantContentPart[] = []; const deferredAssistantContent: typeof assistantContent = []; const toolResults: ProviderToolResultContent[] = []; const pendingToolCallIds = new Set(); @@ -190,13 +190,7 @@ function convertAssistantMessage( toolResults.length = 0; }; - const pushAssistantPart = ( - part: - | { type: "text"; text: string } - | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } - | { type: "file" | "image"; mediaType: string; data: string; filename?: string } - | { type: "tool-call"; toolCallId: string; toolName: string; input: Record }, - ) => { + const pushAssistantPart = (part: AssistantContentPart) => { if (part.type === "tool-call") { if (deferredAssistantContent.length > 0) { flushAssistantMessage(assistantContent); @@ -235,6 +229,7 @@ function convertAssistantMessage( toolName: string; input: Record; state?: string; + providerExecuted?: boolean; }, resultOutput: ReturnType, ) => { @@ -257,6 +252,7 @@ function convertAssistantMessage( toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, input: toolCall.input, + ...(toolCall.providerExecuted === true ? { providerExecuted: true } : {}), }); if (resultOutput) { @@ -265,6 +261,7 @@ function convertAssistantMessage( toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, output: resultOutput, + ...(toolCall.providerExecuted === true ? { providerExecuted: true } : {}), }); } }; @@ -364,6 +361,7 @@ function convertToolMessage( toolCallId: toolPart.toolCallId, toolName: toolPart.toolName, output, + ...(toolPart.providerExecuted === true ? { providerExecuted: true } : {}), }); } continue; diff --git a/src/chat/provider-message-tool-pair-repair.ts b/src/chat/provider-message-tool-pair-repair.ts new file mode 100644 index 0000000000..59a680a80d --- /dev/null +++ b/src/chat/provider-message-tool-pair-repair.ts @@ -0,0 +1,162 @@ +import { + copyProviderModelMessageSourceId, + isToolCallPart, + isToolResultPart, +} from "./conversation.ts"; +import { hasImmediateToolResult } from "./provider-message-anchor-preservation.ts"; +import type { + ChatAssistantContentPart, + ChatToolResultPart, + ProviderModelMessage, +} from "./types.ts"; + +function createSyntheticToolResult(toolCallId: string, toolName: string): ChatToolResultPart { + return { + type: "tool-result", + toolCallId, + toolName, + output: { type: "text", value: "[tool result unavailable]" }, + }; +} + +/** Repair tool pairs. */ +export function repairToolPairs(messages: ProviderModelMessage[]): ProviderModelMessage[] { + const result = [...messages]; + let mutated = false; + + for (let index = 0; index < result.length; index++) { + const message = result[index]; + if (!message) continue; + + if (message.role !== "assistant" || !Array.isArray(message.content)) { + continue; + } + + const inlineResultIds = new Set(); + for (const part of message.content) { + if (isToolResultPart(part)) { + inlineResultIds.add(part.toolCallId); + } + } + + const nextMessage = result[index + 1]; + + const repairedContent: ChatAssistantContentPart[] = []; + const regularToolCalls: Array<{ id: string; toolName: string }> = []; + + for (const part of message.content) { + repairedContent.push(part); + + if (!isToolCallPart(part)) { + continue; + } + + const toolName = part.toolName ?? "unknown"; + + if (part.providerExecuted) { + if ( + !inlineResultIds.has(part.toolCallId) && + !hasImmediateToolResult(nextMessage, part.toolCallId) + ) { + repairedContent.push(createSyntheticToolResult(part.toolCallId, toolName)); + mutated = true; + } + continue; + } + + if (!inlineResultIds.has(part.toolCallId)) { + regularToolCalls.push({ id: part.toolCallId, toolName }); + } + } + + if (repairedContent.length !== message.content.length) { + result[index] = copyProviderModelMessageSourceId(message, { + ...message, + content: repairedContent, + }); + } + + if (regularToolCalls.length === 0) { + continue; + } + + const unresolvedCalls = regularToolCalls.filter((toolCall) => + !hasImmediateToolResult(nextMessage, toolCall.id) + ); + if (unresolvedCalls.length === 0) { + continue; + } + + const movedResults = new Map(); + + if (nextMessage?.role !== "user" && nextMessage?.role !== "system") { + for ( + let laterIndex = index + 2; + laterIndex < result.length && movedResults.size < unresolvedCalls.length; + laterIndex++ + ) { + const laterMessage = result[laterIndex]; + if (laterMessage?.role === "user" || laterMessage?.role === "system") { + break; + } + if (laterMessage?.role !== "tool" || !Array.isArray(laterMessage.content)) { + continue; + } + + let removedFromLater = false; + const keptLaterContent = laterMessage.content.filter((part) => { + if (!isToolResultPart(part)) { + return true; + } + + if ( + !unresolvedCalls.some((toolCall) => toolCall.id === part.toolCallId) || + movedResults.has(part.toolCallId) + ) { + return true; + } + + movedResults.set(part.toolCallId, part); + removedFromLater = true; + return false; + }); + + if (!removedFromLater) { + continue; + } + + if (keptLaterContent.length === 0) { + result.splice(laterIndex, 1); + laterIndex--; + continue; + } + + result[laterIndex] = copyProviderModelMessageSourceId(laterMessage, { + ...laterMessage, + content: keptLaterContent, + }); + } + } + + const repairedResults = unresolvedCalls.map( + (toolCall) => + movedResults.get(toolCall.id) ?? createSyntheticToolResult(toolCall.id, toolCall.toolName), + ); + + if (nextMessage?.role === "tool" && Array.isArray(nextMessage.content)) { + result[index + 1] = copyProviderModelMessageSourceId(nextMessage, { + ...nextMessage, + content: [...repairedResults, ...nextMessage.content], + }); + } else { + const toolMessage: ProviderModelMessage = { + role: "tool", + content: repairedResults, + }; + result.splice(index + 1, 0, copyProviderModelMessageSourceId(message, toolMessage)); + } + mutated = true; + } + + return mutated ? result : messages; +} diff --git a/src/chat/types.ts b/src/chat/types.ts index 78e944abc7..492a6ef133 100644 --- a/src/chat/types.ts +++ b/src/chat/types.ts @@ -242,6 +242,7 @@ export type ChatToolResultPart = { toolCallId: string; toolName: string; output: ChatToolResultOutput; + providerExecuted?: boolean; providerOptions?: unknown; }; From 39b70ca41fc6a466abce2ef8622f6c61eef6e275 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 14:26:31 +0200 Subject: [PATCH 27/56] Separate split provider metadata from replay checkpoints Generic provider metadata can split during text-generation conversion independently of provider replay checkpoints. Use a distinct registered error so checkpoint validation remains reserved for trusted replay-state failures. Constraint: Provider metadata is a generic side channel and may hold non-replay provider state. Rejected: Keep the replay checkpoint error with generic detail | still reports the wrong slug/title to operators. Confidence: high Scope-risk: narrow Tested: deno task test:file src/agent/runtime/message-adapter.test.ts src/agent/runtime/provider-replay.test.ts src/agent/hosted/chat-preparation.test.ts src/chat/message-prep.test.ts src/agent/runtime/text-generation-runtime-message-converter.test.ts Tested: deno check touched replay/chat/error surfaces Tested: deno fmt --check changed P3 files && git diff --check --- ...t-generation-runtime-message-converter.test.ts | 1 + .../text-generation-runtime-message-converter.ts | 4 ++-- src/errors/error-registry/agent.ts | 15 +++++++++++++++ src/errors/index.ts | 1 + 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index 8b9496276a..8c2d32b657 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -538,6 +538,7 @@ describe("text-generation-runtime-message-converter", () => { const error = assertThrows(() => convertToTextGenerationRuntimeMessages([message])); assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "provider-metadata-split-unsupported"); assertEquals( error.detail, "provider metadata cannot be attached after assistant turn splitting", diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 16afde3871..c00c79ca7f 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -18,7 +18,7 @@ import type { } from "./text-generation-runtime-message-types.ts"; import { assertProviderReachableAttachment } from "./attachment-reachability.ts"; import { buildDataFileAnnotation } from "#veryfront/chat/types.ts"; -import { PROVIDER_REPLAY_CHECKPOINT_INVALID } from "#veryfront/errors"; +import { PROVIDER_METADATA_SPLIT_UNSUPPORTED } from "#veryfront/errors"; import { getTextFromParts, getToolArguments, type Message, type ToolCallPart } from "../types.ts"; import { readAttachedProviderMetadata } from "./provider-metadata.ts"; @@ -488,7 +488,7 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( providerMetadata, }); } else if (providerMetadata !== undefined) { - throw PROVIDER_REPLAY_CHECKPOINT_INVALID.create({ + throw PROVIDER_METADATA_SPLIT_UNSUPPORTED.create({ detail: "provider metadata cannot be attached after assistant turn splitting", context: { assistantSegmentCount: assistantMessages.length }, }); diff --git a/src/errors/error-registry/agent.ts b/src/errors/error-registry/agent.ts index b5983cab3d..db5d87c880 100644 --- a/src/errors/error-registry/agent.ts +++ b/src/errors/error-registry/agent.ts @@ -94,6 +94,20 @@ export const PROVIDER_REPLAY_CHECKPOINT_INVALID = defineError({ "Verify the trusted source that resolved the run's provider replay checkpoints; do not retry with the same replay state", }); +/** + * Generic provider metadata could not be preserved through a provider request + * conversion. The metadata may be provider-specific state unrelated to replay + * checkpoints, so keep the failure distinct from checkpoint validation. + */ +export const PROVIDER_METADATA_SPLIT_UNSUPPORTED = defineError({ + slug: "provider-metadata-split-unsupported", + category: "AGENT", + status: 500, + title: "Provider metadata cannot be attached after assistant turn splitting", + suggestion: + "Avoid converting one provider response into multiple assistant request messages before provider metadata is consumed", +}); + /** Registry fragment for AGENT errors (slug → definition). */ export const AGENT_REGISTRY = { "agent-error": AGENT_ERROR, @@ -106,4 +120,5 @@ export const AGENT_REGISTRY = { "durable-run-event-persistence-failed": DURABLE_RUN_EVENT_PERSISTENCE_FAILED, "default-model-credential-mismatch": DEFAULT_MODEL_CREDENTIAL_MISMATCH, "provider-replay-checkpoint-invalid": PROVIDER_REPLAY_CHECKPOINT_INVALID, + "provider-metadata-split-unsupported": PROVIDER_METADATA_SPLIT_UNSUPPORTED, } as const; diff --git a/src/errors/index.ts b/src/errors/index.ts index 79cff8dc3c..cf82b33438 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -127,6 +127,7 @@ export { PRODUCTION_BUILD_REQUIRED, PROJECT_EXECUTION_UNAVAILABLE, PROJECT_SOURCE_EMPTY, + PROVIDER_METADATA_SPLIT_UNSUPPORTED, PROVIDER_REPLAY_CHECKPOINT_INVALID, PUSH_CONFLICT, PUSH_RECEIPT_MISSING, From f6c9a978b42440575daaec2d645e9446b6f40fa8 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 14:33:51 +0200 Subject: [PATCH 28/56] docs(api-reference): regenerate for the replay checkpoint contract commits 66c97aa30 and 39b70ca41 shifted src lines and registered provider-metadata-split-unsupported without regenerating docs, so `deno task lint:ci` failed on docs:errors:check at 39b70ca41: docs/guides/errors.md is stale. Run `deno task docs:errors` and commit the result. Generated output only. --- docs/api-reference/veryfront/agent.md | 48 +++++++------- docs/api-reference/veryfront/chat.md | 92 +++++++++++++------------- docs/api-reference/veryfront/errors.md | 1 + docs/guides/errors.md | 7 ++ 4 files changed, 78 insertions(+), 70 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index a61310e63d..4bda6c2ef7 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -607,9 +607,9 @@ Input delivered to a hosted agent-service detached execution callback. | `composeAbortSignals` | Compose abort signals helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-stream-watchdog.ts#L61) | | `computeOpenToolCalls` | Compute open tool calls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L174) | | `containsExactArtifactPathValue` | Contains exact artifact path value helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/slash-command-artifact-policy.ts#L195) | -| `convertAgentRuntimeMessagesToProviderMessages` | Convert agent runtime messages to provider messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L709) | +| `convertAgentRuntimeMessagesToProviderMessages` | Convert agent runtime messages to provider messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L736) | | `convertCompactedProviderMessagesToChildForkRuntimeMessages` | Convert compacted provider messages to child fork runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L126) | -| `convertProviderMessagesToAgentRuntimeMessages` | Convert provider messages to agent runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L697) | +| `convertProviderMessagesToAgentRuntimeMessages` | Convert provider messages to agent runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L724) | | `createAgentServiceAgUiValidationErrorResponse` | Response payload for create hosted AG-UI validation error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L151) | | `createAgentServiceAuth` | Create hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L351) | | `createAgentServiceChildMirrorContext` | Context for create hosted child mirror. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L233) | @@ -714,7 +714,7 @@ Input delivered to a hosted agent-service detached execution callback. | `createRuntimePromptBlock` | Create runtime prompt block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/prompt-block.ts#L9) | | `createStreamedStepState` | State for create streamed step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-step-state.ts#L44) | | `createToolExecutionDataEventBridgeStream` | Create tool execution data event bridge stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-execution-data-event-bridge.ts#L41) | -| `createToolResultPart` | Create a chat tool-result part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L441) | +| `createToolResultPart` | Create a chat tool-result part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L464) | | `createVeryfrontCloudAgentServiceChatExecutionRootRunOptions` | Options accepted by create Veryfront Cloud hosted chat execution root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-chat-execution-preparation.ts#L43) | | `createVeryfrontCloudHostedChatExecutionRootRunOptions` | Options accepted by create Veryfront Cloud hosted chat execution root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-chat-execution-preparation.ts#L43) | | `createVeryfrontCloudPreparedAgentServiceChatExecutionRuntimeOptions` | Options accepted by create Veryfront Cloud prepared hosted chat execution runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-prepared-chat-execution-runtime.ts#L83) | @@ -772,9 +772,9 @@ Input delivered to a hosted agent-service detached execution callback. | `formatChildRunStreamPartError` | Error shape for format child run stream part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/execution-support.ts#L30) | | `formatRuntimeSkillMetadata` | Formats bounded runtime skill metadata for prompt use. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-prompt.ts#L342) | | `getAgent` | Return agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/composition/composition.ts#L246) | -| `getAgentRuntimeTextPart` | Return a runtime text part when the value carries text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L347) | -| `getAgentRuntimeToolCallPart` | Return a runtime tool-call part when the value carries a tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L373) | -| `getAgentRuntimeToolResultPart` | Return a runtime tool-result part when the value carries a tool result. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L403) | +| `getAgentRuntimeTextPart` | Return a runtime text part when the value carries text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L358) | +| `getAgentRuntimeToolCallPart` | Return a runtime tool-call part when the value carries a tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L384) | +| `getAgentRuntimeToolResultPart` | Return a runtime tool-result part when the value carries a tool result. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L420) | | `getAgentsAsTools` | Return agents as tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/composition/composition.ts#L295) | | `getAgentServiceTokenFromRequest` | Request payload for get hosted service token from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L234) | | `getAgUiChatUiMessageChunkMetadata` | Return AG-UI chat UI message chunk metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/chat-ui-chunk-encoder.ts#L136) | @@ -863,8 +863,8 @@ Input delivered to a hosted agent-service detached execution callback. | `normalizeConversationRunEvents` | Normalizes conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-event-normalization.ts#L95) | | `normalizeEncodedConversationRunEvents` | Normalizes encoded conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-events.ts#L456) | | `normalizeHostedChildArtifactPath` | Normalizes hosted child artifact path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-artifact-support.ts#L133) | -| `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L305) | -| `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L305) | +| `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | +| `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeRuntimeSkillReferencePath` | Normalizes and bounds a portable runtime skill reference path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1011) | | `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L551) | | `parseAgentServiceConfig` | Configuration used by parse agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L159) | @@ -893,9 +893,9 @@ Input delivered to a hosted agent-service detached execution callback. | `persistConversationUserMessage` | Message shape for persist conversation user. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L211) | | `persistLatestConversationUserMessage` | Message shape for persist latest conversation user. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L277) | | `prepareAgentRuntimeMessagesFromUiMessages` | Prepare agent runtime messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-preparation.ts#L35) | -| `prepareAgentServiceChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L508) | -| `prepareAgentServiceChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L375) | -| `prepareAgentServiceChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L645) | +| `prepareAgentServiceChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L518) | +| `prepareAgentServiceChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L385) | +| `prepareAgentServiceChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L655) | | `prepareAgentServiceConversationRootRunContext` | Context for prepare hosted conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L129) | | `prepareConversationRootRunContext` | Context for prepare conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-context.ts#L128) | | `prepareConversationRootRunLifecycle` | Prepare conversation root run lifecycle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L42) | @@ -906,9 +906,9 @@ Input delivered to a hosted agent-service detached execution callback. | `prepareDefaultHostedChildForkSandboxToolSources` | Prepare default hosted child fork sandbox tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-tool-sources.ts#L197) | | `prepareDefaultHostedChildForkToolAssembly` | Prepare default hosted child fork tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-requested-tools.ts#L375) | | `prepareDefaultHostedChildForkToolSources` | Prepare default hosted child fork tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-tool-sources.ts#L88) | -| `prepareHostedChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L508) | -| `prepareHostedChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L375) | -| `prepareHostedChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L645) | +| `prepareHostedChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L518) | +| `prepareHostedChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L385) | +| `prepareHostedChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L655) | | `prepareHostedChatRuntimeToolAssembly` | Prepare hosted chat runtime tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L559) | | `prepareHostedChildForkRuntimeStepMessages` | Prepare hosted child fork runtime step messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L138) | | `prepareHostedConversationRootRunContext` | Context for prepare hosted conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L129) | @@ -1019,8 +1019,8 @@ Input delivered to a hosted agent-service detached execution callback. | Name | Description | Source | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `AgentRuntime` | Implement agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/index.ts#L1084) | -| `AgentRuntimeMessageConversionError` | Error shape for agent runtime message conversion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L138) | +| `AgentRuntime` | Implement agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/index.ts#L1097) | +| `AgentRuntimeMessageConversionError` | Error shape for agent runtime message conversion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L147) | | `AgentServiceAuthError` | Error shape for hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L14) | | `AppendConversationRunEventsError` | Error shape for append conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L4) | | `BufferMemory` | Implement buffer memory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory.ts#L128) | @@ -1074,8 +1074,8 @@ Input delivered to a hosted agent-service detached execution callback. | `AgentRunEventSink` | Receives events produced within one scoped agent run execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/runtime/model-call-context.ts#L91) | | `AgentRunModelCallContextEvent` | Provider-agnostic input persisted before one model dispatch. System-message provider options contain only validated prompt-cache metadata. Other provider-specific values are excluded because run events are durable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/runtime/model-call-context.ts#L77) | | `AgentRuntimeForkStepRunner` | Public API contract for agent runtime fork step runner. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-stream.ts#L110) | -| `AgentRuntimeMessage` | Message shape for agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L107) | -| `AgentRuntimeMessagePart` | Public API contract for agent runtime message part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L87) | +| `AgentRuntimeMessage` | Message shape for agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L115) | +| `AgentRuntimeMessagePart` | Public API contract for agent runtime message part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L93) | | `AgentServiceActiveSpanAttributes` | Public API contract for hosted agent service active span attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/routes.ts#L57) | | `AgentServiceAgUiChatForwardedConfig` | Configuration used by hosted AG-UI chat forwarded. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L44) | | `AgentServiceAuth` | Public API contract for hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L167) | @@ -1457,9 +1457,9 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedAgentServiceStreamExecutionInput` | Input payload for hosted agent service stream execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/routes.ts#L66) | | `HostedAgUiChatForwardedConfig` | Configuration used by hosted AG-UI chat forwarded. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L44) | | `HostedChatExecutionLifecycleAdapter` | Public API contract for hosted chat execution lifecycle adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-lifecycle-types.ts#L5) | -| `HostedChatExecutionPreparationInput` | Input payload for hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L241) | -| `HostedChatExecutionPreparationResult` | Result returned from hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L289) | -| `HostedChatExecutionPreparationRootRunOptions` | Options accepted by hosted chat execution preparation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L220) | +| `HostedChatExecutionPreparationInput` | Input payload for hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L251) | +| `HostedChatExecutionPreparationResult` | Result returned from hosted chat execution preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L299) | +| `HostedChatExecutionPreparationRootRunOptions` | Options accepted by hosted chat execution preparation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L230) | | `HostedChatExecutionRootStreamWatchdog` | Public API contract for hosted chat execution root stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L96) | | `HostedChatExecutionRunContext` | Context for hosted chat execution run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L90) | | `HostedChatExecutionRuntime` | Public API contract for hosted chat execution runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L77) | @@ -1476,11 +1476,11 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedChatRuntimeAgentAdapterWarning` | Public API contract for hosted chat runtime agent adapter warning. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-agent-adapter.ts#L19) | | `HostedChatRuntimeAllowedToolNames` | Public API contract for hosted chat runtime allowed tool names. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L64) | | `HostedChatRuntimeCreationOptions` | Options accepted by hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L120) | -| `HostedChatRuntimeCreationPreparationInput` | Input payload for hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L108) | -| `HostedChatRuntimeCreationPreparationResult` | Result returned from hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L152) | +| `HostedChatRuntimeCreationPreparationInput` | Input payload for hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L118) | +| `HostedChatRuntimeCreationPreparationResult` | Result returned from hosted chat runtime creation preparation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L162) | | `HostedChatRuntimeCreationResult` | Result returned from hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L94) | | `HostedChatRuntimeFinishPart` | Public API contract for hosted chat runtime finish part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L17) | -| `HostedChatRuntimeInstructionsInput` | Input payload for hosted chat runtime instructions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L97) | +| `HostedChatRuntimeInstructionsInput` | Input payload for hosted chat runtime instructions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L107) | | `HostedChatRuntimeOnFinishEvent` | Event emitted for hosted chat runtime on finish. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L54) | | `HostedChatRuntimePreparationRootRunContext` | Context for hosted chat runtime preparation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L81) | | `HostedChatRuntimePreparationSteering` | Public API contract for hosted chat runtime preparation steering. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L91) | diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index a8fc3aba9f..6f00adbe15 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -587,27 +587,27 @@ import { #### Functions -| Name | Description | Source | -| -------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1812) | -| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1525) | -| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1433) | -| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L172) | -| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1839) | -| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1906) | -| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L247) | -| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1781) | -| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L125) | -| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1775) | -| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L56) | -| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L680) | -| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1327) | -| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L686) | -| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1025) | -| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1627) | -| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L713) | -| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L986) | -| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L815) | +| Name | Description | Source | +| -------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1681) | +| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1551) | +| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1459) | +| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L178) | +| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1708) | +| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1775) | +| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L253) | +| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1650) | +| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L131) | +| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1644) | +| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L62) | +| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L686) | +| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1353) | +| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L692) | +| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1044) | +| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/provider-message-tool-pair-repair.ts#L23) | +| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L719) | +| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L993) | +| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L821) | #### Types @@ -667,18 +667,18 @@ import { buildDataFileAnnotation, isImageFile, isTextPreviewFile } from "veryfro | Name | Description | Source | | ------------------------------------ | ------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `buildDataFileAnnotation` | Builds data file annotation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L656) | -| `isImageFile` | Check whether a file is an image. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L614) | -| `isTextPreviewFile` | Check whether a file supports text preview. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L628) | -| `isValidImageFile` | Check whether a file is a supported image upload. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L619) | -| `normalizeInlineAttachmentMediaType` | Normalizes inline attachment media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L633) | +| `buildDataFileAnnotation` | Builds data file annotation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L657) | +| `isImageFile` | Check whether a file is an image. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L615) | +| `isTextPreviewFile` | Check whether a file supports text preview. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L629) | +| `isValidImageFile` | Check whether a file is a supported image upload. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L620) | +| `normalizeInlineAttachmentMediaType` | Normalizes inline attachment media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L634) | #### Types | Name | Description | Source | | -------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `ChatAssistantContentPart` | Public API contract for chat assistant content part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L251) | -| `ChatAssistantMessage` | Message shape for chat assistant. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L272) | +| `ChatAssistantContentPart` | Public API contract for chat assistant content part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L252) | +| `ChatAssistantMessage` | Message shape for chat assistant. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L273) | | `ChatDataUiPart` | Chat UI part that carries custom data chunks. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L162) | | `ChatDynamicToolUiPart` | Tool UI part for a runtime-selected tool name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L150) | | `ChatFileUiPart` | Public API contract for chat file UI part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L107) | @@ -689,15 +689,15 @@ import { buildDataFileAnnotation, isImageFile, isTextPreviewFile } from "veryfro | `ChatModelTextPart` | Provider model message part that carries text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L191) | | `ChatNamedToolUiPart` | Tool UI part keyed by a static tool type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L156) | | `ChatReasoningUiPart` | Public API contract for chat reasoning UI part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L77) | -| `ChatRequestContext` | Context for chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L350) | -| `ChatRuntimeOverrides` | Public API contract for chat runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L302) | +| `ChatRequestContext` | Context for chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L351) | +| `ChatRuntimeOverrides` | Public API contract for chat runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L303) | | `ChatSourceDocumentUiPart` | Public API contract for chat source document UI part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L98) | | `ChatSourceUrlUiPart` | Public API contract for chat source URL UI part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L90) | | `ChatStepStartUiPart` | Public API contract for chat step start UI part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L85) | -| `ChatSystemMessage` | Message shape for chat system. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L259) | +| `ChatSystemMessage` | Message shape for chat system. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L260) | | `ChatTextUiPart` | Public API contract for chat text UI part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L71) | | `ChatToolCallPart` | Provider model message part that carries a tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L216) | -| `ChatToolMessage` | Message shape for chat tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L278) | +| `ChatToolMessage` | Message shape for chat tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L279) | | `ChatToolPartState` | State for chat tool part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L122) | | `ChatToolResultOutput` | Output from chat tool result. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L225) | | `ChatToolResultPart` | Provider model message part that carries a tool result. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L240) | @@ -705,30 +705,30 @@ import { buildDataFileAnnotation, isImageFile, isTextPreviewFile } from "veryfro | `ChatUiMessageChunk` | Public API contract for chat UI message chunk. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/protocol.ts#L412) | | `ChatUiMessagePart` | Public API contract for chat UI message part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L168) | | `ChatUiMessageRole` | Public API contract for chat UI message role. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L68) | -| `ChatUserContentPart` | Public API contract for chat user content part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L249) | -| `ChatUserMessage` | Message shape for chat user. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L266) | +| `ChatUserContentPart` | Public API contract for chat user content part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L250) | +| `ChatUserMessage` | Message shape for chat user. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L267) | | `ChildRunAudit` | Public API contract for child run audit. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/protocol.ts#L163) | | `ChildRunAuditToolCall` | Public API contract for child run audit tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/protocol.ts#L148) | | `ChildRunAuditToolResult` | Result returned from child run audit tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/protocol.ts#L155) | -| `DurableRootRunDescriptor` | Public API contract for durable root run descriptor. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L291) | +| `DurableRootRunDescriptor` | Public API contract for durable root run descriptor. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L292) | | `FileUIPartWithUpload` | File UI part enriched with upload metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L116) | | `MessageMetadata` | Public API contract for chat message metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/protocol.ts#L175) | -| `ProjectFile` | Public API contract for project file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L310) | -| `ProjectFileListItem` | Public API contract for project file list item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L316) | -| `ProviderModelMessage` | Message shape for provider model. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L284) | -| `UploadedFileReference` | Public API contract for uploaded file reference. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L325) | +| `ProjectFile` | Public API contract for project file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L311) | +| `ProjectFileListItem` | Public API contract for project file list item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L317) | +| `ProviderModelMessage` | Message shape for provider model. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L285) | +| `UploadedFileReference` | Public API contract for uploaded file reference. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L326) | #### Constants | Name | Description | Source | | ----------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `getChatRequestContextSchema` | Zod schema for get chat request context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L335) | -| `getChatToolPartStateSchema` | Zod schema for get chat tool part state. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L439) | -| `getChatUiMessagePartSchema` | Zod schema for get chat UI message part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L561) | -| `getChatUiMessageRoleSchema` | Zod schema for get chat UI message role. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L434) | -| `getChatUiMessageSchema` | Zod schema for get chat UI message. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L576) | -| `getChatUiMessagesSchema` | Zod schema for get chat UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L586) | -| `getMessageMetadataSchema` | Zod schema for get message metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L401) | +| `getChatRequestContextSchema` | Zod schema for get chat request context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L336) | +| `getChatToolPartStateSchema` | Zod schema for get chat tool part state. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L440) | +| `getChatUiMessagePartSchema` | Zod schema for get chat UI message part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L562) | +| `getChatUiMessageRoleSchema` | Zod schema for get chat UI message role. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L435) | +| `getChatUiMessageSchema` | Zod schema for get chat UI message. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L577) | +| `getChatUiMessagesSchema` | Zod schema for get chat UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L587) | +| `getMessageMetadataSchema` | Zod schema for get message metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L402) | | `imageFileTypes` | Image media types that chat uploads can display natively. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L53) | | `textFileExtensions` | File extensions that chat uploads can inline as text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/types.ts#L50) | diff --git a/docs/api-reference/veryfront/errors.md b/docs/api-reference/veryfront/errors.md index f35ec36c1d..56fda38a24 100644 --- a/docs/api-reference/veryfront/errors.md +++ b/docs/api-reference/veryfront/errors.md @@ -132,6 +132,7 @@ throw INVALID_WIDGET.create({ detail: "The widget id is malformed." }); | `PRODUCTION_BUILD_REQUIRED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L27) | | `PROJECT_EXECUTION_UNAVAILABLE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L52) | | `PROJECT_SOURCE_EMPTY` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L109) | +| `PROVIDER_METADATA_SPLIT_UNSUPPORTED` | Generic provider metadata could not be preserved through a provider request conversion. The metadata may be provider-specific state unrelated to replay checkpoints, so keep the failure distinct from checkpoint validation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L102) | | `PROVIDER_REPLAY_CHECKPOINT_INVALID` | Provider replay checkpoint state failed validation at a consumer boundary. Replay state guards provider protocol correctness on resume, so malformed or unappliable state fails closed instead of degrading into an unsigned replay. Details never include checkpoint contents: provider blocks carry signed reasoning material that must stay out of logs and error text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L88) | | `PUSH_CONFLICT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L84) | | `PUSH_RECEIPT_MISSING` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L76) | diff --git a/docs/guides/errors.md b/docs/guides/errors.md index 5d9a0593ca..32c0c3577b 100644 --- a/docs/guides/errors.md +++ b/docs/guides/errors.md @@ -826,6 +826,13 @@ Provider replay checkpoint is invalid. - **HTTP status:** 500 - **What to do:** Verify the trusted source that resolved the run's provider replay checkpoints; do not retry with the same replay state +### provider-metadata-split-unsupported + +Provider metadata cannot be attached after assistant turn splitting. + +- **HTTP status:** 500 +- **What to do:** Avoid converting one provider response into multiple assistant request messages before provider metadata is consumed + ## General Raised anywhere; these are not specific to one subsystem. From 1e974a9713f5bda527ee0e3cf439dce1c0fc2986 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 14:34:10 +0200 Subject: [PATCH 29/56] fix(agent): accept every supported provider-result content shape The tool-result validation added in 66c97aa30 required Array.isArray(block.content), but the Anthropic parser that owns those schemas requires a record for web_fetch/code-execution results and every *_tool_result_error, and accepts a string for mcp_tool_result. Valid replay state was rejected at parseProviderReplayBlock, before anchoring. Measured against parseAnthropicServerToolResult on the same block: builder checkpoint parser web_fetch_tool_result (record content) accepted REJECTED -> accepted web_search_tool_result (error record) accepted REJECTED -> accepted mcp_tool_result (string content) accepted REJECTED -> accepted mcp_tool_result (missing is_error) rejected REJECTED (unchanged) web_search_tool_result (content: 42) rejected REJECTED (unchanged) The gate stays a container-shape check; the per-field schema remains with the provider parser. The existing "not an array" web_search rejection is unchanged and still passes. --- src/agent/runtime/provider-replay.test.ts | 60 +++++++++++++++++++++++ src/agent/runtime/provider-replay.ts | 17 ++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 0334ec8412..afe6c70f37 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -300,6 +300,66 @@ describe("agent/runtime/provider-replay", () => { checkpoint.totalPartCount = 0; }); }); + + it("should accept the record and string provider-result content shapes", () => { + for ( + const block of [ + { + type: "web_fetch_tool_result", + tool_use_id: "srvtool-1", + caller: { type: "direct" }, + content: { + type: "web_fetch_result", + url: "https://example.com/a", + retrieved_at: "2026-01-01T00:00:00Z", + content: { + type: "document", + source: { type: "text", media_type: "text/plain", data: "hi" }, + }, + }, + }, + { + type: "web_search_tool_result", + tool_use_id: "srvtool-1", + caller: { type: "direct" }, + content: { type: "web_search_tool_result_error", error_code: "max_uses_exceeded" }, + }, + { type: "mcp_tool_result", tool_use_id: "srvtool-1", is_error: false, content: "ok" }, + ] + ) { + const checkpoint = parseProviderReplayCheckpoint({ + version: 1, + messageId: "assistant-1", + provider: "anthropic", + providerBlocks: [{ type: "provider-block", provider: "anthropic", block }], + providerBlockPositions: [0], + totalPartCount: 1, + }); + assertEquals(checkpoint.providerBlocks[0]?.block, block); + } + }); + + it("should reject a provider-result content primitive", () => { + assertProviderReplayError(() => + parseProviderReplayCheckpoint({ + version: 1, + messageId: "assistant-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { + type: "web_search_tool_result", + tool_use_id: "srvtool-1", + caller: { type: "direct" }, + content: 42, + }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }) + ); + }); }); describe("parseServerResolvedProviderReplayCheckpoints", () => { diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index af7c763898..0125f39095 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -131,6 +131,21 @@ function validateAnthropicThinkingReplayBlock( } } +// Container shape only; the per-field schema stays with the provider parser. +// mcp_tool_result carries a string or an array, web_search_tool_result an array +// of results or an error record, and every other supported result a record. +function hasSupportedAnthropicProviderToolResultContent( + block: Record, +): boolean { + if (block.type === "mcp_tool_result") { + return typeof block.content === "string" || Array.isArray(block.content); + } + if (block.type === "web_search_tool_result") { + return Array.isArray(block.content) || isRecord(block.content); + } + return isRecord(block.content); +} + function validateAnthropicProviderToolResultBlock( block: Record, context?: Record, @@ -138,7 +153,7 @@ function validateAnthropicProviderToolResultBlock( if (!isNonEmptyString(block.tool_use_id)) { invalidCheckpoint("checkpoint provider tool-result block is malformed", context); } - if (!Array.isArray(block.content)) { + if (!hasSupportedAnthropicProviderToolResultContent(block)) { invalidCheckpoint("checkpoint provider tool-result block is malformed", context); } if (block.type === "mcp_tool_result" && typeof block.is_error !== "boolean") { From 83474bb9fcc6449423775e9661bd39c10862be90 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 14:40:52 +0200 Subject: [PATCH 30/56] test(errors): record the provider-metadata-split-unsupported slug PUBLISHED_ERROR_SLUGS is asserted exhaustively, so adding an error without a line here reds coverage shard 4/8, tests (unit) and coverage gate. --- src/errors/error-registry.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/errors/error-registry.test.ts b/src/errors/error-registry.test.ts index b11e2c0a14..e6fd571455 100644 --- a/src/errors/error-registry.test.ts +++ b/src/errors/error-registry.test.ts @@ -118,6 +118,7 @@ const PUBLISHED_ERROR_SLUGS: readonly string[] = Object.freeze([ "production-build-required", "project-execution-unavailable", "project-source-empty", + "provider-metadata-split-unsupported", "provider-replay-checkpoint-invalid", "push-conflict", "push-receipt-missing", From 78213bdae99bbd8589ec14efc96a34b51ba1f393 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 14:32:27 +0200 Subject: [PATCH 31/56] Publish the split provider metadata error contract The provider-metadata split path added a public error slug, so the documentation and published-slug contract need to include it. CI intentionally fails when registry entries are not documented because clients rely on stable RFC 9457 docs links. Constraint: CI enforces the public error slug catalogue and docs anchors. Rejected: Reuse provider-replay-checkpoint-invalid | that conflates generic metadata splitting with replay checkpoint validation. Confidence: high Scope-risk: narrow Tested: deno task test:file tests/docs/error-docs-links.test.ts src/errors/error-registry.test.ts Tested: deno task test:file src/agent/runtime/text-generation-runtime-message-converter.test.ts src/agent/runtime/message-adapter.test.ts src/agent/runtime/provider-replay.test.ts src/agent/hosted/chat-preparation.test.ts src/chat/message-prep.test.ts Tested: deno check src/errors/error-registry/agent.ts src/errors/index.ts src/errors/error-registry.test.ts src/agent/runtime/text-generation-runtime-message-converter.ts src/agent/runtime/text-generation-runtime-message-converter.test.ts Tested: deno fmt --check docs/guides/errors.md src/errors/error-registry.test.ts src/errors/error-registry/agent.ts src/errors/index.ts src/agent/runtime/text-generation-runtime-message-converter.ts src/agent/runtime/text-generation-runtime-message-converter.test.ts Tested: git diff --check --- docs/guides/errors.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/guides/errors.md b/docs/guides/errors.md index 32c0c3577b..83d7153547 100644 --- a/docs/guides/errors.md +++ b/docs/guides/errors.md @@ -819,6 +819,13 @@ Default model has no matching provider credential. - **HTTP status:** 400 - **What to do:** Set the agent's model to a provider you have a key for, or use model: "auto" +### provider-metadata-split-unsupported + +Provider metadata cannot be attached after assistant turn splitting. + +- **HTTP status:** 500 +- **What to do:** Avoid converting one provider response into multiple assistant request messages before provider metadata is consumed + ### provider-replay-checkpoint-invalid Provider replay checkpoint is invalid. From fe150c84f1d75dc9128691b2f678c7a86972088a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 14:54:52 +0200 Subject: [PATCH 32/56] Fail closed on invalid replay result state Provider replay checkpoints can carry provider-executed result payloads that must remain byte-exact through resume. The checkpoint boundary now validates active-provider compatibility, provider-result type correlation, MCP text block shape, and preserved tool-result outputs before attaching replay metadata. Constraint: Durable replay state must fail at the typed checkpoint boundary, not later inside provider request construction. Rejected: Rely on provider request builders for validation | that surfaces plain provider-history errors after replay metadata has already been attached. Confidence: high Scope-risk: narrow Tested: deno task test:file src/agent/runtime/provider-replay.test.ts src/chat/message-prep.test.ts Tested: deno check src/agent/runtime/provider-replay.ts src/agent/runtime/provider-replay.test.ts src/agent/runtime/index.ts src/chat/message-prep.ts src/chat/message-prep.test.ts scripts/lint/ban-chat-antipatterns.ts Tested: deno task lint:chat-ratchets Tested: deno fmt --check src/agent/runtime/provider-replay.ts src/agent/runtime/provider-replay.test.ts src/agent/runtime/index.ts src/chat/message-prep.ts src/chat/message-prep.test.ts scripts/lint/ban-chat-antipatterns.ts Tested: git diff --check --- scripts/lint/ban-chat-antipatterns.ts | 2 +- src/agent/runtime/index.ts | 10 +- src/agent/runtime/provider-replay.test.ts | 118 ++++++++++++++++++++++ src/agent/runtime/provider-replay.ts | 109 +++++++++++++++++++- src/chat/message-prep.test.ts | 38 +++++++ src/chat/message-prep.ts | 4 + 6 files changed, 276 insertions(+), 5 deletions(-) diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index 66b97361a7..14d8e0bcd0 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -112,7 +112,7 @@ const FILE_SIZE_CEILINGS: Record = { // reconciliation). Not React components, so this map does not subject them // to the antipattern ratchets above — it only pins their size. "src/chat/conversation.ts": 557, - "src/chat/message-prep.ts": 1916, + "src/chat/message-prep.ts": 1789, "src/chat/tool-replay-reconciliation.ts": 291, "src/chat/message-part-parsing.ts": 264, "src/chat/part-field-access.ts": 65, diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 17e68de57a..df69509d74 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -64,6 +64,7 @@ import { MiddlewareChain } from "../middleware/chain.ts"; import { tryGetCacheKeyContext } from "#veryfront/cache/cache-key-builder.ts"; import type { ToolExecutionContext } from "#veryfront/tool"; import { + getModelRuntimeId, getModelRuntimeProvider, isLocalModelRuntime, supportsModelRuntimeToolCalling, @@ -204,11 +205,14 @@ const NativeError = Error; function getActiveProviderReplayProvider( languageModel: ModelRuntime, -): ProviderReplayProvider | undefined { - const provider = getModelRuntimeProvider(languageModel); +): ProviderReplayProvider | "unsupported" { + const provider = + (typeof languageModel.modelProvider === "string" ? languageModel.modelProvider : undefined) ?? + getModelRuntimeProvider(languageModel) ?? + getModelRuntimeId(languageModel)?.split("/")[0]; if (provider === "anthropic") return "anthropic"; if (provider === "openai") return "openai-responses"; - return undefined; + return "unsupported"; } function resolveRuntimeGenAiProviderName(modelId: string): string | undefined { diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index afe6c70f37..cd62f384f9 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -789,6 +789,112 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should reject provider tool results that do not match their tool use type", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-web-fetch", + name: "web_fetch", + input: { url: "https://veryfront.com/docs" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, providerResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + + it("should reject malformed MCP result elements at the checkpoint boundary", () => { + const providerCall = { + type: "mcp_tool_use", + id: "srvtool-mcp", + name: "search_docs", + server_name: "docs", + input: { query: "provider replay" }, + }; + const providerResult = { + type: "mcp_tool_result", + tool_use_id: providerCall.id, + is_error: false, + content: [{ type: "image" }], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, providerResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + it("should accept a provider tool turn whose text was persisted as one concatenated part", () => { const leadingText = { type: "text", text: "Let me search. " }; const providerCall = { @@ -1134,6 +1240,18 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should reject checkpoints when the active model provider cannot replay them", () => { + const target = createCheckpointedAssistantMessage("assistant-message-1"); + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages( + [target], + [createValidCheckpoint()], + { activeProvider: "unsupported" }, + ) + ); + }); + it("should keep fresher in-process replay metadata over a delivered checkpoint", () => { const target = createCheckpointedAssistantMessage("assistant-message-1"); const inProcess = { anthropic: { rawAssistantMessages: [[{ type: "text", text: "live" }]] } }; diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 0125f39095..178bef2598 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -61,7 +61,7 @@ export type ProviderReplayCheckpoint = { }; type ApplyProviderReplayCheckpointsOptions = { - activeProvider?: ProviderReplayProvider; + activeProvider?: ProviderReplayProvider | "unsupported"; }; /** @@ -161,6 +161,109 @@ function validateAnthropicProviderToolResultBlock( } } +function hasValidAnthropicErrorContent( + content: Record, + type: string, +): boolean { + return content.type === type && isNonEmptyString(content.error_code); +} + +function hasValidAnthropicMcpContent(value: unknown): boolean { + if (typeof value === "string") return true; + if (!Array.isArray(value)) return false; + return value.every((item) => { + if (!isRecord(item) || item.type !== "text" || typeof item.text !== "string") return false; + const citations = item.citations; + return citations === undefined || citations === null || + Array.isArray(citations) && + citations.every((citation) => isRecord(citation) && isNonEmptyString(citation.type)); + }); +} + +function expectedAnthropicResultTypeForTool(toolName: string): string | undefined { + switch (toolName) { + case "web_search": + return "web_search_tool_result"; + case "web_fetch": + return "web_fetch_tool_result"; + case "code_execution": + return "code_execution_tool_result"; + case "bash_code_execution": + return "bash_code_execution_tool_result"; + case "text_editor_code_execution": + return "text_editor_code_execution_tool_result"; + default: + return undefined; + } +} + +function assertAnthropicProviderToolResultsMatchUses( + checkpoint: ProviderReplayCheckpoint, +): void { + const providerToolNames = new Map(); + + for (const replayBlock of checkpoint.providerBlocks) { + const block = replayBlock.block; + if (block.type !== "server_tool_use" && block.type !== "mcp_tool_use") continue; + const toolUse = toCanonicalAnthropicToolCall(block, true); + const toolCallId = String(toolUse.toolCallId); + if (providerToolNames.has(toolCallId)) { + invalidCheckpoint("checkpoint provider tool-use id is duplicated"); + } + providerToolNames.set(toolCallId, String(toolUse.toolName)); + } + + for (const replayBlock of checkpoint.providerBlocks) { + const block = replayBlock.block; + if ( + typeof block.type !== "string" || + !ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES.has(block.type) + ) { + continue; + } + validateAnthropicProviderToolResultBlock(block); + const toolCallId = String(block.tool_use_id); + const toolName = providerToolNames.get(toolCallId); + if (!toolName) { + invalidCheckpoint("checkpoint provider tool-result has no matching provider tool-use"); + } + if (block.type === "mcp_tool_result") { + if (!hasValidAnthropicMcpContent(block.content)) { + invalidCheckpoint("checkpoint MCP tool-result content is malformed"); + } + continue; + } + const expectedType = expectedAnthropicResultTypeForTool(toolName); + if (block.type !== expectedType) { + invalidCheckpoint("checkpoint provider tool-result type does not match its tool-use"); + } + const content = isRecord(block.content) ? block.content : undefined; + if ( + (block.type === "web_search_tool_result" && isRecord(block.content) && + !hasValidAnthropicErrorContent(block.content, "web_search_tool_result_error")) || + (block.type === "web_fetch_tool_result" && content?.type !== "web_fetch_result" && + !hasValidAnthropicErrorContent(content ?? {}, "web_fetch_tool_result_error")) || + (block.type === "code_execution_tool_result" && + content?.type !== "code_execution_result" && + content?.type !== "encrypted_code_execution_result" && + !hasValidAnthropicErrorContent(content ?? {}, "code_execution_tool_result_error")) || + (block.type === "bash_code_execution_tool_result" && + content?.type !== "bash_code_execution_result" && + !hasValidAnthropicErrorContent(content ?? {}, "bash_code_execution_tool_result_error")) || + (block.type === "text_editor_code_execution_tool_result" && + content?.type !== "text_editor_code_execution_view_result" && + content?.type !== "text_editor_code_execution_create_result" && + content?.type !== "text_editor_code_execution_str_replace_result" && + !hasValidAnthropicErrorContent( + content ?? {}, + "text_editor_code_execution_tool_result_error", + )) + ) { + invalidCheckpoint("checkpoint provider tool-result content is malformed"); + } + } +} + function toTranscriptVisibleAnthropicReplayPart( block: Record, ): Record | undefined { @@ -549,12 +652,16 @@ export function applyProviderReplayCheckpointsToMessages( // turn is absent, so deployment skew surfaces immediately. for (const checkpoint of checkpoints) { assertReconstructibleProviderReplayCheckpoint(checkpoint); + if (options.activeProvider === "unsupported") { + invalidCheckpoint("active model provider cannot replay provider checkpoints"); + } if (options.activeProvider !== undefined && checkpoint.provider !== options.activeProvider) { invalidCheckpoint("checkpoint provider does not match the active model provider", { checkpointProvider: checkpoint.provider, activeProvider: options.activeProvider, }); } + assertAnthropicProviderToolResultsMatchUses(checkpoint); } for (const checkpoint of checkpoints) { const matches = messages.filter((message) => message.id === checkpoint.messageId); diff --git a/src/chat/message-prep.test.ts b/src/chat/message-prep.test.ts index ae39d6d44d..c9849f65a8 100644 --- a/src/chat/message-prep.test.ts +++ b/src/chat/message-prep.test.ts @@ -348,6 +348,44 @@ Deno.test("maskOldToolOutputs preserves checkpointed historical reasoning", () = assertEquals(preserved[1], messages[1]); }); +Deno.test("maskOldToolOutputs preserves checkpointed historical provider tool outputs", () => { + const messages = [ + { role: "user", content: "run the provider tool" }, + withProviderModelMessageSourceId( + { + role: "assistant", + content: [{ + type: "tool-call", + toolCallId: "srvtool-1", + toolName: "web_fetch", + input: { url: "https://veryfront.com/docs" }, + providerExecuted: true, + }], + }, + "assistant-1", + ), + withProviderModelMessageSourceId( + { + role: "tool", + content: [{ + type: "tool-result", + toolCallId: "srvtool-1", + toolName: "web_fetch", + output: { type: "json", value: { content: "x".repeat(600) } }, + }], + }, + "assistant-1", + ), + { role: "user", content: "continue" }, + ] satisfies ProviderModelMessage[]; + + const preserved = maskOldToolOutputs(messages, { + preserveSourceMessageIds: ["assistant-1"], + }); + + assertEquals(preserved[2], messages[2]); +}); + Deno.test("maskOldToolOutputs masks historical web_search, readFile, web_fetch and task results per tool", () => { const messages = [ { role: "user", content: "gather context" }, diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index dd61ab1429..06667980f9 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -1384,6 +1384,10 @@ export function maskOldToolOutputs( } if (msg.role !== "tool" || !Array.isArray(msg.content)) return msg; + const sourceMessageId = getProviderModelMessageSourceId(msg); + if (sourceMessageId && preservedSourceMessageIds.has(sourceMessageId)) { + return msg; + } const newContent: ChatToolResultPart[] = msg.content.map((part) => { if (part.type !== "tool-result") { From 835a2167573f7d78bd96f9eb00b687b7a0053da8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 15:00:13 +0200 Subject: [PATCH 33/56] Keep replay API reference current The replay boundary changes moved public source anchors in the generated agent and chat API reference. CI checks generated docs, so the reference has to be regenerated with the implementation commit. Constraint: docs:api-reference:check fails when generated anchors are stale. Confidence: high Scope-risk: narrow Tested: deno task docs Tested: deno task docs:api-reference:check --- docs/api-reference/veryfront/agent.md | 2 +- docs/api-reference/veryfront/chat.md | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 4bda6c2ef7..77920db335 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -1019,7 +1019,7 @@ Input delivered to a hosted agent-service detached execution callback. | Name | Description | Source | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `AgentRuntime` | Implement agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/index.ts#L1097) | +| `AgentRuntime` | Implement agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/index.ts#L1101) | | `AgentRuntimeMessageConversionError` | Error shape for agent runtime message conversion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L147) | | `AgentServiceAuthError` | Error shape for hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L14) | | `AppendConversationRunEventsError` | Error shape for append conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L4) | diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index 6f00adbe15..4d6eaf6959 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -589,16 +589,16 @@ import { | Name | Description | Source | | -------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1681) | -| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1551) | -| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1459) | +| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1685) | +| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1555) | +| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1463) | | `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L178) | -| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1708) | -| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1775) | +| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1712) | +| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1779) | | `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L253) | -| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1650) | +| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1654) | | `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L131) | -| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1644) | +| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1648) | | `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L62) | | `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L686) | | `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1353) | From e2d8fdaace61a3070019ccc163ec2f79346ad3fd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 15:05:24 +0200 Subject: [PATCH 34/56] Remove duplicate generated error entry The generated error reference already places provider-metadata-split-unsupported in the agent section. The earlier manual insertion left a duplicate stale anchor before provider-replay-checkpoint-invalid, which docs:errors:check rejects. Constraint: docs/guides/errors.md is generated from the error registry and must not carry manual duplicate entries. Confidence: high Scope-risk: narrow Tested: deno task docs:errors Tested: deno task docs:errors:check Tested: deno task test:file tests/docs/error-docs-links.test.ts src/errors/error-registry.test.ts Tested: deno task docs:api-reference:check --- docs/guides/errors.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/guides/errors.md b/docs/guides/errors.md index 83d7153547..32c0c3577b 100644 --- a/docs/guides/errors.md +++ b/docs/guides/errors.md @@ -819,13 +819,6 @@ Default model has no matching provider credential. - **HTTP status:** 400 - **What to do:** Set the agent's model to a provider you have a key for, or use model: "auto" -### provider-metadata-split-unsupported - -Provider metadata cannot be attached after assistant turn splitting. - -- **HTTP status:** 500 -- **What to do:** Avoid converting one provider response into multiple assistant request messages before provider metadata is consumed - ### provider-replay-checkpoint-invalid Provider replay checkpoint is invalid. From 8a6c961e8240a6aa2f733cccdcc3d78944566241 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 15:11:00 +0200 Subject: [PATCH 35/56] Preserve checkpoint history through direct message prep Direct message-prep callers can pass provider-owned checkpoint anchors without also constructing historical retention options. The provider replay path depends on those anchors surviving both historical output masking and input compaction, so message preparation now merges the checkpoint anchors into the retention IDs once before both transformations. The late workflow-list unmount test now observes the externally visible no-error path requested by review. Constraint: Provider checkpoint replay must preserve exact provider-visible history while still compacting unrelated historical tool payloads. Rejected: Require every direct caller to duplicate checkpoint IDs into historicalToolInputRetention | leaves an unsafe footgun at the public helper boundary. Confidence: high Scope-risk: narrow Directive: Keep checkpoint preservation applied before every lossy historical-history transform. Tested: PATH=/tmp/deno-2.7.7.HGru0c:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Applications/VMware Fusion.app/Contents/Public:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex-path:/Users/kojiwakayama/.codex/tmp/arg0/codex-arg0NVg5qf:/Users/kojiwakayama/Documents/CODE/agent-lab/.venv/bin:/Users/kojiwakayama/.veryfront/bin:/Users/kojiwakayama/.opencode/bin:/Users/kojiwakayama/.local/bin:/Users/kojiwakayama/.antigravity/antigravity/bin:/opt/homebrew/share/google-cloud-sdk/bin:/opt/homebrew/opt/openjdk/bin:/Users/kojiwakayama/.bun/bin:/Users/kojiwakayama/.krew/bin:/Applications/Visual Studio Code.app/Contents/Resources/app/bin:/Users/kojiwakayama/.nvm/versions/node/v24.18.0/bin:/Users/kojiwakayama/.cargo/bin:/Applications/Docker.app/Contents/Resources/bin/:/Users/kojiwakayama/.lmstudio/bin:/Applications/Warp.app/Contents/Resources/bin deno task test:file src/chat/message-prep.test.ts src/workflow/react/use-workflow-start.test.tsx src/agent/runtime/provider-replay.test.ts Tested: PATH=/tmp/deno-2.7.7.HGru0c:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Applications/VMware Fusion.app/Contents/Public:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex-path:/Users/kojiwakayama/.codex/tmp/arg0/codex-arg0NVg5qf:/Users/kojiwakayama/Documents/CODE/agent-lab/.venv/bin:/Users/kojiwakayama/.veryfront/bin:/Users/kojiwakayama/.opencode/bin:/Users/kojiwakayama/.local/bin:/Users/kojiwakayama/.antigravity/antigravity/bin:/opt/homebrew/share/google-cloud-sdk/bin:/opt/homebrew/opt/openjdk/bin:/Users/kojiwakayama/.bun/bin:/Users/kojiwakayama/.krew/bin:/Applications/Visual Studio Code.app/Contents/Resources/app/bin:/Users/kojiwakayama/.nvm/versions/node/v24.18.0/bin:/Users/kojiwakayama/.cargo/bin:/Applications/Docker.app/Contents/Resources/bin/:/Users/kojiwakayama/.lmstudio/bin:/Applications/Warp.app/Contents/Resources/bin deno check src/chat/message-prep.ts src/chat/message-prep.test.ts src/workflow/react/use-workflow-start.test.tsx scripts/lint/ban-chat-antipatterns.ts Tested: PATH=/tmp/deno-2.7.7.HGru0c:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Applications/VMware Fusion.app/Contents/Public:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex-path:/Users/kojiwakayama/.codex/tmp/arg0/codex-arg0NVg5qf:/Users/kojiwakayama/Documents/CODE/agent-lab/.venv/bin:/Users/kojiwakayama/.veryfront/bin:/Users/kojiwakayama/.opencode/bin:/Users/kojiwakayama/.local/bin:/Users/kojiwakayama/.antigravity/antigravity/bin:/opt/homebrew/share/google-cloud-sdk/bin:/opt/homebrew/opt/openjdk/bin:/Users/kojiwakayama/.bun/bin:/Users/kojiwakayama/.krew/bin:/Applications/Visual Studio Code.app/Contents/Resources/app/bin:/Users/kojiwakayama/.nvm/versions/node/v24.18.0/bin:/Users/kojiwakayama/.cargo/bin:/Applications/Docker.app/Contents/Resources/bin/:/Users/kojiwakayama/.lmstudio/bin:/Applications/Warp.app/Contents/Resources/bin deno task lint:chat-ratchets Tested: PATH=/tmp/deno-2.7.7.HGru0c:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Applications/VMware Fusion.app/Contents/Public:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex-path:/Users/kojiwakayama/.codex/tmp/arg0/codex-arg0NVg5qf:/Users/kojiwakayama/Documents/CODE/agent-lab/.venv/bin:/Users/kojiwakayama/.veryfront/bin:/Users/kojiwakayama/.opencode/bin:/Users/kojiwakayama/.local/bin:/Users/kojiwakayama/.antigravity/antigravity/bin:/opt/homebrew/share/google-cloud-sdk/bin:/opt/homebrew/opt/openjdk/bin:/Users/kojiwakayama/.bun/bin:/Users/kojiwakayama/.krew/bin:/Applications/Visual Studio Code.app/Contents/Resources/app/bin:/Users/kojiwakayama/.nvm/versions/node/v24.18.0/bin:/Users/kojiwakayama/.cargo/bin:/Applications/Docker.app/Contents/Resources/bin/:/Users/kojiwakayama/.lmstudio/bin:/Applications/Warp.app/Contents/Resources/bin deno task docs:api-reference:check Tested: PATH=/tmp/deno-2.7.7.HGru0c:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Applications/VMware Fusion.app/Contents/Public:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex-path:/Users/kojiwakayama/.codex/tmp/arg0/codex-arg0NVg5qf:/Users/kojiwakayama/Documents/CODE/agent-lab/.venv/bin:/Users/kojiwakayama/.veryfront/bin:/Users/kojiwakayama/.opencode/bin:/Users/kojiwakayama/.local/bin:/Users/kojiwakayama/.antigravity/antigravity/bin:/opt/homebrew/share/google-cloud-sdk/bin:/opt/homebrew/opt/openjdk/bin:/Users/kojiwakayama/.bun/bin:/Users/kojiwakayama/.krew/bin:/Applications/Visual Studio Code.app/Contents/Resources/app/bin:/Users/kojiwakayama/.nvm/versions/node/v24.18.0/bin:/Users/kojiwakayama/.cargo/bin:/Applications/Docker.app/Contents/Resources/bin/:/Users/kojiwakayama/.lmstudio/bin:/Applications/Warp.app/Contents/Resources/bin deno task docs:errors:check Not-tested: Full CI matrix locally; GitHub Actions will rerun after push. --- docs/api-reference/veryfront/chat.md | 18 +++---- scripts/lint/ban-chat-antipatterns.ts | 2 +- src/chat/message-prep.test.ts | 48 +++++++++++++++++++ src/chat/message-prep.ts | 28 ++++++++++- .../react/use-workflow-start.test.tsx | 12 ++++- 5 files changed, 94 insertions(+), 14 deletions(-) diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index 4d6eaf6959..ac91c624b7 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -589,21 +589,21 @@ import { | Name | Description | Source | | -------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1685) | -| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1555) | -| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1463) | +| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1709) | +| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1579) | +| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1487) | | `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L178) | -| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1712) | -| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1779) | +| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1736) | +| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1803) | | `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L253) | -| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1654) | +| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1678) | | `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L131) | -| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1648) | +| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1672) | | `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L62) | | `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L686) | -| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1353) | +| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1377) | | `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L692) | -| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1044) | +| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1064) | | `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/provider-message-tool-pair-repair.ts#L23) | | `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L719) | | `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L993) | diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index 14d8e0bcd0..dbb656d65e 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -112,7 +112,7 @@ const FILE_SIZE_CEILINGS: Record = { // reconciliation). Not React components, so this map does not subject them // to the antipattern ratchets above — it only pins their size. "src/chat/conversation.ts": 557, - "src/chat/message-prep.ts": 1789, + "src/chat/message-prep.ts": 1813, "src/chat/tool-replay-reconciliation.ts": 291, "src/chat/message-part-parsing.ts": 264, "src/chat/part-field-access.ts": 65, diff --git a/src/chat/message-prep.test.ts b/src/chat/message-prep.test.ts index c9849f65a8..6c171779ac 100644 --- a/src/chat/message-prep.test.ts +++ b/src/chat/message-prep.test.ts @@ -2121,6 +2121,54 @@ Deno.test("prepareProviderModelMessagesFromUiMessages compacts custom tools thro assert((diagnostics[0] as { retainedInputChars?: number }).retainedInputChars! < 1_000); }); +Deno.test("prepareProviderModelMessagesFromUiMessages preserves checkpointed provider-owned tool history during historical compaction", () => { + const inputMarker = "CHECKPOINTED_PROVIDER_TOOL_INPUT_MARKER"; + const outputMarker = "CHECKPOINTED_PROVIDER_TOOL_OUTPUT_MARKER"; + const prepared = prepareProviderModelMessagesFromUiMessages( + [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Patch the file." }], + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ + type: "dynamic-tool", + toolName: "update_file", + toolCallId: "tool-update", + input: { + path: "components/Checkpointed.tsx", + content: `${inputMarker}:${"const value = 1;\n".repeat(3000)}`, + }, + state: "output-available", + providerExecuted: true, + output: { + ok: true, + content: `${outputMarker}:${"patched output ".repeat(3000)}`, + }, + }], + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Continue from that patch." }], + }, + ], + { + providerOwnedToolNames: ["update_file"], + preserveProviderOwnedToolSourceMessageIds: ["assistant-1"], + }, + ); + + const serialized = JSON.stringify(prepared); + assertStringIncludes(serialized, inputMarker); + assertStringIncludes(serialized, outputMarker); + assertEquals(serialized.includes("historical_tool_input_summary"), false); + assertEquals(serialized.includes("[update_file output omitted"), false); +}); + Deno.test("compactHistoricalUiMessageToolInputs does not treat raw completed tool calls as results", () => { const inputMarker = "RAW_COMPLETED_CALL_IS_NOT_A_RESULT"; const compacted = compactHistoricalUiMessageToolInputs([ diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index 06667980f9..66da92e5b2 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -1040,6 +1040,26 @@ function filterValidMessages( ); } +function mergeHistoricalToolInputRetention( + retention: HistoricalToolInputRetentionOptions | undefined, + preserveSourceMessageIds: readonly string[] | undefined, +): HistoricalToolInputRetentionOptions | undefined { + if (!preserveSourceMessageIds || preserveSourceMessageIds.length === 0) { + return retention; + } + + const mergedPreservedIds = [ + ...(retention?.preserveSourceMessageIds ?? []), + ...preserveSourceMessageIds, + ]; + const dedupedPreservedIds = [...new Set(mergedPreservedIds)]; + + return { + ...retention, + preserveSourceMessageIds: dedupedPreservedIds, + }; +} + /** Prepare provider model messages from UI messages. */ export function prepareProviderModelMessagesFromUiMessages( messages: ChatUiMessage[], @@ -1069,8 +1089,12 @@ export function prepareProviderModelMessagesFromUiMessages( preserveEmptyAssistantSourceMessageIds: options.preserveProviderOwnedToolSourceMessageIds, }; const sanitized = sanitizeProviderModelMessages(patchedMessages, preserveOptions); - const masked = maskOldToolOutputs(sanitized, options.historicalToolInputRetention); - const compactedInputs = compactOldToolInputs(masked, options.historicalToolInputRetention); + const historicalToolInputRetention = mergeHistoricalToolInputRetention( + options.historicalToolInputRetention, + options.preserveProviderOwnedToolSourceMessageIds, + ); + const masked = maskOldToolOutputs(sanitized, historicalToolInputRetention); + const compactedInputs = compactOldToolInputs(masked, historicalToolInputRetention); const compacted = enforceTokenBudget(compactedInputs); const filtered = filterValidMessages(compacted, preserveOptions); return repairToolPairs(filtered); diff --git a/src/workflow/react/use-workflow-start.test.tsx b/src/workflow/react/use-workflow-start.test.tsx index e673b05d9e..10eebb62a5 100644 --- a/src/workflow/react/use-workflow-start.test.tsx +++ b/src/workflow/react/use-workflow-start.test.tsx @@ -565,6 +565,11 @@ describe("useWorkflowStart", () => { const response = Promise.withResolvers(); let root: ReturnType | null = null; let domRestored = false; + const consoleErrors: unknown[][] = []; + const originalConsoleError = console.error; + console.error = (...args: unknown[]): void => { + consoleErrors.push(args); + }; installMockFetch((() => response.promise) as typeof fetch); @@ -578,12 +583,15 @@ describe("useWorkflowStart", () => { flushSync(() => root!.render()); flushSync(() => root!.unmount()); root = null; - restoreDom(); - domRestored = true; response.reject(new Error("late list failure")); await new Promise((resolve) => setTimeout(resolve, 20)); + assertEquals(document.getElementById("root")?.textContent, ""); + assertEquals(consoleErrors, []); + restoreDom(); + domRestored = true; } finally { + console.error = originalConsoleError; if (root) flushSync(() => root!.unmount()); if (!domRestored) restoreDom(); } From 73def1aeacf0147c21d0bb34e4932fa69a0ffa37 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 15:22:49 +0200 Subject: [PATCH 36/56] Preserve provider replay across durable resume boundaries Durable provider replay must fail closed only for malformed replay state, not for valid resumes whose history was compacted, split across stored assistant turns, or carried outside AG-UI forwardedProps size limits. The runtime now validates Anthropic replay deliveries before mutating messages, correlates provider-owned tool results across the ordered checkpoint delivery, preserves raw assistant message grouping, validates split same-source assistant anchors without trusting ID prefixes, and accepts server-resolved replay checkpoints through a verified private request field outside forwardedProps while keeping the legacy forwardedProps fallback. Constraint: Provider replay metadata can contain opaque signed material, so validation errors must stay generic and must not echo payload contents. Constraint: The private replay field is trusted only after the existing server-envelope/run-event token verification; public bodies are stripped or ignored. Rejected: Increase the forwardedProps budget | forwardedProps is public AG-UI configuration and should not carry multi-megabyte provider replay payloads. Rejected: Import the Anthropic extension parser into core runtime | would add an avoidable core-to-extension dependency for a small boundary validator. Confidence: high Scope-risk: moderate Directive: Keep provider replay validation ordered and validate-before-attach; do not attach raw provider metadata until every delivered checkpoint has passed boundary and anchor validation. Tested: deno task test:file src/agent/runtime/provider-replay.test.ts src/agent/runtime/text-generation-runtime-message-converter.test.ts src/agent/hosted/runtime-request-config.test.ts src/agent/runtime/ag-ui-contract.test.ts src/agent/ag-ui/host-support.test.ts src/agent/hosted/chat-request.test.ts src/agent/hosted/ag-ui-chat-request.test.ts src/agent/runtime/agent-invocation-contract.test.ts Tested: deno task test:file src/agent/hosted/chat-preparation.test.ts src/chat/message-prep.test.ts Tested: deno check src/agent/runtime/provider-replay.ts src/agent/runtime/provider-replay.test.ts src/agent/runtime/text-generation-runtime-message-converter.ts src/agent/runtime/text-generation-runtime-message-converter.test.ts src/agent/hosted/runtime-request-config.ts src/agent/hosted/runtime-request-config.test.ts src/agent/hosted/chat-request-parser.ts src/agent/hosted/chat-request.ts src/agent/hosted/chat-request.test.ts src/agent/hosted/ag-ui-chat-request.ts src/agent/hosted/ag-ui-chat-request.test.ts src/agent/runtime/ag-ui-contract.ts src/agent/runtime/ag-ui-contract.test.ts src/agent/ag-ui/host-support.ts src/agent/ag-ui/host-support.test.ts src/agent/runtime/agent-invocation-contract.ts src/agent/runtime/agent-invocation-contract.test.ts Tested: deno task typecheck Tested: deno task lint Tested: deno fmt --check <17 touched files> Tested: git diff --check Not-tested: deno task verify:quick is blocked by pre-existing fmt drift in untouched scripts/test/run-deno-suite.ts and scripts/test/run-suite.test.ts. --- src/agent/ag-ui/host-support.test.ts | 42 ++ src/agent/ag-ui/host-support.ts | 1 + src/agent/hosted/ag-ui-chat-request.ts | 2 + src/agent/hosted/chat-request-parser.ts | 24 +- src/agent/hosted/chat-request.test.ts | 22 +- src/agent/hosted/chat-request.ts | 5 + .../hosted/cloud-agent-chat-execution.ts | 9 +- .../hosted/runtime-request-config.test.ts | 47 ++ src/agent/hosted/runtime-request-config.ts | 31 +- src/agent/runtime/ag-ui-contract.test.ts | 40 ++ src/agent/runtime/ag-ui-contract.ts | 1 + .../runtime/agent-invocation-contract.ts | 7 + src/agent/runtime/provider-replay.test.ts | 540 +++++++++++++- src/agent/runtime/provider-replay.ts | 674 +++++++++++++++--- ...xt-generation-runtime-message-converter.ts | 29 + .../react/use-workflow-start.test.tsx | 5 +- 16 files changed, 1359 insertions(+), 120 deletions(-) diff --git a/src/agent/ag-ui/host-support.test.ts b/src/agent/ag-ui/host-support.test.ts index 403b33cce0..fda424fe68 100644 --- a/src/agent/ag-ui/host-support.test.ts +++ b/src/agent/ag-ui/host-support.test.ts @@ -167,6 +167,48 @@ describe("agent/ag-ui-host-support", () => { ); }); + it("accepts private provider replay state outside the forwarded props budget", async () => { + const request = new Request("http://localhost/api/ag-ui", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [{ + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "hello" }], + }], + forwardedProps: { + traceId: "trace-1", + }, + serverResolvedProviderReplayCheckpoints: [{ + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { + type: "thinking", + thinking: "x".repeat(220_000), + signature: "sig-private-large", + }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }], + }), + }); + + const parsed = await parseAgUiRequest(request); + + assertEquals(parsed.forwardedProps, { traceId: "trace-1" }); + assertEquals( + Array.isArray(parsed.serverResolvedProviderReplayCheckpoints), + true, + "large replay payload must not be counted against forwardedProps", + ); + }); + it("returns a 400 Response from parseAgUiRequestOrError for malformed JSON bodies", async () => { const request = new Request("http://localhost/api/ag-ui", { method: "POST", diff --git a/src/agent/ag-ui/host-support.ts b/src/agent/ag-ui/host-support.ts index 0151f869f6..62dfe93674 100644 --- a/src/agent/ag-ui/host-support.ts +++ b/src/agent/ag-ui/host-support.ts @@ -106,6 +106,7 @@ export const getAgUiRequestSchema = defineSchema((v) => (value) => value === undefined || isWithinJsonSizeLimit(value, MAX_FORWARDED_PROPS_BYTES), { message: "forwardedProps must be less than 192 KB" }, ), + serverResolvedProviderReplayCheckpoints: v.unknown().optional(), model: v.string().optional(), maxOutputTokens: v.number().int().positive().optional(), }) diff --git a/src/agent/hosted/ag-ui-chat-request.ts b/src/agent/hosted/ag-ui-chat-request.ts index fae05db9ed..b1a7c89eba 100644 --- a/src/agent/hosted/ag-ui-chat-request.ts +++ b/src/agent/hosted/ag-ui-chat-request.ts @@ -202,6 +202,8 @@ export async function buildParsedHostedAgUiRequest( model: chatContext.model, allowDelegation: chatContext.allowDelegation, forwardedProps: input.agUiInput.forwardedProps, + serverResolvedProviderReplayCheckpoints: + input.agUiInput.serverResolvedProviderReplayCheckpoints, runtimeOverrides: chatContext.runtimeOverrides, durableRootRun: undefined, persistLatestUserMessageBeforeDurableRun: true, diff --git a/src/agent/hosted/chat-request-parser.ts b/src/agent/hosted/chat-request-parser.ts index 504e40274a..2d129970c6 100644 --- a/src/agent/hosted/chat-request-parser.ts +++ b/src/agent/hosted/chat-request-parser.ts @@ -73,6 +73,12 @@ export type ParsedHostedChatRequest = { authToken: string; /** True only after a server envelope credential is verified and bound to this run. */ serverEnvelopeVerified?: true; + /** + * Provider-native replay state resolved by the server outside forwardedProps + * so large opaque provider blocks do not consume the public forwardedProps budget. + * Ignored unless `serverEnvelopeVerified` is true. + */ + serverResolvedProviderReplayCheckpoints?: unknown; /** * Integration tools the control plane resolved for this run, taken from the * verified run-event token rather than the request body. Absent unless a @@ -179,7 +185,7 @@ async function withVerifiedRunEventAppendToken( const token = request.headers.get(RUN_EVENT_APPEND_TOKEN_HEADER)?.trim(); if (!token) { return { - ...parsedRequest, + ...stripUnverifiedServerResolvedRequestState(parsedRequest), forwardedProps: stripUnverifiedServerResolvedForwardedProps( parsedRequest.forwardedProps, ), @@ -207,7 +213,9 @@ async function withVerifiedRunEventAppendToken( ); const verifiedRequest: ParsedHostedChatRequest = { - ...parsedRequest, + ...(trustServerEnvelope + ? parsedRequest + : stripUnverifiedServerResolvedRequestState(parsedRequest)), ...(trustServerEnvelope ? { serverEnvelopeVerified: true as const } : {}), ...(verifiedContext ? { validatedContext: { ...parsedRequest.validatedContext, ...verifiedContext } } @@ -230,6 +238,16 @@ async function withVerifiedRunEventAppendToken( return verifiedRequest; } +function stripUnverifiedServerResolvedRequestState( + parsedRequest: ParsedHostedChatRequest, +): ParsedHostedChatRequest { + const { + serverResolvedProviderReplayCheckpoints: _serverResolvedProviderReplayCheckpoints, + ...publicParsedRequest + } = parsedRequest; + return publicParsedRequest; +} + /** * Keep only names that are canonical integration tool ids, deduplicated and * capped. The grant is signed, so this guards against a control-plane bug @@ -469,6 +487,7 @@ async function buildParsedHostedChatRequestInternal( model, allowDelegation, forwardedProps, + serverResolvedProviderReplayCheckpoints, runtimeOverrides, durableRootRun, } = input.chatRequest; @@ -533,6 +552,7 @@ async function buildParsedHostedChatRequestInternal( model, allowDelegation, forwardedProps, + serverResolvedProviderReplayCheckpoints, runtimeOverrides, durableRootRun, persistLatestUserMessageBeforeDurableRun: false, diff --git a/src/agent/hosted/chat-request.test.ts b/src/agent/hosted/chat-request.test.ts index 50dccd5f44..69aa13e527 100644 --- a/src/agent/hosted/chat-request.test.ts +++ b/src/agent/hosted/chat-request.test.ts @@ -46,6 +46,18 @@ const rawReplayToolResultPart = { is_error: false, } as const; const rawReplayParts = [rawReplayToolCallPart, rawReplayToolResultPart] as const; +const serverResolvedProviderReplayCheckpoint = { + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { type: "thinking", thinking: "", signature: "sig-private-replay" }, + }], + providerBlockPositions: [0], + totalPartCount: 1, +} as const; type ParsedHostedChatRequestMessagePart = ParsedHostedChatRequest["messages"][number]["parts"][ number @@ -1921,7 +1933,10 @@ describe("agent/hosted-chat-request", () => { headers: { "X-Veryfront-Run-Event-Token": "run-event-service-token", }, - body: JSON.stringify(createRuntimeInvocation()), + body: JSON.stringify({ + ...createRuntimeInvocation(), + serverResolvedProviderReplayCheckpoints: [serverResolvedProviderReplayCheckpoint], + }), }), { authenticate: () => Promise.resolve({ userId, authToken: "user-api-token" }), @@ -1943,6 +1958,9 @@ describe("agent/hosted-chat-request", () => { assertEquals(Object.keys(parsed).includes("runEventAppendToken"), false); assertEquals(JSON.stringify(parsed).includes("run-event-service-token"), false); assertEquals(parsed.serverEnvelopeVerified, true); + assertEquals(parsed.serverResolvedProviderReplayCheckpoints, [ + serverResolvedProviderReplayCheckpoint, + ]); assertEquals(verifiedRunEventTokens, [{ token: "run-event-service-token", projectId, @@ -1961,6 +1979,7 @@ describe("agent/hosted-chat-request", () => { messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "Hello" }] }], context: { conversationId, projectId, branchId }, durableRootRun: { runId: "run_root_1", messageId }, + serverResolvedProviderReplayCheckpoints: [serverResolvedProviderReplayCheckpoint], serverResolvedToolExposureCheckpoint: { version: 1, loadedToolNames: ["delete_project"], @@ -1986,6 +2005,7 @@ describe("agent/hosted-chat-request", () => { assertEquals("runEventAppendToken" in parsed, false); assertEquals(JSON.stringify(parsed).includes("run-event-service-token"), false); assertEquals(parsed.serverEnvelopeVerified, undefined); + assertEquals(parsed.serverResolvedProviderReplayCheckpoints, undefined); assertEquals(parsed.forwardedProps, { harmless: "preserved" }); }); diff --git a/src/agent/hosted/chat-request.ts b/src/agent/hosted/chat-request.ts index b4a4145158..2d0109f3a7 100644 --- a/src/agent/hosted/chat-request.ts +++ b/src/agent/hosted/chat-request.ts @@ -454,6 +454,7 @@ export const getHostedChatRequestSchema = defineSchema((v) => model: v.string().optional(), allowDelegation: v.boolean().optional(), forwardedProps: v.record(v.string(), v.unknown()).optional(), + serverResolvedProviderReplayCheckpoints: v.unknown().optional(), runtimeOverrides: getHostedChatRuntimeOverridesSchema().optional(), durableRootRun: getHostedDurableRootRunDescriptorSchema().optional(), }) @@ -473,6 +474,7 @@ export type HostedChatRequestInput = { model?: string; allowDelegation?: boolean; forwardedProps?: Record; + serverResolvedProviderReplayCheckpoints?: unknown; runtimeOverrides?: ChatRuntimeOverrides; durableRootRun?: DurableRootRunDescriptor; }; @@ -594,6 +596,9 @@ export function buildHostedChatRequestInputFromRuntimeAgentInvocation( ...(environmentContext ? { environmentContext } : {}), }, forwardedProps: buildHostedChatRequestForwardedPropsFromRuntimeAgentInvocation(input), + ...(input.serverResolvedProviderReplayCheckpoints !== undefined + ? { serverResolvedProviderReplayCheckpoints: input.serverResolvedProviderReplayCheckpoints } + : {}), durableRootRun: { runId: input.run.runId, messageId: input.run.messageId, diff --git a/src/agent/hosted/cloud-agent-chat-execution.ts b/src/agent/hosted/cloud-agent-chat-execution.ts index 12aad9cb9d..0e09542eec 100644 --- a/src/agent/hosted/cloud-agent-chat-execution.ts +++ b/src/agent/hosted/cloud-agent-chat-execution.ts @@ -332,10 +332,11 @@ export async function prepareChatExecutionWithinProjectRuntime( req.forwardedProps, req.serverEnvelopeVerified === true, ), - serverResolvedProviderReplayCheckpoints: getServerResolvedProviderReplayCheckpoints( - req.forwardedProps, - req.serverEnvelopeVerified === true, - ), + serverResolvedProviderReplayCheckpoints: getServerResolvedProviderReplayCheckpoints({ + forwardedProps: req.forwardedProps, + serverResolvedProviderReplayCheckpoints: req.serverResolvedProviderReplayCheckpoints, + serverEnvelopeVerified: req.serverEnvelopeVerified === true, + }), // Sourced from the verified run-event token, never from forwardedProps, so // it is trusted on the durable-chat path without trusting that body. ...(req.serverResolvedIntegrationToolNames?.length diff --git a/src/agent/hosted/runtime-request-config.test.ts b/src/agent/hosted/runtime-request-config.test.ts index 128253eeca..8e0e7ec549 100644 --- a/src/agent/hosted/runtime-request-config.test.ts +++ b/src/agent/hosted/runtime-request-config.test.ts @@ -99,6 +99,53 @@ it("server-resolved provider replay checkpoints require a verified envelope and ); }); +it("server-resolved provider replay checkpoints prefer verified private request state outside forwarded props", () => { + const forwardedCheckpoint = { + version: 1 as const, + messageId: "assistant-message-forwarded", + provider: "anthropic" as const, + providerBlocks: [{ + type: "provider-block" as const, + provider: "anthropic" as const, + block: { type: "thinking", thinking: "", signature: "sig-forwarded" }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + const privateCheckpoint = { + ...forwardedCheckpoint, + messageId: "assistant-message-private", + providerBlocks: [{ + type: "provider-block" as const, + provider: "anthropic" as const, + block: { type: "thinking", thinking: "", signature: "sig-private" }, + }], + }; + + assertEquals( + getServerResolvedProviderReplayCheckpoints({ + forwardedProps: { + serverResolvedProviderReplayCheckpoints: [forwardedCheckpoint], + }, + serverResolvedProviderReplayCheckpoints: [privateCheckpoint], + serverEnvelopeVerified: true, + }), + [privateCheckpoint], + "verified top-level state carries replay without consuming forwardedProps budget", + ); + assertEquals( + getServerResolvedProviderReplayCheckpoints({ + forwardedProps: { + serverResolvedProviderReplayCheckpoints: [forwardedCheckpoint], + }, + serverResolvedProviderReplayCheckpoints: [privateCheckpoint], + serverEnvelopeVerified: false, + }), + undefined, + "unverified top-level state is ignored", + ); +}); + it("server-resolved provider replay checkpoints reject providers this runtime cannot replay", () => { // Contract-valid on the wire, but stage 1 only reconstructs anthropic // replay: accepting it and skipping later would be silent degraded replay, diff --git a/src/agent/hosted/runtime-request-config.ts b/src/agent/hosted/runtime-request-config.ts index 2cbc7726ce..8cacbaa176 100644 --- a/src/agent/hosted/runtime-request-config.ts +++ b/src/agent/hosted/runtime-request-config.ts @@ -98,12 +98,41 @@ export function getServerResolvedToolExposureCheckpoint( * malformed state fails explicitly instead of degrading into an unsigned * replay, unlike the tool exposure checkpoint above whose absence is safe. */ +type ServerResolvedProviderReplayCheckpointInput = { + forwardedProps?: Record; + serverResolvedProviderReplayCheckpoints?: unknown; + serverEnvelopeVerified: boolean; +}; + +/** Read the provider replay checkpoints resolved by the authenticated server. */ +export function getServerResolvedProviderReplayCheckpoints( + input: ServerResolvedProviderReplayCheckpointInput, +): ProviderReplayCheckpoint[] | undefined; export function getServerResolvedProviderReplayCheckpoints( forwardedProps: Record | undefined, serverEnvelopeVerified: boolean, +): ProviderReplayCheckpoint[] | undefined; +export function getServerResolvedProviderReplayCheckpoints( + inputOrForwardedProps: + | ServerResolvedProviderReplayCheckpointInput + | Record + | undefined, + maybeServerEnvelopeVerified?: boolean, ): ProviderReplayCheckpoint[] | undefined { + const input = typeof maybeServerEnvelopeVerified === "boolean" + ? { + forwardedProps: inputOrForwardedProps as Record | undefined, + serverEnvelopeVerified: maybeServerEnvelopeVerified, + } + : inputOrForwardedProps as ServerResolvedProviderReplayCheckpointInput; + const { + forwardedProps, + serverResolvedProviderReplayCheckpoints, + serverEnvelopeVerified, + } = input; if (!serverEnvelopeVerified) return undefined; - const value = forwardedProps?.serverResolvedProviderReplayCheckpoints; + const value = serverResolvedProviderReplayCheckpoints ?? + forwardedProps?.serverResolvedProviderReplayCheckpoints; if (value === undefined) return undefined; const checkpoints = parseServerResolvedProviderReplayCheckpoints(value); // Contract-valid state this runtime version cannot reconstruct (another diff --git a/src/agent/runtime/ag-ui-contract.test.ts b/src/agent/runtime/ag-ui-contract.test.ts index 8db3fde797..f8c18f9d79 100644 --- a/src/agent/runtime/ag-ui-contract.test.ts +++ b/src/agent/runtime/ag-ui-contract.test.ts @@ -77,6 +77,46 @@ describe("agent/runtime-ag-ui-contract", () => { assertEquals(parsed.messages.length, 1); }); + it("accepts private provider replay state outside forwarded props", () => { + const parsed = getAgUiRuntimeRequestSchema().parse({ + threadId: crypto.randomUUID(), + runId: "run_1", + messages: [{ + id: "user_1", + role: "user", + content: "Hello", + }], + context: [], + tools: [], + forwardedProps: { + traceId: "trace-1", + }, + serverResolvedProviderReplayCheckpoints: [{ + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { + type: "thinking", + thinking: "x".repeat(220_000), + signature: "sig-private-large", + }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }], + }); + + assertEquals(parsed.forwardedProps, { traceId: "trace-1" }); + assertEquals( + Array.isArray(parsed.serverResolvedProviderReplayCheckpoints), + true, + "large replay payload must not be counted against forwardedProps", + ); + }); + it("normalizes runtime request defaults without leaking non-object state", () => { const normalized = normalizeAgUiRuntimeRequest( getAgUiRuntimeRequestSchema().parse({ diff --git a/src/agent/runtime/ag-ui-contract.ts b/src/agent/runtime/ag-ui-contract.ts index 730a31f057..95c3cb7d6f 100644 --- a/src/agent/runtime/ag-ui-contract.ts +++ b/src/agent/runtime/ag-ui-contract.ts @@ -205,6 +205,7 @@ export const getAgUiRuntimeRequestSchema = defineSchema((v) => (value) => value === undefined || isWithinJsonSizeLimit(value, MAX_FORWARDED_PROPS_BYTES), { message: "forwardedProps must be less than 192 KB" }, ), + serverResolvedProviderReplayCheckpoints: v.unknown().optional(), }) ); diff --git a/src/agent/runtime/agent-invocation-contract.ts b/src/agent/runtime/agent-invocation-contract.ts index 9a5e4bbe4e..3c83cf8e85 100644 --- a/src/agent/runtime/agent-invocation-contract.ts +++ b/src/agent/runtime/agent-invocation-contract.ts @@ -369,6 +369,7 @@ export const getRuntimeAgentRunInvocationSchema = defineSchema((v) => (value) => value === undefined || isWithinJsonSizeLimit(value, MAX_FORWARDED_PROPS_BYTES), { message: "forwardedProps must be less than 192 KB" }, ), + serverResolvedProviderReplayCheckpoints: v.unknown().optional(), }).superRefine((input, ctx) => { if (input.agentConfig && input.agentConfig.id !== input.run.agentId) { ctx.addIssue({ @@ -442,6 +443,9 @@ export type RuntimeAgentControlPlaneStreamRequest = { agentSource: RuntimeAgentRunInvocation["agentSource"]; agentConfig?: RuntimeAgentRunInvocation["agentConfig"]; forwardedProps?: RuntimeAgentRunInvocation["forwardedProps"]; + serverResolvedProviderReplayCheckpoints?: RuntimeAgentRunInvocation[ + "serverResolvedProviderReplayCheckpoints" + ]; }; /** Builds runtime agent control plane stream request from invocation. */ @@ -465,6 +469,9 @@ export function buildRuntimeAgentControlPlaneStreamRequestFromInvocation( agentSource: input.agentSource, ...(input.agentConfig ? { agentConfig: input.agentConfig } : {}), ...(input.forwardedProps ? { forwardedProps: input.forwardedProps } : {}), + ...(input.serverResolvedProviderReplayCheckpoints !== undefined + ? { serverResolvedProviderReplayCheckpoints: input.serverResolvedProviderReplayCheckpoints } + : {}), }; } diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index cd62f384f9..5e74cb3dcb 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -409,6 +409,27 @@ describe("agent/runtime/provider-replay", () => { ]) ); }); + + it("should reject malformed non-result blocks even when their anchor is absent", () => { + for ( + const block of [ + { type: "text" }, + { type: "redacted_thinking" }, + { type: "tool_use", id: "call-1", input: {} }, + ] + ) { + assertProviderReplayError(() => + parseServerResolvedProviderReplayCheckpoints([{ + version: 1, + messageId: "assistant-message-gone", + provider: "anthropic", + providerBlocks: [{ type: "provider-block", provider: "anthropic", block }], + providerBlockPositions: [0], + totalPartCount: 1, + }]) + ); + } + }); }); describe("applyProviderReplayCheckpointsToMessages", () => { @@ -699,6 +720,76 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should correlate provider tool results across checkpointed assistant turns", () => { + const providerCall = { + type: "mcp_tool_use", + id: "mcptool-cross-turn", + name: "echo", + server_name: "example-mcp", + input: { value: "hello" }, + }; + const providerResult = { + type: "mcp_tool_result", + tool_use_id: providerCall.id, + is_error: false, + content: "hello", + }; + const callTurn = { + id: "assistant-call", + role: "assistant", + parts: [{ + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }], + timestamp: 1, + } as Message; + const resultTurn = { + id: "assistant-result", + role: "assistant", + parts: [{ + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: "hello", + providerExecuted: true, + }], + timestamp: 2, + } as Message; + const callCheckpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: callTurn.id, + provider: "anthropic", + providerBlocks: [{ type: "provider-block", provider: "anthropic", block: providerCall }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + const resultCheckpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: resultTurn.id, + provider: "anthropic", + providerBlocks: [{ type: "provider-block", provider: "anthropic", block: providerResult }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + + applyProviderReplayCheckpointsToMessages( + [callTurn, resultTurn], + [callCheckpoint, resultCheckpoint], + ); + + assertEquals( + readAttachedProviderMetadata(callTurn), + { anthropic: { rawAssistantMessages: [[providerCall]] } }, + ); + assertEquals( + readAttachedProviderMetadata(resultTurn), + { anthropic: { rawAssistantMessages: [[providerResult]] } }, + ); + }); + it("should reject malformed provider tool-result blocks before attachment", () => { const providerCall = { type: "server_tool_use", @@ -713,6 +804,277 @@ describe("agent/runtime/provider-replay", () => { caller: { type: "direct" }, content: "not an array", }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, malformedProviderResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + + it("should reject a provider result that appears before its provider tool use", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-web-search", + name: "web_search", + input: { query: "provider replay" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerResult, providerCall].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + + it("should reject duplicate provider results for one provider tool use", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-web-search", + name: "web_search", + input: { query: "provider replay" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, providerResult, providerResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1, 2], + totalPartCount: 3, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + + it("should reject MCP provider results for server-owned tool uses", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-web-search", + name: "web_search", + input: { query: "provider replay" }, + caller: { type: "direct" }, + }; + const mismatchedMcpResult = { + type: "mcp_tool_result", + tool_use_id: providerCall.id, + is_error: false, + content: "ok", + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, mismatchedMcpResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + + it("should reject provider result content that lacks required provider fields", () => { + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: "srvtool-code", + toolName: "code_execution", + args: { code: "1 + 1" }, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: "srvtool-code", + toolName: "code_execution", + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const providerCall = { + type: "server_tool_use", + id: "srvtool-code", + name: "code_execution", + input: { code: "1 + 1" }, + caller: { type: "direct" }, + }; + const malformedProviderResult = { + type: "code_execution_tool_result", + tool_use_id: providerCall.id, + content: { type: "code_execution_result" }, + }; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, malformedProviderResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + + it("should reject malformed web-search result entries at the checkpoint boundary", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-web-search", + name: "web_search", + input: { query: "provider replay" }, + caller: { type: "direct" }, + }; + const malformedProviderResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [{ type: "web_search_result", url: "https://veryfront.com" }], + }; const target = { id: "assistant-message-1", role: "assistant", @@ -956,6 +1318,152 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should preserve checkpoint raw assistant message boundaries", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-1", + name: "web_search", + input: { query: "q" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const trailingText = { type: "text", text: "Found it." }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + trailingText, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, providerResult, trailingText].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1, 2], + providerMessageBlockCounts: [1, 2], + totalPartCount: 3, + }; + + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { anthropic: { rawAssistantMessages: [[providerCall], [providerResult, trailingText]] } }, + ); + assertEquals( + convertToTextGenerationRuntimeMessages([target]), + [{ + role: "assistant", + content: [{ type: "text", text: trailingText.text }], + providerMetadata: { + anthropic: { rawAssistantMessages: [[providerCall], [providerResult, trailingText]] }, + }, + }], + "provider-executed blocks replay through raw metadata without duplicating transcript text", + ); + }); + + it("should attach split provider replay segments to same-source assistant turns", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-1", + name: "web_search", + input: { query: "q" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const trailingText = { type: "text", text: "Found it." }; + const leadingAssistant = { + id: "assistant-message-1", + role: "assistant", + parts: [{ + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }], + timestamp: 1, + } as Message; + const toolSibling = { + id: "assistant-message-1", + role: "tool", + parts: [{ + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }], + timestamp: 2, + } as Message; + const trailingAssistant = { + id: "assistant-message-1", + role: "assistant", + parts: [trailingText], + timestamp: 3, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [providerCall, providerResult, trailingText].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1, 2], + totalPartCount: 3, + }; + + applyProviderReplayCheckpointsToMessages( + [leadingAssistant, toolSibling, trailingAssistant], + [checkpoint], + ); + + assertEquals( + readAttachedProviderMetadata(leadingAssistant), + { anthropic: { rawAssistantMessages: [[providerCall]] } }, + "the leading assistant segment replays only its raw assistant blocks", + ); + assertEquals( + readAttachedProviderMetadata(trailingAssistant), + { anthropic: { rawAssistantMessages: [[providerResult, trailingText]] } }, + "the trailing assistant segment carries the provider result before trailing text", + ); + assertEquals(readAttachedProviderMetadata(toolSibling), undefined); + }); + it("should match replay blocks against persisted assistant transcript order", () => { const providerCall = { type: "server_tool_use", @@ -1062,6 +1570,29 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should not attach any checkpoint metadata when a later anchor fails validation", () => { + const validTarget = createCheckpointedAssistantMessage("assistant-message-1"); + const invalidTarget = createAssistantMessage("assistant-message-2"); + const invalidCheckpoint: ProviderReplayCheckpoint = { + ...createValidCheckpoint(), + messageId: invalidTarget.id, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages( + [validTarget, invalidTarget], + [createValidCheckpoint(), invalidCheckpoint], + ) + ); + + assertEquals( + readAttachedProviderMetadata(validTarget), + undefined, + "a rejected delivery must not partially mutate earlier anchors", + ); + assertEquals(readAttachedProviderMetadata(invalidTarget), undefined); + }); + it("should be a no-op for empty or absent deliveries", () => { const target = createAssistantMessage("assistant-message-1"); applyProviderReplayCheckpointsToMessages([target], undefined); @@ -1288,10 +1819,11 @@ describe("agent/runtime/provider-replay", () => { ); }); - it("should fail explicitly when a split turn cannot carry replay state", () => { - // An assistant turn with an inline tool result followed by more content - // splits into multiple assistant segments during conversion; exact - // replay metadata cannot be paired with either fragment. + it("should fail explicitly when one message internally splits replay state", () => { + // One stored assistant turn with an inline tool result followed by more + // content still cannot receive one exact metadata attachment: by the time + // conversion splits it, no persisted sibling message exists for the + // trailing segment. const target = { id: "assistant-message-1", role: "assistant", diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 178bef2598..37c6b62326 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -18,6 +18,7 @@ const CHECKPOINT_KEYS = new Set([ "provider", "providerBlocks", "providerBlockPositions", + "providerMessageBlockCounts", "totalPartCount", "elapsedMs", "emittedAt", @@ -31,6 +32,39 @@ const ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES = new Set([ "text_editor_code_execution_tool_result", "mcp_tool_result", ]); +const WEB_SEARCH_ERROR_CODES = new Set([ + "invalid_tool_input", + "unavailable", + "max_uses_exceeded", + "too_many_requests", + "query_too_long", + "request_too_large", +]); +const WEB_FETCH_ERROR_CODES = new Set([ + "invalid_tool_input", + "url_too_long", + "url_not_allowed", + "url_not_in_prior_context", + "url_not_accessible", + "unsupported_content_type", + "too_many_requests", + "max_uses_exceeded", + "unavailable", +]); +const CODE_EXECUTION_ERROR_CODES = new Set([ + "invalid_tool_input", + "unavailable", + "too_many_requests", + "execution_time_exceeded", +]); +const BASH_CODE_EXECUTION_ERROR_CODES = new Set([ + ...CODE_EXECUTION_ERROR_CODES, + "output_file_too_large", +]); +const TEXT_EDITOR_CODE_EXECUTION_ERROR_CODES = new Set([ + ...CODE_EXECUTION_ERROR_CODES, + "file_not_found", +]); /** Providers whose continuation contract can require opaque block replay. */ export type ProviderReplayProvider = "anthropic" | "openai-responses"; @@ -55,6 +89,11 @@ export type ProviderReplayCheckpoint = { provider: ProviderReplayProvider; providerBlocks: ProviderReplayBlock[]; providerBlockPositions: number[]; + /** + * Optional raw Anthropic assistant-message grouping. Absent means the + * historical stage-1 shape: all blocks came from one raw assistant message. + */ + providerMessageBlockCounts?: number[]; totalPartCount: number; elapsedMs?: number; emittedAt?: number; @@ -84,6 +123,15 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.length > 0; } +function isSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value); +} + +function isNullableNonNegativeSafeInteger(value: unknown): value is number | null { + return value === null || + typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + function isSupportedAnthropicServerToolCaller(value: unknown): boolean { if (value === undefined) return true; if (!isRecord(value)) return false; @@ -161,11 +209,52 @@ function validateAnthropicProviderToolResultBlock( } } +function isAnthropicProviderToolResultBlock(block: Record): boolean { + return typeof block.type === "string" && ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES.has(block.type); +} + +function validateAnthropicReplayBlock( + block: Record, + context?: Record, +): void { + switch (block.type) { + case "text": + if (typeof block.text !== "string") { + invalidCheckpoint("checkpoint text block is malformed", context); + } + return; + case "thinking": + validateAnthropicThinkingReplayBlock(block, context); + return; + case "redacted_thinking": + if (!isNonEmptyString(block.data)) { + invalidCheckpoint("checkpoint redacted thinking block is malformed", context); + } + return; + case "tool_use": + toCanonicalAnthropicToolCall(block, false); + return; + case "server_tool_use": + case "mcp_tool_use": + toCanonicalAnthropicToolCall(block, true); + return; + default: + if (isAnthropicProviderToolResultBlock(block)) { + validateAnthropicProviderToolResultBlock(block, context); + return; + } + invalidCheckpoint("checkpoint provider block cannot be projected for validation", context); + } +} + function hasValidAnthropicErrorContent( content: Record, type: string, + allowedCodes?: ReadonlySet, ): boolean { - return content.type === type && isNonEmptyString(content.error_code); + return content.type === type && + isNonEmptyString(content.error_code) && + (allowedCodes === undefined || allowedCodes.has(content.error_code)); } function hasValidAnthropicMcpContent(value: unknown): boolean { @@ -180,6 +269,187 @@ function hasValidAnthropicMcpContent(value: unknown): boolean { }); } +function hasValidAnthropicFileOutputs( + value: unknown, + expectedType: "code_execution_output" | "bash_code_execution_output", +): boolean { + return Array.isArray(value) && + value.every((item) => + isRecord(item) && item.type === expectedType && isNonEmptyString(item.file_id) + ); +} + +function hasValidAnthropicCodeExecutionContent(content: Record): boolean { + if ( + hasValidAnthropicErrorContent( + content, + "code_execution_tool_result_error", + CODE_EXECUTION_ERROR_CODES, + ) + ) { + return true; + } + if (content.type === "code_execution_result") { + return typeof content.stdout === "string" && + typeof content.stderr === "string" && + isSafeInteger(content.return_code) && + hasValidAnthropicFileOutputs(content.content, "code_execution_output"); + } + if (content.type === "encrypted_code_execution_result") { + return typeof content.encrypted_stdout === "string" && + typeof content.stderr === "string" && + isSafeInteger(content.return_code) && + hasValidAnthropicFileOutputs(content.content, "code_execution_output"); + } + return false; +} + +function hasValidAnthropicBashCodeExecutionContent(content: Record): boolean { + if ( + hasValidAnthropicErrorContent( + content, + "bash_code_execution_tool_result_error", + BASH_CODE_EXECUTION_ERROR_CODES, + ) + ) { + return true; + } + return content.type === "bash_code_execution_result" && + typeof content.stdout === "string" && + typeof content.stderr === "string" && + isSafeInteger(content.return_code) && + hasValidAnthropicFileOutputs(content.content, "bash_code_execution_output"); +} + +function hasValidAnthropicTextEditorCodeExecutionContent( + content: Record, +): boolean { + if ( + hasValidAnthropicErrorContent( + content, + "text_editor_code_execution_tool_result_error", + TEXT_EDITOR_CODE_EXECUTION_ERROR_CODES, + ) + ) { + return !("error_message" in content) || + content.error_message === null || + typeof content.error_message === "string"; + } + if (content.type === "text_editor_code_execution_view_result") { + return typeof content.content === "string" && + (content.file_type === "text" || + content.file_type === "image" || + content.file_type === "pdf") && + "num_lines" in content && + isNullableNonNegativeSafeInteger(content.num_lines) && + "start_line" in content && + isNullableNonNegativeSafeInteger(content.start_line) && + "total_lines" in content && + isNullableNonNegativeSafeInteger(content.total_lines); + } + if (content.type === "text_editor_code_execution_create_result") { + return typeof content.is_file_update === "boolean"; + } + if (content.type === "text_editor_code_execution_str_replace_result") { + return "lines" in content && + (content.lines === null || + Array.isArray(content.lines) && + content.lines.every((line) => typeof line === "string")) && + "old_start" in content && + isNullableNonNegativeSafeInteger(content.old_start) && + "old_lines" in content && + isNullableNonNegativeSafeInteger(content.old_lines) && + "new_start" in content && + isNullableNonNegativeSafeInteger(content.new_start) && + "new_lines" in content && + isNullableNonNegativeSafeInteger(content.new_lines); + } + return false; +} + +function hasValidAnthropicWebSearchContent(content: unknown): boolean { + if (isRecord(content)) { + return hasValidAnthropicErrorContent( + content, + "web_search_tool_result_error", + WEB_SEARCH_ERROR_CODES, + ); + } + return Array.isArray(content) && + content.every((item) => + isRecord(item) && + item.type === "web_search_result" && + isNonEmptyString(item.url) && + typeof item.title === "string" && + typeof item.encrypted_content === "string" && + "page_age" in item && + (item.page_age === null || typeof item.page_age === "string") + ); +} + +function hasValidAnthropicWebFetchSource(value: unknown): boolean { + if (!isRecord(value) || typeof value.data !== "string") return false; + return value.type === "text" && value.media_type === "text/plain" || + value.type === "base64" && value.media_type === "application/pdf"; +} + +function hasValidAnthropicWebFetchContent(content: Record): boolean { + if ( + hasValidAnthropicErrorContent( + content, + "web_fetch_tool_result_error", + WEB_FETCH_ERROR_CODES, + ) + ) { + return true; + } + const document = isRecord(content.content) ? content.content : undefined; + const citations = document?.citations; + return content.type === "web_fetch_result" && + isNonEmptyString(content.url) && + "retrieved_at" in content && + (content.retrieved_at === null || typeof content.retrieved_at === "string") && + document?.type === "document" && + hasValidAnthropicWebFetchSource(document.source) && + (document.title === undefined || + document.title === null || + typeof document.title === "string") && + (citations === undefined || + citations === null || + (isRecord(citations) && typeof citations.enabled === "boolean")); +} + +function hasValidAnthropicProviderToolResultContentForTool( + block: Record, + toolName: string, +): boolean { + if (block.type === "mcp_tool_result") { + return hasValidAnthropicMcpContent(block.content); + } + if (toolName === "web_search") { + return block.type === "web_search_tool_result" && + hasValidAnthropicWebSearchContent(block.content); + } + const content = isRecord(block.content) ? block.content : undefined; + if (!content) return false; + switch (toolName) { + case "web_fetch": + return block.type === "web_fetch_tool_result" && + hasValidAnthropicWebFetchContent(content); + case "code_execution": + return block.type === "code_execution_tool_result" && + hasValidAnthropicCodeExecutionContent(content); + case "bash_code_execution": + return block.type === "bash_code_execution_tool_result" && + hasValidAnthropicBashCodeExecutionContent(content); + case "text_editor_code_execution": + return block.type === "text_editor_code_execution_tool_result" && + hasValidAnthropicTextEditorCodeExecutionContent(content); + default: + return false; + } +} + function expectedAnthropicResultTypeForTool(toolName: string): string | undefined { switch (toolName) { case "web_search": @@ -198,68 +468,62 @@ function expectedAnthropicResultTypeForTool(toolName: string): string | undefine } function assertAnthropicProviderToolResultsMatchUses( - checkpoint: ProviderReplayCheckpoint, + checkpoints: readonly ProviderReplayCheckpoint[], ): void { - const providerToolNames = new Map(); + const pendingProviderTools = new Map< + string, + { readonly name: string; readonly type: "server_tool_use" | "mcp_tool_use" } + >(); + const providerToolUseIds = new Set(); - for (const replayBlock of checkpoint.providerBlocks) { - const block = replayBlock.block; - if (block.type !== "server_tool_use" && block.type !== "mcp_tool_use") continue; - const toolUse = toCanonicalAnthropicToolCall(block, true); - const toolCallId = String(toolUse.toolCallId); - if (providerToolNames.has(toolCallId)) { - invalidCheckpoint("checkpoint provider tool-use id is duplicated"); - } - providerToolNames.set(toolCallId, String(toolUse.toolName)); - } - - for (const replayBlock of checkpoint.providerBlocks) { - const block = replayBlock.block; - if ( - typeof block.type !== "string" || - !ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES.has(block.type) - ) { - continue; - } - validateAnthropicProviderToolResultBlock(block); - const toolCallId = String(block.tool_use_id); - const toolName = providerToolNames.get(toolCallId); - if (!toolName) { - invalidCheckpoint("checkpoint provider tool-result has no matching provider tool-use"); - } - if (block.type === "mcp_tool_result") { - if (!hasValidAnthropicMcpContent(block.content)) { - invalidCheckpoint("checkpoint MCP tool-result content is malformed"); + for (const checkpoint of checkpoints) { + for (const replayBlock of checkpoint.providerBlocks) { + const block = replayBlock.block; + if (block.type === "server_tool_use" || block.type === "mcp_tool_use") { + const toolUse = toCanonicalAnthropicToolCall(block, true); + const toolCallId = String(toolUse.toolCallId); + if (providerToolUseIds.has(toolCallId)) { + invalidCheckpoint("checkpoint provider tool-use id is duplicated"); + } + providerToolUseIds.add(toolCallId); + pendingProviderTools.set(toolCallId, { + name: String(toolUse.toolName), + type: block.type, + }); + continue; } - continue; - } - const expectedType = expectedAnthropicResultTypeForTool(toolName); - if (block.type !== expectedType) { - invalidCheckpoint("checkpoint provider tool-result type does not match its tool-use"); - } - const content = isRecord(block.content) ? block.content : undefined; - if ( - (block.type === "web_search_tool_result" && isRecord(block.content) && - !hasValidAnthropicErrorContent(block.content, "web_search_tool_result_error")) || - (block.type === "web_fetch_tool_result" && content?.type !== "web_fetch_result" && - !hasValidAnthropicErrorContent(content ?? {}, "web_fetch_tool_result_error")) || - (block.type === "code_execution_tool_result" && - content?.type !== "code_execution_result" && - content?.type !== "encrypted_code_execution_result" && - !hasValidAnthropicErrorContent(content ?? {}, "code_execution_tool_result_error")) || - (block.type === "bash_code_execution_tool_result" && - content?.type !== "bash_code_execution_result" && - !hasValidAnthropicErrorContent(content ?? {}, "bash_code_execution_tool_result_error")) || - (block.type === "text_editor_code_execution_tool_result" && - content?.type !== "text_editor_code_execution_view_result" && - content?.type !== "text_editor_code_execution_create_result" && - content?.type !== "text_editor_code_execution_str_replace_result" && - !hasValidAnthropicErrorContent( - content ?? {}, - "text_editor_code_execution_tool_result_error", - )) - ) { - invalidCheckpoint("checkpoint provider tool-result content is malformed"); + if ( + !isAnthropicProviderToolResultBlock(block) + ) { + continue; + } + validateAnthropicProviderToolResultBlock(block); + const toolCallId = String(block.tool_use_id); + const pendingProviderTool = pendingProviderTools.get(toolCallId); + if (!pendingProviderTool) { + invalidCheckpoint( + "checkpoint provider tool-result has no matching preceding provider tool-use", + ); + } + if (block.type === "mcp_tool_result") { + if (pendingProviderTool.type !== "mcp_tool_use") { + invalidCheckpoint("checkpoint MCP tool-result type does not match its tool-use"); + } + if (!hasValidAnthropicMcpContent(block.content)) { + invalidCheckpoint("checkpoint MCP tool-result content is malformed"); + } + pendingProviderTools.delete(toolCallId); + continue; + } + const toolName = pendingProviderTool.name; + const expectedType = expectedAnthropicResultTypeForTool(toolName); + if (block.type !== expectedType) { + invalidCheckpoint("checkpoint provider tool-result type does not match its tool-use"); + } + if (!hasValidAnthropicProviderToolResultContentForTool(block, toolName)) { + invalidCheckpoint("checkpoint provider tool-result content is malformed"); + } + pendingProviderTools.delete(toolCallId); } } } @@ -267,23 +531,17 @@ function assertAnthropicProviderToolResultsMatchUses( function toTranscriptVisibleAnthropicReplayPart( block: Record, ): Record | undefined { + validateAnthropicReplayBlock(block); switch (block.type) { case "text": { - if (typeof block.text !== "string") { - invalidCheckpoint("checkpoint text block is malformed"); - } return { type: "text", text: block.text }; } case "thinking": { - validateAnthropicThinkingReplayBlock(block); return isNonEmptyString(block.thinking) ? { type: "reasoning", text: block.thinking } : undefined; } case "redacted_thinking": - if (!isNonEmptyString(block.data)) { - invalidCheckpoint("checkpoint redacted thinking block is malformed"); - } return undefined; case "tool_use": return toCanonicalAnthropicToolCall(block, false); @@ -291,11 +549,7 @@ function toTranscriptVisibleAnthropicReplayPart( case "mcp_tool_use": return toCanonicalAnthropicToolCall(block, true); default: { - if ( - typeof block.type === "string" && - ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES.has(block.type) - ) { - validateAnthropicProviderToolResultBlock(block); + if (isAnthropicProviderToolResultBlock(block)) { return { type: "tool-result", toolCallId: block.tool_use_id, @@ -337,7 +591,7 @@ function toTranscriptVisibleProviderPart( type: "tool-call", toolCallId: part.toolCallId, toolName: part.toolName, - input: part.input, + input: part.input ?? part.args, ...(typeof part.toolCallId === "string" && providerExecutedToolCallIds.has(part.toolCallId) ? { providerExecuted: true } @@ -383,23 +637,94 @@ function normalizeTranscriptVisibleProjection( return normalized; } +function projectCheckpointVisibleParts( + checkpoint: ProviderReplayCheckpoint, +): Record[] { + return checkpoint.providerBlocks.flatMap((block) => { + const part = toTranscriptVisibleAnthropicReplayPart(block.block); + return part ? [part] : []; + }); +} + +function getProviderExecutedToolCallIdsFromMessages( + messages: readonly Message[], +): Set { + const ids = new Set(); + for (const message of messages) { + for (const id of getProviderExecutedToolCallIds(message)) { + ids.add(id); + } + } + return ids; +} + +function projectProviderToolResults( + messages: readonly Message[], + providerExecutedToolCallIds: ReadonlySet, +): Record[] { + return messages.flatMap((message) => + message.parts.flatMap((part) => { + const value: unknown = part; + if (!isRecord(value) || value.type !== "tool-result") return []; + const projected = toTranscriptVisibleProviderPart(value, providerExecutedToolCallIds); + return projected?.providerExecuted === true ? [projected] : []; + }) + ); +} + +function assertCheckpointMatchesProjection( + checkpoint: ProviderReplayCheckpoint, + targetProjection: readonly Record[], + targetProviderToolResults: readonly Record[], +): void { + const checkpointProjection = projectCheckpointVisibleParts(checkpoint); + const checkpointProviderToolResults = checkpointProjection.filter((part) => + part.type === "tool-result" + ); + const checkpointVisibleProjection = normalizeTranscriptVisibleProjection( + checkpointProjection.filter((part) => part.type !== "tool-result"), + ); + const normalizedTargetProjection = normalizeTranscriptVisibleProjection(targetProjection); + if ( + stringifyChatJson(checkpointVisibleProjection) !== + stringifyChatJson(normalizedTargetProjection) || + stringifyChatJson(checkpointProviderToolResults) !== + stringifyChatJson(targetProviderToolResults) + ) { + invalidCheckpoint("checkpoint provider blocks do not match the anchored assistant turn"); + } +} + function assertCheckpointMatchesAssistantTurn( target: Message, checkpoint: ProviderReplayCheckpoint, toolSiblings: readonly Message[] = [], + providerExecutedToolCallIds = getProviderExecutedToolCallIds(target), ): void { const providerProjection = convertAgentRuntimeMessagesToProviderMessages([target]) .filter((message) => message.role === "assistant"); if (providerProjection.length > 1) { - invalidCheckpoint("checkpoint anchor projects to more than one assistant message", { - assistantSegmentCount: providerProjection.length, + if (checkpoint.providerMessageBlockCounts?.length !== providerProjection.length) { + invalidCheckpoint("checkpoint anchor projects to more than one assistant message", { + assistantSegmentCount: providerProjection.length, + }); + } + const splitTargetProjection = providerProjection.flatMap((message) => { + if (!Array.isArray(message.content)) { + invalidCheckpoint("checkpoint anchor does not carry structured assistant content"); + } + return message.content.flatMap((part) => { + const projected = toTranscriptVisibleProviderPart(part, providerExecutedToolCallIds); + return projected ? [projected] : []; + }); }); + assertCheckpointMatchesProjection( + checkpoint, + splitTargetProjection, + projectProviderToolResults([target, ...toolSiblings], providerExecutedToolCallIds), + ); + return; } - const checkpointProjection = checkpoint.providerBlocks.flatMap((block) => { - const part = toTranscriptVisibleAnthropicReplayPart(block.block); - return part ? [part] : []; - }); - const providerExecutedToolCallIds = getProviderExecutedToolCallIds(target); const targetContent = providerProjection[0]?.content ?? []; if (!Array.isArray(targetContent)) { invalidCheckpoint("checkpoint anchor does not carry structured assistant content"); @@ -408,29 +733,113 @@ function assertCheckpointMatchesAssistantTurn( const projected = toTranscriptVisibleProviderPart(part, providerExecutedToolCallIds); return projected ? [projected] : []; }); - const checkpointProviderToolResults = checkpointProjection.filter((part) => - part.type === "tool-result" + assertCheckpointMatchesProjection( + checkpoint, + targetProjection, + projectProviderToolResults([target, ...toolSiblings], providerExecutedToolCallIds), ); - const checkpointVisibleProjection = normalizeTranscriptVisibleProjection( - checkpointProjection.filter((part) => part.type !== "tool-result"), +} + +function createCheckpointForRawBlocks( + source: ProviderReplayCheckpoint, + rawBlocks: readonly Record[], +): ProviderReplayCheckpoint { + return { + ...source, + providerBlocks: rawBlocks.map((block) => ({ + type: "provider-block", + provider: source.provider, + block, + })), + providerBlockPositions: rawBlocks.map((_, index) => index), + totalPartCount: rawBlocks.length, + }; +} + +function getRawAssistantMessagesForCheckpoint( + checkpoint: ProviderReplayCheckpoint, +): Record[][] { + if (checkpoint.providerMessageBlockCounts === undefined) { + return [checkpoint.providerBlocks.map((block) => block.block)]; + } + const rawAssistantMessages: Record[][] = []; + let offset = 0; + for (const count of checkpoint.providerMessageBlockCounts) { + rawAssistantMessages.push( + checkpoint.providerBlocks.slice(offset, offset + count).map((block) => block.block), + ); + offset += count; + } + return rawAssistantMessages; +} + +function splitAnthropicAssistantReplayBlocks( + checkpoint: ProviderReplayCheckpoint, +): Record[][] { + const segments: Record[][] = []; + let current: Record[] = []; + for (const replayBlock of checkpoint.providerBlocks) { + if (isAnthropicProviderToolResultBlock(replayBlock.block)) { + if (current.length > 0) { + segments.push(current); + current = []; + } + current.push(replayBlock.block); + continue; + } + current.push(replayBlock.block); + } + if (current.length > 0) { + segments.push(current); + } + return segments; +} + +function assertCheckpointMatchesSplitAssistantTurns( + sameSourceMessages: readonly Message[], + assistantMatches: readonly Message[], + checkpoint: ProviderReplayCheckpoint, +): Record[][] { + const providerExecutedToolCallIds = getProviderExecutedToolCallIdsFromMessages( + sameSourceMessages, ); - const normalizedTargetProjection = normalizeTranscriptVisibleProjection(targetProjection); - const targetProviderToolResults = [target, ...toolSiblings].flatMap((message) => + const targetProjection = assistantMatches.flatMap((message) => message.parts.flatMap((part) => { - const value: unknown = part; - if (!isRecord(value) || value.type !== "tool-result") return []; - const projected = toTranscriptVisibleProviderPart(value, providerExecutedToolCallIds); - return projected?.providerExecuted === true ? [projected] : []; + const projected = toTranscriptVisibleProviderPart(part, providerExecutedToolCallIds); + return projected ? [projected] : []; }) ); - if ( - stringifyChatJson(checkpointVisibleProjection) !== - stringifyChatJson(normalizedTargetProjection) || - stringifyChatJson(checkpointProviderToolResults) !== - stringifyChatJson(targetProviderToolResults) - ) { - invalidCheckpoint("checkpoint provider blocks do not match the anchored assistant turn"); + assertCheckpointMatchesProjection( + checkpoint, + targetProjection, + projectProviderToolResults(sameSourceMessages, providerExecutedToolCallIds), + ); + + const rawSegments = splitAnthropicAssistantReplayBlocks(checkpoint); + if (rawSegments.length !== assistantMatches.length) { + invalidCheckpoint("checkpoint split assistant segment count does not match its anchor"); + } + for (const [index, rawSegment] of rawSegments.entries()) { + const rawSegmentProjection = projectCheckpointVisibleParts( + createCheckpointForRawBlocks(checkpoint, rawSegment), + ); + const rawSegmentAssistantProjection = normalizeTranscriptVisibleProjection( + rawSegmentProjection.filter((part) => part.type !== "tool-result"), + ); + const assistantProjection = normalizeTranscriptVisibleProjection( + assistantMatches[index]!.parts.flatMap((part) => { + const projected = toTranscriptVisibleProviderPart(part, providerExecutedToolCallIds); + return projected ? [projected] : []; + }), + ); + if ( + stringifyChatJson(rawSegmentAssistantProjection) !== + stringifyChatJson(assistantProjection) + ) { + invalidCheckpoint("checkpoint split assistant segment does not match its anchor"); + } } + return rawSegments; } function parseProviderReplayBlock( @@ -462,11 +871,13 @@ function parseProviderReplayBlock( } if ( provider === "anthropic" && - typeof value.block.type === "string" && - ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES.has(value.block.type) + isAnthropicProviderToolResultBlock(value.block) ) { validateAnthropicProviderToolResultBlock(value.block, { index }); } + if (provider === "anthropic") { + validateAnthropicReplayBlock(value.block, { index }); + } return { type: "provider-block", provider, block: value.block }; } @@ -544,6 +955,33 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe } positions.push(position); } + let providerMessageBlockCounts: number[] | undefined; + if (value.providerMessageBlockCounts !== undefined) { + if ( + !Array.isArray(value.providerMessageBlockCounts) || + value.providerMessageBlockCounts.length === 0 + ) { + invalidCheckpoint("checkpoint providerMessageBlockCounts must be a non-empty array"); + } + providerMessageBlockCounts = []; + let groupedBlockCount = 0; + for (const [index, count] of value.providerMessageBlockCounts.entries()) { + if ( + typeof count !== "number" || + !Number.isSafeInteger(count) || + count <= 0 + ) { + invalidCheckpoint("checkpoint providerMessageBlockCounts entries must be positive", { + index, + }); + } + groupedBlockCount += count; + providerMessageBlockCounts.push(count); + } + if (groupedBlockCount !== providerBlocks.length) { + invalidCheckpoint("checkpoint providerMessageBlockCounts must cover every block"); + } + } if ( value.elapsedMs !== undefined && (typeof value.elapsedMs !== "number" || !Number.isFinite(value.elapsedMs) || @@ -564,6 +1002,7 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe provider, providerBlocks, providerBlockPositions: positions, + ...(providerMessageBlockCounts ? { providerMessageBlockCounts } : {}), totalPartCount: value.totalPartCount, ...(value.elapsedMs !== undefined ? { elapsedMs: value.elapsedMs } : {}), ...(value.emittedAt !== undefined ? { emittedAt: value.emittedAt } : {}), @@ -647,6 +1086,11 @@ export function applyProviderReplayCheckpointsToMessages( options: ApplyProviderReplayCheckpointsOptions = {}, ): void { if (checkpoints === undefined || checkpoints.length === 0) return; + const attachmentPlan: Array<{ + target: Message; + rawAssistantMessages: Record[][]; + }> = []; + const providerExecutedToolCallIds = getProviderExecutedToolCallIdsFromMessages(messages); // Runtime support is a property of the delivery, not of which turns are // still in context: an unsupported checkpoint fails the run even when its // turn is absent, so deployment skew surfaces immediately. @@ -661,15 +1105,13 @@ export function applyProviderReplayCheckpointsToMessages( activeProvider: options.activeProvider, }); } - assertAnthropicProviderToolResultsMatchUses(checkpoint); } + assertAnthropicProviderToolResultsMatchUses(checkpoints); for (const checkpoint of checkpoints) { const matches = messages.filter((message) => message.id === checkpoint.messageId); if (matches.length === 0) continue; const assistantMatches = matches.filter((message) => message.role === "assistant"); - if (assistantMatches.length > 1) { - invalidCheckpoint("checkpoint messageId matches more than one assistant message"); - } + const toolSiblings = matches.filter((message) => message.role === "tool"); const target = assistantMatches[0]; if (!target) { const role = matches[0]?.role; @@ -677,17 +1119,35 @@ export function applyProviderReplayCheckpointsToMessages( ...(role ? { role } : {}), }); } - assertCheckpointMatchesAssistantTurn( - target, + if (assistantMatches.length === 1) { + assertCheckpointMatchesAssistantTurn( + target, + checkpoint, + toolSiblings, + providerExecutedToolCallIds, + ); + attachmentPlan.push({ + target, + rawAssistantMessages: getRawAssistantMessagesForCheckpoint(checkpoint), + }); + continue; + } + const rawSegments = assertCheckpointMatchesSplitAssistantTurns( + matches, + assistantMatches, checkpoint, - matches.filter((message) => message.role === "tool"), ); + for (const [index, rawBlocks] of rawSegments.entries()) { + attachmentPlan.push({ target: assistantMatches[index]!, rawAssistantMessages: [rawBlocks] }); + } + } + for (const { target, rawAssistantMessages } of attachmentPlan) { // In-process metadata attached during this run is the same replay state at // first hand; the durable checkpoint never overrides it. if (readAttachedProviderMetadata(target) !== undefined) continue; attachProviderMetadata(target, { anthropic: { - rawAssistantMessages: [checkpoint.providerBlocks.map((block) => block.block)], + rawAssistantMessages, }, }); } diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index c00c79ca7f..f940aceaa3 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -372,6 +372,25 @@ function hasProviderSendableAssistantContent(message: Message): boolean { }); } +function createSplitAnthropicRawAssistantMetadata( + providerMetadata: Record, + segmentCount: number, +): Record[] | undefined { + const anthropic = providerMetadata.anthropic; + if (!isRecord(anthropic)) return undefined; + const rawAssistantMessages = anthropic.rawAssistantMessages; + if (!Array.isArray(rawAssistantMessages) || rawAssistantMessages.length !== segmentCount) { + return undefined; + } + return rawAssistantMessages.map((rawAssistantMessage) => ({ + ...providerMetadata, + anthropic: { + ...anthropic, + rawAssistantMessages: [rawAssistantMessage], + }, + })); +} + function convertAssistantMessageToTextGenerationRuntimeMessages( message: Message, providerExecutedToolCallIds: Set, @@ -488,6 +507,16 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( providerMetadata, }); } else if (providerMetadata !== undefined) { + const splitMetadata = createSplitAnthropicRawAssistantMetadata( + providerMetadata, + assistantMessages.length, + ); + if (splitMetadata !== undefined) { + for (const [index, assistantMessage] of assistantMessages.entries()) { + assistantMessage.providerMetadata = splitMetadata[index]; + } + return messages; + } throw PROVIDER_METADATA_SPLIT_UNSUPPORTED.create({ detail: "provider metadata cannot be attached after assistant turn splitting", context: { assistantSegmentCount: assistantMessages.length }, diff --git a/src/workflow/react/use-workflow-start.test.tsx b/src/workflow/react/use-workflow-start.test.tsx index 10eebb62a5..48f8aacdd7 100644 --- a/src/workflow/react/use-workflow-start.test.tsx +++ b/src/workflow/react/use-workflow-start.test.tsx @@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client"; import { JSDOM } from "npm:jsdom@28.0.0"; import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { waitFor } from "#veryfront/testing/deno-compat.ts"; import { installMockFetch, restoreMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { useApproval, type UseApprovalResult } from "./use-approval.ts"; import { useWorkflow, type UseWorkflowResult } from "./use-workflow.ts"; @@ -126,7 +127,9 @@ describe("useWorkflowStart", () => { secondResponse.resolve(Response.json({ runId: "run-second" })); assertEquals(await secondStart, "run-second"); - await new Promise((resolve) => setTimeout(resolve, 20)); + await waitFor(() => hook!.isStarting === false, { + message: "second start should leave the hook idle", + }); assertEquals(hook!.isStarting, false); assertEquals(hook!.lastRunId, "run-second"); assertEquals(startedRunIds, ["run-first", "run-second"]); From a8185f7443e72203dc0f4e7c0e9bb5852c58f0ad Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 16:47:26 +0200 Subject: [PATCH 37/56] fix(agent): validate provider result caller and raw message group count Reject a malformed `caller` on web_search/web_fetch replay results and cap providerMessageBlockCounts at the Anthropic raw-assistant-message limit, so both fail at the typed checkpoint boundary instead of as a plain TypeError during request construction. --- docs/api-reference/veryfront/agent.md | 147 +++++++++++----------- src/agent/runtime/provider-replay.test.ts | 40 ++++++ src/agent/runtime/provider-replay.ts | 16 ++- 3 files changed, 128 insertions(+), 75 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 77920db335..67e96a665d 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -226,30 +226,31 @@ Exchange this run's authority for an exact direct-child capability. Request payload for parsed hosted chat. -| Property | Type | Description | Source | -| ------------------------------------------ | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `agentId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L71) | -| `userId` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L72) | -| `authToken` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L73) | -| `serverEnvelopeVerified?` | `true` | True only after a server envelope credential is verified and bound to this run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L75) | -| `serverResolvedIntegrationToolNames?` | `readonly string[]` | Integration tools the control plane resolved for this run, taken from the verified run-event token rather than the request body. Absent unless a token verified, so a forged body can never introduce it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L81) | -| `messages` | `ChatUiMessage[]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L82) | -| `validatedContext` | `ChatRequestContext` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L83) | -| `projectId` | `string \| null` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L84) | -| `projectSlug?` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L85) | -| `conversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L86) | -| `parentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L87) | -| `upstreamParentConversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L88) | -| `upstreamParentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L89) | -| `spawnedFromToolCallId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L90) | -| `taskId?` | `string` | Durable task identity supplied only by a signed runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L92) | -| `model` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L93) | -| `allowDelegation` | `boolean \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L94) | -| `forwardedProps` | `HostedChatRequest["forwardedProps"]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L95) | -| `runtimeOverrides` | `ChatRuntimeOverrides \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L96) | -| `durableRootRun` | `DurableRootRunDescriptor \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L97) | -| `persistLatestUserMessageBeforeDurableRun` | `boolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L98) | -| `agentConfig?` | `RuntimeAgentMarkdownDefinition` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L99) | +| Property | Type | Description | Source | +| ------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| `agentId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L71) | +| `userId` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L72) | +| `authToken` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L73) | +| `serverEnvelopeVerified?` | `true` | True only after a server envelope credential is verified and bound to this run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L75) | +| `serverResolvedProviderReplayCheckpoints?` | `unknown` | Provider-native replay state resolved by the server outside forwardedProps so large opaque provider blocks do not consume the public forwardedProps budget. Ignored unless `serverEnvelopeVerified` is true. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L81) | +| `serverResolvedIntegrationToolNames?` | `readonly string[]` | Integration tools the control plane resolved for this run, taken from the verified run-event token rather than the request body. Absent unless a token verified, so a forged body can never introduce it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L87) | +| `messages` | `ChatUiMessage[]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L88) | +| `validatedContext` | `ChatRequestContext` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L89) | +| `projectId` | `string \| null` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L90) | +| `projectSlug?` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L91) | +| `conversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L92) | +| `parentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L93) | +| `upstreamParentConversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L94) | +| `upstreamParentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L95) | +| `spawnedFromToolCallId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L96) | +| `taskId?` | `string` | Durable task identity supplied only by a signed runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L98) | +| `model` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L99) | +| `allowDelegation` | `boolean \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L100) | +| `forwardedProps` | `HostedChatRequest["forwardedProps"]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L101) | +| `runtimeOverrides` | `ChatRuntimeOverrides \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L102) | +| `durableRootRun` | `DurableRootRunDescriptor \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L103) | +| `persistLatestUserMessageBeforeDurableRun` | `boolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L104) | +| `agentConfig?` | `RuntimeAgentMarkdownDefinition` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L105) | ### `PrepareHostedConversationRootRunContextInput` @@ -442,7 +443,7 @@ Input delivered to a hosted agent-service detached execution callback. | `AGENT_DELEGATE_TOOL_PREFIX` | Prefix used for the delegate tool exposed to the coordinator agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-delegation-names.ts#L4) | | `AgUiDetachedStartAcceptedSchema` | Schema for AG-UI detached start accepted. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/detached-start.ts#L103) | | `AgUiDetachedStartRequestSchema` | Schema for AG-UI detached start request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/detached-start.ts#L99) | -| `AgUiRequestSchema` | Schema for AG-UI request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L121) | +| `AgUiRequestSchema` | Schema for AG-UI request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L122) | | `AgUiResumeSignalSchema` | Schema for AG-UI resume signal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/run-control.ts#L33) | | `AppendConversationRunEventsResponseSchema` | Schema for append conversation run events response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-contracts.ts#L405) | | `CompleteConversationRunResponseSchema` | Schema for complete conversation run response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-contracts.ts#L341) | @@ -499,7 +500,7 @@ Input delivered to a hosted agent-service detached execution callback. | `RuntimeAgentProjectContextSchema` | Schema for runtime agent project context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L260) | | `RuntimeAgentRunContextSchema` | Schema for runtime agent run context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L343) | | `RuntimeAgentRunIdSchema` | Schema for runtime agent run ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L32) | -| `RuntimeAgentRunInvocationSchema` | Schema for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L394) | +| `RuntimeAgentRunInvocationSchema` | Schema for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L395) | | `RuntimeAgentServiceIdSchema` | Schema for runtime agent service ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L56) | | `RuntimeAgentSourceContextSchema` | Schema for runtime agent source context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L153) | | `RuntimeAgentTargetKindSchema` | Schema for runtime agent target kind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L162) | @@ -555,9 +556,9 @@ Input delivered to a hosted agent-service detached execution callback. | `buildFinalizedMessageFallbackChunks` | Builds finalized message fallback chunks. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/finalized-message.ts#L126) | | `buildFinalizedMessageState` | State for build finalized message. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/finalized-message.ts#L70) | | `buildForkRuntimeStepFromResponse` | Build a fork runtime step from an agent response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-step-progress.ts#L12) | -| `buildHostedChatRequestForwardedPropsFromRuntimeAgentInvocation` | Builds hosted chat request forwarded props from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L557) | -| `buildHostedChatRequestFromRuntimeAgentInvocation` | Builds hosted chat request from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L608) | -| `buildHostedChatRequestInputFromRuntimeAgentInvocation` | Builds hosted chat request input from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L576) | +| `buildHostedChatRequestForwardedPropsFromRuntimeAgentInvocation` | Builds hosted chat request forwarded props from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L559) | +| `buildHostedChatRequestFromRuntimeAgentInvocation` | Builds hosted chat request from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L613) | +| `buildHostedChatRequestInputFromRuntimeAgentInvocation` | Builds hosted chat request input from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L578) | | `buildHostedChildCompletedLog` | Builds hosted child completed log. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-execution-logging.ts#L69) | | `buildHostedChildConversationBody` | Builds hosted child conversation body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-bootstrap.ts#L35) | | `buildHostedChildErrorLog` | Builds hosted child error log. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-execution-logging.ts#L89) | @@ -574,9 +575,9 @@ Input delivered to a hosted agent-service detached execution callback. | `buildInvokeAgentFollowupInstruction` | Builds invoke agent followup instruction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L100) | | `buildInvokeAgentTraceAttributes` | Builds invoke agent trace attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L235) | | `buildParsedAgentServiceAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L544) | +| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L564) | | `buildParsedHostedAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L544) | +| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L564) | | `buildProjectContextPromptBlock` | Builds the shared project-context prompt block (project reference + branch). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L103) | | `buildProjectInstructionsPromptBlock` | Builds the project-instructions prompt block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L120) | | `buildProjectServiceTraceAttributes` | Builds Datadog unified service trace attributes for a hosted project run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L93) | @@ -585,7 +586,7 @@ Input delivered to a hosted agent-service detached execution callback. | `buildRootOwnedChildRunResultHint` | Builds root owned child run result hint. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/result-summary.ts#L348) | | `buildRootOwnedChildRunResultText` | Builds root owned child run result text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/result-summary.ts#L333) | | `buildRootOwnedDelegatedFindingsInstruction` | Builds root owned delegated findings instruction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L34) | -| `buildRuntimeAgentControlPlaneStreamRequestFromInvocation` | Builds runtime agent control plane stream request from invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L448) | +| `buildRuntimeAgentControlPlaneStreamRequestFromInvocation` | Builds runtime agent control plane stream request from invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L452) | | `buildRuntimeAvailableSkillsPromptBlock` | Builds a bounded, injection-safe runtime available-skills prompt. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-prompt.ts#L457) | | `buildRuntimeLoadedSkillResponse` | Build a bounded loaded-skill response and fail closed on invalid metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1097) | | `buildRuntimeSkillDefinition` | Build a bounded, immutable runtime skill definition. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L913) | @@ -630,14 +631,14 @@ Input delivered to a hosted agent-service detached execution callback. | `createAgUiHandler` | Handler for create AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L527) | | `createAgUiResponseStream` | Create AG-UI response stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/response-stream.ts#L64) | | `createAgUiResumeHandler` | Handler for create AG-UI resume. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/run-control.ts#L78) | -| `createAgUiRunErrorEvent` | Event emitted for create AG-UI run error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L429) | +| `createAgUiRunErrorEvent` | Event emitted for create AG-UI run error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L430) | | `createAgUiRuntimeChatStreamEncoder` | Create AG-UI runtime chat stream encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-chat-stream-encoder.ts#L254) | | `createAgUiRuntimeContextMap` | Create AG-UI runtime context map. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L12) | | `createAgUiRuntimeEventEncoder` | Create AG-UI runtime event encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-event-encoder.ts#L32) | | `createAgUiRuntimeHandler` | Handler for create AG-UI runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-handler.ts#L406) | | `createAgUiRuntimeResponse` | Response payload for create AG-UI runtime response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-response.ts#L29) | -| `createAgUiSseErrorResponse` | Response payload for create AG-UI sse error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L440) | -| `createAgUiSseResponse` | Response payload for create AG-UI sse. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L453) | +| `createAgUiSseErrorResponse` | Response payload for create AG-UI sse error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L441) | +| `createAgUiSseResponse` | Response payload for create AG-UI sse. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L454) | | `createAgUiTrackedResponse` | Response payload for create AG-UI tracked response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/tracked-response.ts#L26) | | `createBootstrappedHostedChatExecutionRuntime` | Create bootstrapped hosted chat execution runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L442) | | `createChatUiMessageStreamFromDataStream` | Create chat UI message stream from data stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/chat-ui-message-stream.ts#L608) | @@ -789,8 +790,8 @@ Input delivered to a hosted agent-service detached execution callback. | `getConversationRunEventJsonByteLength` | Return conversation run event JSON byte length. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-event-normalization.ts#L27) | | `getEmptyHostedFinalizedMessageTerminalError` | Error shape for get empty hosted finalized message terminal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-terminal-error.ts#L113) | | `getForkRuntimeAllowedToolNames` | Return fork runtime allowed tool names. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-native-tool-inventory.ts#L90) | -| `getForwardedHostedModelId` | Return forwarded hosted model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L119) | -| `getForwardedHostedRuntimeOverrides` | Return forwarded hosted runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L129) | +| `getForwardedHostedModelId` | Return forwarded hosted model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L148) | +| `getForwardedHostedRuntimeOverrides` | Return forwarded hosted runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L158) | | `getHostedChildWrittenArtifactPath` | Return hosted child written artifact path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-artifact-support.ts#L112) | | `getHostedMirroredAbortErrorText` | Return hosted mirrored abort error text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L163) | | `getHostedServiceTokenFromRequest` | Request payload for get hosted service token from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L234) | @@ -852,9 +853,9 @@ Input delivered to a hosted agent-service detached execution callback. | `mirrorDefaultResearchRunArtifact` | Mirror default research run artifact helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/default-research-artifact-support.ts#L299) | | `monitorConversationRunStatus` | Monitor conversation run status helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable.ts#L1130) | | `monitorHostedChildRunStatus` | Monitor hosted child run status helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-status.ts#L149) | -| `normalizeAgUiMessages` | Normalizes AG-UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L385) | +| `normalizeAgUiMessages` | Normalizes AG-UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L386) | | `normalizeAgUiRuntimeMessages` | Normalizes AG-UI runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-support.ts#L44) | -| `normalizeAgUiRuntimeRequest` | Request payload for normalize AG-UI runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L225) | +| `normalizeAgUiRuntimeRequest` | Request payload for normalize AG-UI runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L226) | | `normalizeChatMessageMetadata` | Normalizes chat message metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/chat-ui-message-helpers.ts#L282) | | `normalizeChatUiMessageChunk` | Normalizes chat UI message chunk. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/chat-ui-message-helpers.ts#L349) | | `normalizeChatUiMessageChunkToAgUiRuntimeEvent` | Event emitted for normalize chat UI message chunk to AG-UI runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/chat-ui-chunk-encoder.ts#L224) | @@ -866,27 +867,27 @@ Input delivered to a hosted agent-service detached execution callback. | `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeRuntimeSkillReferencePath` | Normalizes and bounds a portable runtime skill reference path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1011) | -| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L551) | +| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L571) | | `parseAgentServiceConfig` | Configuration used by parse agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L159) | | `parseAgUiContextBoolean` | Parses AG-UI context boolean. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L56) | | `parseAgUiContextJsonValue` | Parses AG-UI context JSON value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L27) | | `parseAgUiContextNullableString` | Parses AG-UI context nullable string. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L46) | | `parseAgUiContextSchema` | Zod schema for parse AG-UI context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L62) | | `parseAgUiContextString` | Parses AG-UI context string. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L40) | -| `parseAgUiRequest` | Request payload for parse AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L370) | -| `parseAgUiRequestOrError` | Error shape for parse AG-UI request or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L375) | -| `parseAgUiRuntimeRequest` | Request payload for parse AG-UI runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L249) | -| `parseAgUiRuntimeRequestOrError` | Error shape for parse AG-UI runtime request or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L254) | +| `parseAgUiRequest` | Request payload for parse AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L371) | +| `parseAgUiRequestOrError` | Error shape for parse AG-UI request or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L376) | +| `parseAgUiRuntimeRequest` | Request payload for parse AG-UI runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L250) | +| `parseAgUiRuntimeRequestOrError` | Error shape for parse AG-UI runtime request or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L255) | | `parseAgUiSseResponse` | Parse an AG-UI SSE `Response` into normalized events, text, tool starts, and terminal error state. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/sse-parser.ts#L258) | | `parseAppendConversationRunEventsErrorBody` | Parses append conversation run events error body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L54) | | `parseDataStreamSseEvents` | Parses data stream sse events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/data-stream.ts#L14) | | `parseHostedAgentServiceConfig` | Configuration used by parse hosted agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L167) | -| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L551) | +| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L571) | | `parseRuntimeAgentMarkdownDefinition` | Definition for parse runtime agent markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L192) | -| `parseRuntimeAgentRunInvocation` | Parses runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L472) | -| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L590) | -| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L590) | -| `parseRuntimeAgentRunInvocationOrError` | Error shape for parse runtime agent run invocation or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L479) | +| `parseRuntimeAgentRunInvocation` | Parses runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L479) | +| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L610) | +| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L610) | +| `parseRuntimeAgentRunInvocationOrError` | Error shape for parse runtime agent run invocation or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L486) | | `parseRuntimeSkillDocument` | Parses a bounded runtime skill document and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L561) | | `parseRuntimeSkillMetadata` | Parses bounded runtime skill metadata and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L569) | | `parseToolInputObject` | Parses tool input object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-input.ts#L135) | @@ -937,8 +938,8 @@ Input delivered to a hosted agent-service detached execution callback. | `resolveHostedChildStreamWatchdogState` | State for resolve hosted child stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-stream-watchdog.ts#L33) | | `resolveHostedChildTerminalErrorCode` | Resolves a code is a hosted child terminal error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-status.ts#L91) | | `resolveHostedDurableRunSetupErrorResponse` | Response payload for resolve hosted durable run setup error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/durable-chat-run-start.ts#L122) | -| `resolveHostedRuntimeRequestConfig` | Configuration used by resolve hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L219) | -| `resolveHostedRuntimeThinkingOverride` | Resolves hosted runtime thinking override. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L157) | +| `resolveHostedRuntimeRequestConfig` | Configuration used by resolve hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L248) | +| `resolveHostedRuntimeThinkingOverride` | Resolves hosted runtime thinking override. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L186) | | `resolveNodeAgentServiceTelemetryConfig` | Configuration used by resolve node agent service telemetry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L379) | | `resolveNodeHostedAgentServiceTelemetryConfig` | Configuration used by resolve node hosted agent service telemetry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L315) | | `resolveRuntimeAgentDefinitionsDir` | Resolves runtime agent definitions dir. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition-files.ts#L96) | @@ -1176,7 +1177,7 @@ Input delivered to a hosted agent-service detached execution callback. | `AgUiChunkEncoder` | Public API contract for AG-UI chunk encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/chunk-encoder.ts#L15) | | `AgUiChunkEncoderBridge` | Public API contract for AG-UI chunk encoder bridge. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/chunk-encoder-bridge.ts#L13) | | `AgUiCompletion` | Payload handed to `AgUiHandlerOptions.onComplete` after an AG-UI run streams to completion successfully - the server-side counterpart to the client's `useConversationChat` persistence path. Lets an application persist the finalized conversation without reconstructing it from the SSE stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L57) | -| `AgUiContextItem` | Public API contract for AG-UI context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L126) | +| `AgUiContextItem` | Public API contract for AG-UI context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L127) | | `AgUiDetachedStartAccepted` | Public API contract for AG-UI detached start accepted. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/detached-start.ts#L110) | | `AgUiDetachedStartHandlerOptions` | Options accepted by AG-UI detached start handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/detached-start.ts#L226) | | `AgUiDetachedStartRequest` | Request payload for AG-UI detached start. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/detached-start.ts#L106) | @@ -1186,9 +1187,9 @@ Input delivered to a hosted agent-service detached execution callback. | `AgUiForwardedConfigOptions` | Options accepted by AG-UI forwarded config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L6) | | `AgUiHandlerConfigWithAgent` | Public API contract for AG-UI handler config with agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L514) | | `AgUiHandlerOptions` | Options accepted by AG-UI handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L499) | -| `AgUiInjectedTool` | Public API contract for AG-UI injected tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L124) | +| `AgUiInjectedTool` | Public API contract for AG-UI injected tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L125) | | `AgUiOnComplete` | Called once after a successful AG-UI run with the finalized conversation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L84) | -| `AgUiRequest` | Request payload for AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L128) | +| `AgUiRequest` | Request payload for AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L129) | | `AgUiResponseEncoder` | Public API contract for AG-UI response encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/response-stream.ts#L37) | | `AgUiResponseExecution` | Public API contract for AG-UI response execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/response-stream.ts#L30) | | `AgUiResponseRequestState` | State for AG-UI response request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/response-stream.ts#L22) | @@ -1198,17 +1199,17 @@ Input delivered to a hosted agent-service detached execution callback. | `AgUiRunFinishedMetadata` | Public API contract for AG-UI run finished metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/encoder.ts#L7) | | `AgUiRuntimeChatStreamEncoder` | Public API contract for AG-UI runtime chat stream encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-chat-stream-encoder.ts#L48) | | `AgUiRuntimeChatStreamEncoderState` | State for AG-UI runtime chat stream encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-chat-stream-encoder.ts#L41) | -| `AgUiRuntimeContextItem` | Public API contract for AG-UI runtime context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L216) | +| `AgUiRuntimeContextItem` | Public API contract for AG-UI runtime context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L217) | | `AgUiRuntimeEventEncoder` | Public API contract for AG-UI runtime event encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-event-encoder.ts#L14) | | `AgUiRuntimeHandlerConfig` | Configuration used by AG-UI runtime handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-handler.ts#L401) | | `AgUiRuntimeHandlerConfigWithAgent` | Public API contract for AG-UI runtime handler config with agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-handler.ts#L396) | | `AgUiRuntimeHandlerExecute` | Public API contract for AG-UI runtime handler execute. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-handler.ts#L357) | | `AgUiRuntimeHandlerExecuteInput` | Input payload for AG-UI runtime handler execute. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-handler.ts#L349) | | `AgUiRuntimeHandlerOptions` | Options accepted by AG-UI runtime handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-handler.ts#L382) | -| `AgUiRuntimeInjectedTool` | Public API contract for AG-UI runtime injected tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L212) | +| `AgUiRuntimeInjectedTool` | Public API contract for AG-UI runtime injected tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L213) | | `AgUiRuntimeLifecycleContext` | Context for AG-UI runtime lifecycle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-handler.ts#L35) | -| `AgUiRuntimeMessage` | Message shape for AG-UI runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L220) | -| `AgUiRuntimeRequest` | Request payload for AG-UI runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L222) | +| `AgUiRuntimeMessage` | Message shape for AG-UI runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L221) | +| `AgUiRuntimeRequest` | Request payload for AG-UI runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/ag-ui-contract.ts#L223) | | `AgUiRuntimeStreamEvent` | Event emitted for AG-UI runtime stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/encoder.ts#L4) | | `AgUiSseEvent` | Event emitted for AG-UI sse. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L18) | | `AgUiSseEventType` | Normalized AG-UI runtime event type value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/sse-parser.ts#L27) | @@ -1467,8 +1468,8 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedChatExecutionRuntimeLogger` | Public API contract for hosted chat execution runtime logger. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L84) | | `HostedChatProjectAccessError` | Error shape for hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L53) | | `HostedChatProjectAccessResult` | Result returned from hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L60) | -| `HostedChatRequest` | Request payload for hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L468) | -| `HostedChatRequestInput` | Input payload for hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L470) | +| `HostedChatRequest` | Request payload for hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L469) | +| `HostedChatRequestInput` | Input payload for hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L471) | | `HostedChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L47) | | `HostedChatRuntimeAgent` | Public API contract for hosted chat runtime agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L87) | | `HostedChatRuntimeAgentAdapterInput` | Input payload for hosted chat runtime agent adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-agent-adapter.ts#L25) | @@ -1679,7 +1680,7 @@ Input delivered to a hosted agent-service detached execution callback. | `NormalizedAgentServiceContract` | Public API contract for normalized agent service contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/definition.ts#L114) | | `NormalizedHostedChatRequest` | Request payload for normalized hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L57) | | `OpenToolCalls` | Public API contract for open tool calls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L110) | -| `ParseAgentServiceChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L103) | +| `ParseAgentServiceChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L109) | | `ParseAgUiSseResponseOptions` | Options for `parseAgUiSseResponse()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/sse-parser.ts#L50) | | `ParsedAgentServiceAgUiRequest` | Request payload for parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L59) | | `ParsedAgentServiceChatRequest` | Request payload for parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L70) | @@ -1687,9 +1688,9 @@ Input delivered to a hosted agent-service detached execution callback. | `ParsedHostedAgUiRequest` | Request payload for parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L59) | | `ParsedHostedChatRequest` | Request payload for parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L70) | | `ParsedRuntimeSkillDocument` | Public API contract for parsed runtime skill document. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L420) | -| `ParseHostedChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L103) | +| `ParseHostedChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L109) | | `ParseRuntimeAgentMarkdownDefinitionInput` | Input payload for parse runtime agent markdown definition. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L120) | -| `ParseRuntimeAgentRunInvocationHostedChatRequestOptions` | Options accepted when parsing a signed control-plane runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L117) | +| `ParseRuntimeAgentRunInvocationHostedChatRequestOptions` | Options accepted when parsing a signed control-plane runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L123) | | `PersistConversationUserMessageFailure` | Public API contract for persist conversation user message failure. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L70) | | `PrepareAgentRuntimeMessagesFromUiMessagesOptions` | Options accepted by prepare agent runtime messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-preparation.ts#L22) | | `PrepareAgentServiceChatRuntimeMessagesOptions` | Options accepted by prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L64) | @@ -1750,17 +1751,17 @@ Input delivered to a hosted agent-service detached execution callback. | `RunFrameworkForkStepInput` | Input payload for run framework fork step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-stream.ts#L279) | | `RunResumeSessionManagerOptions` | Options accepted by run resume session manager. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/resume-session.ts#L78) | | `RunSessionStatus` | Public API contract for run session status. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/resume-session.ts#L4) | -| `RuntimeAgentContextItem` | Public API contract for runtime agent context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L399) | -| `RuntimeAgentControlPlaneStreamRequest` | Request payload for runtime agent control plane stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L428) | +| `RuntimeAgentContextItem` | Public API contract for runtime agent context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L400) | +| `RuntimeAgentControlPlaneStreamRequest` | Request payload for runtime agent control plane stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L429) | | `RuntimeAgentMarkdownDefinition` | Definition for runtime agent markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L100) | -| `RuntimeAgentProjectContext` | Context for runtime agent project. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L411) | -| `RuntimeAgentRunContext` | Context for runtime agent run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L419) | -| `RuntimeAgentRunInvocation` | Public API contract for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L423) | -| `RuntimeAgentSourceContext` | Context for runtime agent source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L403) | -| `RuntimeAgentTargetKind` | Public API contract for runtime agent target kind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L407) | +| `RuntimeAgentProjectContext` | Context for runtime agent project. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L412) | +| `RuntimeAgentRunContext` | Context for runtime agent run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L420) | +| `RuntimeAgentRunInvocation` | Public API contract for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L424) | +| `RuntimeAgentSourceContext` | Context for runtime agent source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L404) | +| `RuntimeAgentTargetKind` | Public API contract for runtime agent target kind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L408) | | `RuntimeAgentThinkingConfig` | Configuration used by runtime agent thinking. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L25) | -| `RuntimeAgentTool` | Public API contract for runtime agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L397) | -| `RuntimeAgentValidatedClaims` | Public API contract for runtime agent validated claims. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L415) | +| `RuntimeAgentTool` | Public API contract for runtime agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L398) | +| `RuntimeAgentValidatedClaims` | Public API contract for runtime agent validated claims. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L416) | | `RuntimeBuiltinSkillEntriesResult` | Result returned from runtime builtin skill entries. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/builtin-skill-files.ts#L61) | | `RuntimeClientCapability` | Public API contract for runtime client capability. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/client-profile.ts#L48) | | `RuntimeClientProfile` | Public API contract for runtime client profile. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/client-profile.ts#L52) | @@ -1891,7 +1892,7 @@ Input delivered to a hosted agent-service detached execution callback. | `hostedAgentProjectSteeringOptionsSchema` | Zod schema for hosted agent project steering options. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/agent-project-steering.ts#L35) | | `hostedAgentServiceConfigSchema` | Zod schema for hosted agent service config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L152) | | `hostedAgUiChatForwardedConfigSchema` | Schema for agent service AG-UI chat forwarded config. Schema for hosted AG-UI chat forwarded config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L39) | -| `hostedChatRequestSchema` | Schema for hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L465) | +| `hostedChatRequestSchema` | Schema for hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L466) | | `hostedChatRuntimeOverridesSchema` | Schema for hosted chat runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L47) | | `hostedChildForkToolInputSchema` | Schema for hosted child fork tool input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-tool-input.ts#L70) | | `hostedChildTerminalErrorCodes` | Shared hosted child terminal error codes value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-status.ts#L16) | diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 5e74cb3dcb..1de9416e1b 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -339,6 +339,46 @@ describe("agent/runtime/provider-replay", () => { } }); + it("should reject a malformed caller on a web provider result", () => { + assertProviderReplayError(() => + parseProviderReplayCheckpoint({ + version: 1, + messageId: "assistant-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { + type: "web_search_tool_result", + tool_use_id: "srvtool-1", + caller: { type: "code_execution_20250825" }, + content: [], + }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }) + ); + }); + + it("should reject more raw assistant message groups than the provider accepts", () => { + assertProviderReplayError(() => + parseProviderReplayCheckpoint({ + version: 1, + messageId: "assistant-1", + provider: "anthropic", + providerBlocks: Array.from({ length: 7 }, () => ({ + type: "provider-block", + provider: "anthropic", + block: { type: "text", text: "hi" }, + })), + providerBlockPositions: [0, 1, 2, 3, 4, 5, 6], + providerMessageBlockCounts: [1, 1, 1, 1, 1, 1, 1], + totalPartCount: 7, + }) + ); + }); + it("should reject a provider-result content primitive", () => { assertProviderReplayError(() => parseProviderReplayCheckpoint({ diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 37c6b62326..ba662e7840 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -11,6 +11,9 @@ const MAX_PROVIDER_REPLAY_BLOCKS = 100; const MAX_PROVIDER_REPLAY_CHECKPOINTS = 100; const MAX_PROVIDER_REPLAY_TOTAL_PARTS = 10_000; const MAX_PROVIDER_REPLAY_MESSAGE_ID_LENGTH = 256; +// Mirrors MAX_ANTHROPIC_RAW_ASSISTANT_MESSAGES in the anthropic extension, +// which src cannot import from. +const MAX_PROVIDER_REPLAY_MESSAGE_GROUPS = 6; const CHECKPOINT_KEYS = new Set([ "version", @@ -207,6 +210,12 @@ function validateAnthropicProviderToolResultBlock( if (block.type === "mcp_tool_result" && typeof block.is_error !== "boolean") { invalidCheckpoint("checkpoint provider tool-result block is malformed", context); } + if ( + (block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") && + !isSupportedAnthropicServerToolCaller(block.caller) + ) { + invalidCheckpoint("checkpoint provider tool-result caller is malformed", context); + } } function isAnthropicProviderToolResultBlock(block: Record): boolean { @@ -959,9 +968,12 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe if (value.providerMessageBlockCounts !== undefined) { if ( !Array.isArray(value.providerMessageBlockCounts) || - value.providerMessageBlockCounts.length === 0 + value.providerMessageBlockCounts.length === 0 || + value.providerMessageBlockCounts.length > MAX_PROVIDER_REPLAY_MESSAGE_GROUPS ) { - invalidCheckpoint("checkpoint providerMessageBlockCounts must be a non-empty array"); + invalidCheckpoint( + `checkpoint providerMessageBlockCounts must contain 1-${MAX_PROVIDER_REPLAY_MESSAGE_GROUPS} entries`, + ); } providerMessageBlockCounts = []; let groupedBlockCount = 0; From c1ef8710d4557bb73a901022d3552f3ea0614d75 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 15:22:49 +0200 Subject: [PATCH 38/56] Preserve provider replay across durable resume boundaries Durable provider replay must fail closed only for malformed replay state, not for valid resumes whose history was compacted, split across stored assistant turns, or carried outside AG-UI forwardedProps size limits. The runtime now validates Anthropic replay deliveries before mutating messages, correlates provider-owned tool results across transcript message boundaries, preserves raw assistant message grouping, validates split same-source assistant anchors without trusting ID prefixes, compares replayed provider-result payloads with stored transcript outputs, caps raw Anthropic metadata to provider limits, and accepts server-resolved replay checkpoints through a verified private request field outside forwardedProps while keeping the legacy forwardedProps fallback. Constraint: Provider replay metadata can contain opaque signed material, so validation errors must stay generic and must not echo payload contents. Constraint: The private replay field is trusted only after the existing server-envelope/run-event token verification; public bodies are stripped or ignored. Rejected: Increase the forwardedProps budget | forwardedProps is public AG-UI configuration and should not carry multi-megabyte provider replay payloads. Rejected: Import the Anthropic extension parser into core runtime | would add an avoidable core-to-extension dependency for a small boundary validator. Confidence: high Scope-risk: moderate Directive: Keep provider replay validation ordered and validate-before-attach; do not attach raw provider metadata until every delivered checkpoint has passed boundary and anchor validation. Tested: deno task test:file src/agent/runtime/provider-replay.test.ts src/agent/hosted/chat-request.test.ts src/agent/ag-ui/host-support.test.ts Tested: deno task test:file src/agent/runtime/provider-replay.test.ts src/agent/runtime/text-generation-runtime-message-converter.test.ts src/agent/hosted/runtime-request-config.test.ts src/agent/runtime/ag-ui-contract.test.ts src/agent/ag-ui/host-support.test.ts src/agent/hosted/chat-request.test.ts src/agent/hosted/ag-ui-chat-request.test.ts src/agent/runtime/agent-invocation-contract.test.ts src/agent/hosted/chat-preparation.test.ts src/chat/message-prep.test.ts Tested: deno task typecheck Tested: deno task lint Tested: deno task fmt:check Tested: deno task test:unit:serial (rerun after one transient Deno pending-promise exit) Tested: deno task test:unit:cwd Tested: deno task test:unit:cwd-exclusion Tested: deno task test:unit:parallel completed once before remote rebase; after rebase, affected unit batch 5 passed sanitized with 647 passed/0 failed and unrelated clipboard file passed isolated after a parallel-suite flake. Tested: git diff --check --- scripts/test/run-deno-suite.ts | 4 +- scripts/test/run-suite.test.ts | 8 +- src/agent/ag-ui/host-support.test.ts | 42 + src/agent/ag-ui/request-shared.ts | 17 +- src/agent/hosted/chat-request-parser.ts | 14 +- src/agent/hosted/chat-request.test.ts | 69 +- src/agent/runtime/ag-ui-contract.test.ts | 20 + .../runtime/agent-invocation-contract.test.ts | 39 + .../runtime/agent-invocation-contract.ts | 6 +- src/agent/runtime/provider-replay-limits.ts | 5 + src/agent/runtime/provider-replay.test.ts | 972 +++++++++++++++--- src/agent/runtime/provider-replay.ts | 539 ++++++++-- ...neration-runtime-message-converter.test.ts | 33 + ...xt-generation-runtime-message-converter.ts | 9 +- 14 files changed, 1544 insertions(+), 233 deletions(-) create mode 100644 src/agent/runtime/provider-replay-limits.ts diff --git a/scripts/test/run-deno-suite.ts b/scripts/test/run-deno-suite.ts index ee6bb3fc5b..d9b8f6eaba 100644 --- a/scripts/test/run-deno-suite.ts +++ b/scripts/test/run-deno-suite.ts @@ -259,7 +259,9 @@ if (import.meta.main) { for (const [index, files] of batches.entries()) { if (batches.length > 1) { console.log( - `[test-suite] ${suite} batch ${index + 1}/${batches.length} (${files.length} files)`, + `[test-suite] ${suite} batch ${ + index + 1 + }/${batches.length} (${files.length} files)`, ); } const status = await new Deno.Command("deno", { diff --git a/scripts/test/run-suite.test.ts b/scripts/test/run-suite.test.ts index 6ae2eab9e1..4fab50cb91 100644 --- a/scripts/test/run-suite.test.ts +++ b/scripts/test/run-suite.test.ts @@ -390,7 +390,13 @@ describe("migration command surface", () => { }); it("partitions large suites without dropping or repeating files", () => { - const files = ["a.test.ts", "b.test.ts", "c.test.ts", "d.test.ts", "e.test.ts"]; + const files = [ + "a.test.ts", + "b.test.ts", + "c.test.ts", + "d.test.ts", + "e.test.ts", + ]; assertEquals(partitionDenoSuiteFiles(files, 2), [ ["a.test.ts", "b.test.ts"], diff --git a/src/agent/ag-ui/host-support.test.ts b/src/agent/ag-ui/host-support.test.ts index fda424fe68..47ceadcce0 100644 --- a/src/agent/ag-ui/host-support.test.ts +++ b/src/agent/ag-ui/host-support.test.ts @@ -7,6 +7,7 @@ import { } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { AG_UI_MAX_REQUEST_BODY_BYTES } from "./request-shared.ts"; +import { DEFAULT_LIMITS } from "#veryfront/security/input-validation/types.ts"; import { createAgUiRunErrorEvent, createAgUiSseErrorResponse, @@ -209,6 +210,47 @@ describe("agent/ag-ui-host-support", () => { ); }); + it("rejects public AG-UI provider replay state above the generic request body limit", async () => { + const request = new Request("http://localhost/api/ag-ui", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [{ + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "hello" }], + }], + serverResolvedProviderReplayCheckpoints: [{ + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { + type: "thinking", + thinking: "x".repeat(1_049_600), + signature: "sig-private-large", + }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }], + }), + }); + + const result = await parseAgUiRequestOrError(request); + + assertInstanceOf(result, Response); + assertEquals(result.status, 413); + const body = await result.json(); + assertEquals(body.error, "Invalid AG-UI request"); + assertEquals(body.details, [{ + path: [], + message: `Request body exceeds ${DEFAULT_LIMITS.maxBodySize} bytes`, + }]); + }); + it("returns a 400 Response from parseAgUiRequestOrError for malformed JSON bodies", async () => { const request = new Request("http://localhost/api/ag-ui", { method: "POST", diff --git a/src/agent/ag-ui/request-shared.ts b/src/agent/ag-ui/request-shared.ts index 3f27354a76..1af6b976b0 100644 --- a/src/agent/ag-ui/request-shared.ts +++ b/src/agent/ag-ui/request-shared.ts @@ -1,19 +1,23 @@ import { INVALID_ARGUMENT, VeryfrontError } from "#veryfront/errors"; +import { DEFAULT_LIMITS } from "#veryfront/security/input-validation/types.ts"; import { isRequestBodyTooLargeError, readBodyWithLimit, } from "#veryfront/security/input-validation/limits.ts"; -import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/index.ts"; -export const AG_UI_MAX_REQUEST_BODY_BYTES = DEFAULT_MAX_BODY_SIZE_BYTES; +export const AG_UI_MAX_REQUEST_BODY_BYTES = DEFAULT_LIMITS.maxBodySize; -export async function parseAgUiJsonBody(request: Request): Promise { - return JSON.parse(await readBodyWithLimit(request, AG_UI_MAX_REQUEST_BODY_BYTES)); +export async function parseAgUiJsonBody( + request: Request, + maxBodySizeBytes = AG_UI_MAX_REQUEST_BODY_BYTES, +): Promise { + return JSON.parse(await readBodyWithLimit(request, maxBodySizeBytes)); } export function createAgUiBodyLimitErrorResponse( error: unknown, errorLabel: string, + maxBodySizeBytes = AG_UI_MAX_REQUEST_BODY_BYTES, ): Response | undefined { if (!isRequestBodyTooLargeError(error)) { return undefined; @@ -24,7 +28,7 @@ export function createAgUiBodyLimitErrorResponse( error: errorLabel, details: [{ path: [], - message: `Request body exceeds ${AG_UI_MAX_REQUEST_BODY_BYTES} bytes`, + message: `Request body exceeds ${maxBodySizeBytes} bytes`, }], }, { status: 413 }, @@ -78,11 +82,12 @@ export function extractRequest(requestOrCtx: unknown): Request { export async function parseAgUiJsonRequestOrError( parseRequest: () => Promise, errorLabel: string, + maxBodySizeBytes = AG_UI_MAX_REQUEST_BODY_BYTES, ): Promise { try { return await parseRequest(); } catch (error) { - const bodyLimitError = createAgUiBodyLimitErrorResponse(error, errorLabel); + const bodyLimitError = createAgUiBodyLimitErrorResponse(error, errorLabel, maxBodySizeBytes); if (bodyLimitError) return bodyLimitError; if (isSchemaValidationError(error)) { diff --git a/src/agent/hosted/chat-request-parser.ts b/src/agent/hosted/chat-request-parser.ts index 2d129970c6..0154147ae0 100644 --- a/src/agent/hosted/chat-request-parser.ts +++ b/src/agent/hosted/chat-request-parser.ts @@ -23,6 +23,7 @@ import { readBodyWithLimit, } from "#veryfront/security/input-validation/limits.ts"; import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/index.ts"; +import { MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES } from "#veryfront/agent/runtime/provider-replay-limits.ts"; import { type HostedRuntimeSourceBindingError, type HostedRuntimeSourceIdentity, @@ -136,16 +137,19 @@ export type ParseRuntimeAgentRunInvocationHostedChatRequestOptions = | Promise; }; -async function parseRequestJson(request: Request): Promise { +async function parseRequestJson( + request: Request, + maxBodySizeBytes: number, +): Promise { let body: string; try { - body = await readBodyWithLimit(request, DEFAULT_MAX_BODY_SIZE_BYTES); + body = await readBodyWithLimit(request, maxBodySizeBytes); } catch (error) { if (isRequestBodyTooLargeError(error)) { return Response.json( { errorCode: "REQUEST_TOO_LARGE", - message: `Request body exceeds ${DEFAULT_MAX_BODY_SIZE_BYTES} bytes`, + message: `Request body exceeds ${maxBodySizeBytes} bytes`, }, { status: 413 }, ); @@ -577,7 +581,7 @@ export async function parseHostedChatRequestFromRequest( return authenticatedRequest; } - const requestBody = await parseRequestJson(request); + const requestBody = await parseRequestJson(request, DEFAULT_MAX_BODY_SIZE_BYTES); if (requestBody instanceof Response) return requestBody; const parsed = hostedChatRequestSchema.safeParse(requestBody); @@ -616,7 +620,7 @@ export async function parseRuntimeAgentRunInvocationHostedChatRequestFromRequest return authenticatedRequest; } - const requestBody = await parseRequestJson(request); + const requestBody = await parseRequestJson(request, MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES); if (requestBody instanceof Response) return requestBody; const invocation = getRuntimeAgentRunInvocationSchema().safeParse(requestBody); diff --git a/src/agent/hosted/chat-request.test.ts b/src/agent/hosted/chat-request.test.ts index 69aa13e527..6e4cb7bd89 100644 --- a/src/agent/hosted/chat-request.test.ts +++ b/src/agent/hosted/chat-request.test.ts @@ -2,6 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { convertUiMessagesToProviderModelMessages } from "../../chat/provider-message-conversion.ts"; import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES } from "#veryfront/agent/runtime/provider-replay-limits.ts"; import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/index.ts"; import { buildHostedChatRequestForwardedPropsFromRuntimeAgentInvocation, @@ -1887,7 +1888,7 @@ describe("agent/hosted-chat-request", () => { const response = await parseHostedChatRequestFromRequest( new Request("https://agent.example.com/api/runs", { method: "POST", - body: JSON.stringify({ padding: "x".repeat(DEFAULT_MAX_BODY_SIZE_BYTES) }), + body: JSON.stringify({ padding: "x".repeat(MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES) }), }), { authenticate: () => Promise.resolve({ userId, authToken: "token_1" }), @@ -1968,6 +1969,72 @@ describe("agent/hosted-chat-request", () => { }]); }); + it("accepts private provider replay requests above the generic body limit", async () => { + const parsed = await parseRuntimeAgentRunInvocationHostedChatRequestFromRequest( + new Request("https://agent.example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { + "X-Veryfront-Run-Event-Token": "run-event-service-token", + }, + body: JSON.stringify({ + ...createRuntimeInvocation(), + serverResolvedProviderReplayCheckpoints: [{ + ...serverResolvedProviderReplayCheckpoint, + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { + type: "thinking", + thinking: "x".repeat(DEFAULT_MAX_BODY_SIZE_BYTES + 1_024), + signature: "sig-private-large", + }, + }], + }], + }), + }), + { + authenticate: () => Promise.resolve({ userId, authToken: "user-api-token" }), + verifyProjectAccess: () => Promise.resolve({ success: true }), + verifyRunEventAppendToken: () => Promise.resolve(true), + runtimeSource, + }, + ); + + if (parsed instanceof Response) throw new Error("Expected parsed request"); + assertEquals( + Array.isArray(parsed.serverResolvedProviderReplayCheckpoints), + true, + "private replay state uses the replay request envelope, not the generic body cap", + ); + }); + + it("rejects ordinary hosted chat requests above the generic body limit", async () => { + const response = await parseHostedChatRequestFromRequest( + new Request("https://agent.example.com/api/chat", { + method: "POST", + body: JSON.stringify( + createHostedChatRequestBody([ + createHostedChatRequestMessage("user", [{ + type: "text", + text: "x".repeat(DEFAULT_MAX_BODY_SIZE_BYTES + 1_024), + }]), + ]), + ), + }), + { + authenticate: () => Promise.resolve({ userId, authToken: "user-api-token" }), + verifyProjectAccess: () => Promise.resolve({ success: true }), + }, + ); + + assertEquals(response instanceof Response, true); + assertEquals((response as Response).status, 413); + assertStringIncludes( + await (response as Response).text(), + `Request body exceeds ${DEFAULT_MAX_BODY_SIZE_BYTES} bytes`, + ); + }); + it("does not trust server-resolved fields from an ordinary chat body even with a writer token", async () => { const parsed = await parseHostedChatRequestFromRequest( new Request("https://agent.example.com/api/runs", { diff --git a/src/agent/runtime/ag-ui-contract.test.ts b/src/agent/runtime/ag-ui-contract.test.ts index f8c18f9d79..c482987beb 100644 --- a/src/agent/runtime/ag-ui-contract.test.ts +++ b/src/agent/runtime/ag-ui-contract.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertInstanceOf } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { DEFAULT_LIMITS } from "#veryfront/security/input-validation/types.ts"; import { getAgUiRuntimeRequestSchema, normalizeAgUiRuntimeRequest, @@ -189,6 +190,25 @@ describe("agent/runtime-ag-ui-contract", () => { assertEquals(body.details, [{ path: [], message: "Malformed JSON request body" }]); }); + it("keeps public runtime AG-UI requests on the generic body limit", async () => { + const result = await parseAgUiRuntimeRequestOrError( + new Request("http://localhost/api/runs/run_1", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ padding: "x".repeat(DEFAULT_LIMITS.maxBodySize) }), + }), + ); + + assertInstanceOf(result, Response); + assertEquals(result.status, 413); + const body = await result.json(); + assertEquals(body.error, "Invalid AG-UI runtime request"); + assertEquals(body.details, [{ + path: [], + message: `Request body exceeds ${DEFAULT_LIMITS.maxBodySize} bytes`, + }]); + }); + it("returns a 400 response when the runtime AG-UI request has no body", async () => { const result = await parseAgUiRuntimeRequestOrError( new Request("http://localhost/api/ag-ui", { diff --git a/src/agent/runtime/agent-invocation-contract.test.ts b/src/agent/runtime/agent-invocation-contract.test.ts index 8293d991a9..9c0cc59604 100644 --- a/src/agent/runtime/agent-invocation-contract.test.ts +++ b/src/agent/runtime/agent-invocation-contract.test.ts @@ -7,6 +7,8 @@ import { parseRuntimeAgentRunInvocationOrError, RuntimeAgentRunInvocationSchema, } from "../index.ts"; +import { DEFAULT_LIMITS } from "#veryfront/security/input-validation/types.ts"; +import { MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES } from "./provider-replay-limits.ts"; const conversationId = "10000000-1000-4000-8000-100000000001"; const messageId = "10000000-1000-4000-8000-100000000002"; @@ -523,6 +525,43 @@ describe("agent/runtime-agent-invocation-contract", () => { assertEquals(parsed.context.length, 1); }); + it("keeps the replay-sized body limit on runtime agent invocation requests", async () => { + const parsed = await parseRuntimeAgentRunInvocation( + new Request("http://localhost/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(createInvocation({ + serverResolvedProviderReplayCheckpoints: [{ + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { + type: "thinking", + thinking: "x".repeat(DEFAULT_LIMITS.maxBodySize), + signature: "sig-private-large", + }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }], + })), + }), + ); + + assertEquals( + JSON.stringify(parsed).length < MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES, + true, + "test fixture must stay inside the replay envelope", + ); + assertEquals( + Array.isArray(parsed.serverResolvedProviderReplayCheckpoints), + true, + ); + }); + it("returns a 400 response for malformed runtime agent invocation payloads", async () => { const result = await parseRuntimeAgentRunInvocationOrError( new Request("http://localhost/api/control-plane/runs/run_1/stream", { diff --git a/src/agent/runtime/agent-invocation-contract.ts b/src/agent/runtime/agent-invocation-contract.ts index 3c83cf8e85..3931b041fd 100644 --- a/src/agent/runtime/agent-invocation-contract.ts +++ b/src/agent/runtime/agent-invocation-contract.ts @@ -2,6 +2,7 @@ import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema, RefinementCtx } from "#veryfront/extensions/schema/index.ts"; import { ensureBuiltinSchemaValidator } from "#veryfront/extensions/builtin-extensions.ts"; import { parseAgUiJsonBody, parseAgUiJsonRequestOrError } from "../ag-ui/request-shared.ts"; +import { MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES } from "./provider-replay-limits.ts"; import { getRuntimeAgentMarkdownDefinitionSchema } from "./agent-definition.ts"; ensureBuiltinSchemaValidator(); @@ -479,7 +480,9 @@ export function buildRuntimeAgentControlPlaneStreamRequestFromInvocation( export async function parseRuntimeAgentRunInvocation( request: Request, ): Promise { - return getRuntimeAgentRunInvocationSchema().parse(await parseAgUiJsonBody(request)); + return getRuntimeAgentRunInvocationSchema().parse( + await parseAgUiJsonBody(request, MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES), + ); } /** Error shape for parse runtime agent run invocation or. */ @@ -489,5 +492,6 @@ export async function parseRuntimeAgentRunInvocationOrError( return await parseAgUiJsonRequestOrError( () => parseRuntimeAgentRunInvocation(request), "Invalid runtime agent invocation", + MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES, ); } diff --git a/src/agent/runtime/provider-replay-limits.ts b/src/agent/runtime/provider-replay-limits.ts new file mode 100644 index 0000000000..6e5d57449b --- /dev/null +++ b/src/agent/runtime/provider-replay-limits.ts @@ -0,0 +1,5 @@ +export const MAX_ANTHROPIC_RAW_ASSISTANT_MESSAGES = 6; +export const MAX_PROVIDER_REPLAY_RAW_METADATA_DEPTH = 64; +export const MAX_PROVIDER_REPLAY_RAW_METADATA_NODES = 65_536; +export const MAX_PROVIDER_REPLAY_RAW_METADATA_STRING_CHARS = 8 * 1024 * 1024; +export const MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES = 10 * 1024 * 1024; diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 1de9416e1b..e85dd16f76 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -339,46 +339,6 @@ describe("agent/runtime/provider-replay", () => { } }); - it("should reject a malformed caller on a web provider result", () => { - assertProviderReplayError(() => - parseProviderReplayCheckpoint({ - version: 1, - messageId: "assistant-1", - provider: "anthropic", - providerBlocks: [{ - type: "provider-block", - provider: "anthropic", - block: { - type: "web_search_tool_result", - tool_use_id: "srvtool-1", - caller: { type: "code_execution_20250825" }, - content: [], - }, - }], - providerBlockPositions: [0], - totalPartCount: 1, - }) - ); - }); - - it("should reject more raw assistant message groups than the provider accepts", () => { - assertProviderReplayError(() => - parseProviderReplayCheckpoint({ - version: 1, - messageId: "assistant-1", - provider: "anthropic", - providerBlocks: Array.from({ length: 7 }, () => ({ - type: "provider-block", - provider: "anthropic", - block: { type: "text", text: "hi" }, - })), - providerBlockPositions: [0, 1, 2, 3, 4, 5, 6], - providerMessageBlockCounts: [1, 1, 1, 1, 1, 1, 1], - totalPartCount: 7, - }) - ); - }); - it("should reject a provider-result content primitive", () => { assertProviderReplayError(() => parseProviderReplayCheckpoint({ @@ -1154,36 +1114,52 @@ describe("agent/runtime/provider-replay", () => { ); }); - it("should reject a provider tool result without its provider-owned call", () => { + it("should reject web provider results with malformed callers", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-web-search", + name: "web_search", + input: { query: "provider replay" }, + caller: { type: "direct" }, + }; const providerResult = { type: "web_search_tool_result", - tool_use_id: "srvtool-web-search", - caller: { type: "direct" }, + tool_use_id: providerCall.id, + caller: { type: "unknown" }, content: [], }; const target = { id: "assistant-message-1", role: "assistant", - parts: [{ - type: "tool-result", - toolCallId: providerResult.tool_use_id, - toolName: "web_search", - result: [], - providerExecuted: true, - }], + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], timestamp: 1, } as Message; const checkpoint: ProviderReplayCheckpoint = { version: 1, messageId: target.id, provider: "anthropic", - providerBlocks: [{ - type: "provider-block", - provider: "anthropic", - block: providerResult, - }], - providerBlockPositions: [0], - totalPartCount: 1, + providerBlocks: [providerCall, providerResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, }; assertProviderReplayError(() => @@ -1191,19 +1167,32 @@ describe("agent/runtime/provider-replay", () => { ); }); - it("should reject provider tool results that do not match their tool use type", () => { + it("should reject provider results whose payload differs from the transcript", () => { const providerCall = { type: "server_tool_use", - id: "srvtool-web-fetch", - name: "web_fetch", - input: { url: "https://veryfront.com/docs" }, + id: "srvtool-web-search", + name: "web_search", + input: { query: "provider replay" }, caller: { type: "direct" }, }; + const transcriptResult = [{ + type: "web_search_result", + url: "https://veryfront.com/actual", + title: "Actual", + encrypted_content: "actual", + page_age: null, + }]; const providerResult = { type: "web_search_tool_result", tool_use_id: providerCall.id, caller: { type: "direct" }, - content: [], + content: [{ + type: "web_search_result", + url: "https://veryfront.com/stale", + title: "Stale", + encrypted_content: "stale", + page_age: null, + }], }; const target = { id: "assistant-message-1", @@ -1220,7 +1209,7 @@ describe("agent/runtime/provider-replay", () => { type: "tool-result", toolCallId: providerCall.id, toolName: providerCall.name, - result: [], + result: transcriptResult, providerExecuted: true, }, ], @@ -1244,19 +1233,32 @@ describe("agent/runtime/provider-replay", () => { ); }); - it("should reject malformed MCP result elements at the checkpoint boundary", () => { + it("should normalize provider results before matching the transcript", () => { const providerCall = { - type: "mcp_tool_use", - id: "srvtool-mcp", - name: "search_docs", - server_name: "docs", + type: "server_tool_use", + id: "srvtool-web-search", + name: "web_search", input: { query: "provider replay" }, + caller: { type: "direct" }, }; + const transcriptResult = [{ + type: "web_search_result", + url: "https://veryfront.com/actual", + title: "Actual", + pageAge: null, + encryptedContent: "actual", + }]; const providerResult = { - type: "mcp_tool_result", + type: "web_search_tool_result", tool_use_id: providerCall.id, - is_error: false, - content: [{ type: "image" }], + caller: { type: "direct" }, + content: [{ + type: "web_search_result", + url: "https://veryfront.com/actual", + title: "Actual", + encrypted_content: "actual", + page_age: null, + }], }; const target = { id: "assistant-message-1", @@ -1273,7 +1275,7 @@ describe("agent/runtime/provider-replay", () => { type: "tool-result", toolCallId: providerCall.id, toolName: providerCall.name, - result: [], + result: transcriptResult, providerExecuted: true, }, ], @@ -1292,32 +1294,45 @@ describe("agent/runtime/provider-replay", () => { totalPartCount: 2, }; - assertProviderReplayError(() => - applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { anthropic: { rawAssistantMessages: [[providerCall, providerResult]] } }, ); }); - it("should accept a provider tool turn whose text was persisted as one concatenated part", () => { - const leadingText = { type: "text", text: "Let me search. " }; + it("should unwrap prepared JSON provider results before matching the transcript", () => { const providerCall = { type: "server_tool_use", - id: "srvtool-1", + id: "srvtool-web-search", name: "web_search", - input: { query: "q" }, + input: { query: "provider replay" }, caller: { type: "direct" }, }; const providerResult = { type: "web_search_tool_result", tool_use_id: providerCall.id, caller: { type: "direct" }, - content: [], + content: [{ + type: "web_search_result", + url: "https://veryfront.com/actual", + title: "Actual", + encrypted_content: "actual", + page_age: null, + }], }; - const trailingText = { type: "text", text: "Here is what I found." }; + const transcriptResult = [{ + type: "web_search_result", + url: "https://veryfront.com/actual", + title: "Actual", + pageAge: null, + encryptedContent: "actual", + }]; const target = { id: "assistant-message-1", role: "assistant", parts: [ - { type: "text", text: "Let me search. Here is what I found." }, { type: "tool-call", toolCallId: providerCall.id, @@ -1329,50 +1344,50 @@ describe("agent/runtime/provider-replay", () => { type: "tool-result", toolCallId: providerCall.id, toolName: providerCall.name, - result: [], + result: { type: "json", value: transcriptResult }, providerExecuted: true, }, ], timestamp: 1, } as Message; - const blocks = [leadingText, providerCall, providerResult, trailingText]; const checkpoint: ProviderReplayCheckpoint = { version: 1, messageId: target.id, provider: "anthropic", - providerBlocks: blocks.map((block) => ({ + providerBlocks: [providerCall, providerResult].map((block) => ({ type: "provider-block" as const, provider: "anthropic" as const, block, })), - providerBlockPositions: [0, 1, 2, 3], - totalPartCount: 4, + providerBlockPositions: [0, 1], + totalPartCount: 2, }; applyProviderReplayCheckpointsToMessages([target], [checkpoint]); assertEquals( readAttachedProviderMetadata(target), - { anthropic: { rawAssistantMessages: [blocks] } }, - "split provider text matches the single persisted transcript text part", + { anthropic: { rawAssistantMessages: [[providerCall, providerResult]] } }, ); }); - it("should preserve checkpoint raw assistant message boundaries", () => { + it("should normalize provider error results before matching the transcript", () => { const providerCall = { type: "server_tool_use", - id: "srvtool-1", + id: "srvtool-web-search-error", name: "web_search", - input: { query: "q" }, + input: { query: "provider replay" }, caller: { type: "direct" }, }; const providerResult = { - type: "web_search_tool_result", + type: "web_search_tool_result_error", tool_use_id: providerCall.id, caller: { type: "direct" }, - content: [], + content: { + type: "web_search_tool_result_error", + error_code: "max_uses_exceeded", + }, }; - const trailingText = { type: "text", text: "Found it." }; const target = { id: "assistant-message-1", role: "assistant", @@ -1388,10 +1403,19 @@ describe("agent/runtime/provider-replay", () => { type: "tool-result", toolCallId: providerCall.id, toolName: providerCall.name, - result: [], + result: { + type: "json", + value: { + name: "AnthropicServerToolResultError", + provider: "anthropic", + code: "max_uses_exceeded", + toolCallId: providerCall.id, + toolName: providerCall.name, + }, + }, + isError: true, providerExecuted: true, }, - trailingText, ], timestamp: 1, } as Message; @@ -1399,41 +1423,99 @@ describe("agent/runtime/provider-replay", () => { version: 1, messageId: target.id, provider: "anthropic", - providerBlocks: [providerCall, providerResult, trailingText].map((block) => ({ + providerBlocks: [providerCall, providerResult].map((block) => ({ type: "provider-block" as const, provider: "anthropic" as const, block, })), - providerBlockPositions: [0, 1, 2], - providerMessageBlockCounts: [1, 2], - totalPartCount: 3, + providerBlockPositions: [0, 1], + totalPartCount: 2, }; applyProviderReplayCheckpointsToMessages([target], [checkpoint]); assertEquals( readAttachedProviderMetadata(target), - { anthropic: { rawAssistantMessages: [[providerCall], [providerResult, trailingText]] } }, + { anthropic: { rawAssistantMessages: [[providerCall, providerResult]] } }, ); - assertEquals( - convertToTextGenerationRuntimeMessages([target]), - [{ - role: "assistant", - content: [{ type: "text", text: trailingText.text }], - providerMetadata: { - anthropic: { rawAssistantMessages: [[providerCall], [providerResult, trailingText]] }, - }, + }); + + it("should reset provider-executed ids at transcript boundaries before matching anchors", () => { + const reusedToolCallId = "reused-tool-call"; + const historicalProviderCall = { + type: "server_tool_use", + id: reusedToolCallId, + name: "web_search", + input: { query: "historical provider call" }, + caller: { type: "direct" }, + }; + const targetRawToolUse = { + type: "tool_use", + id: reusedToolCallId, + name: "lookup", + input: { query: "ordinary local call" }, + }; + const historicalAssistant = { + id: "assistant-historical", + role: "assistant", + parts: [{ + type: "tool-call", + toolCallId: historicalProviderCall.id, + toolName: historicalProviderCall.name, + args: historicalProviderCall.input, + providerExecuted: true, }], - "provider-executed blocks replay through raw metadata without duplicating transcript text", + timestamp: 1, + } as Message; + const boundary = { + id: "user-boundary", + role: "user", + parts: [{ type: "text", text: "new request" }], + timestamp: 2, + } as Message; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [{ + type: "tool-call", + toolCallId: targetRawToolUse.id, + toolName: targetRawToolUse.name, + args: targetRawToolUse.input, + }], + timestamp: 3, + } as Message; + attachProviderMetadata(historicalAssistant, { + anthropic: { rawAssistantMessages: [[historicalProviderCall]] }, + }); + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: targetRawToolUse, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + + applyProviderReplayCheckpointsToMessages([historicalAssistant, boundary, target], [ + checkpoint, + ]); + + assertEquals( + readAttachedProviderMetadata(target), + { anthropic: { rawAssistantMessages: [[targetRawToolUse]] } }, ); }); - it("should attach split provider replay segments to same-source assistant turns", () => { + it("should reject provider results separated from their provider tool use by a transcript boundary", () => { const providerCall = { type: "server_tool_use", - id: "srvtool-1", + id: "srvtool-web-search", name: "web_search", - input: { query: "q" }, + input: { query: "provider replay" }, caller: { type: "direct" }, }; const providerResult = { @@ -1442,9 +1524,8 @@ describe("agent/runtime/provider-replay", () => { caller: { type: "direct" }, content: [], }; - const trailingText = { type: "text", text: "Found it." }; - const leadingAssistant = { - id: "assistant-message-1", + const callTurn = { + id: "assistant-call", role: "assistant", parts: [{ type: "tool-call", @@ -1455,9 +1536,15 @@ describe("agent/runtime/provider-replay", () => { }], timestamp: 1, } as Message; - const toolSibling = { - id: "assistant-message-1", - role: "tool", + const boundary = { + id: "user-boundary", + role: "user", + parts: [{ type: "text", text: "new request" }], + timestamp: 2, + } as Message; + const resultTurn = { + id: "assistant-result", + role: "assistant", parts: [{ type: "tool-result", toolCallId: providerCall.id, @@ -1465,34 +1552,372 @@ describe("agent/runtime/provider-replay", () => { result: [], providerExecuted: true, }], - timestamp: 2, - } as Message; - const trailingAssistant = { - id: "assistant-message-1", - role: "assistant", - parts: [trailingText], timestamp: 3, } as Message; - const checkpoint: ProviderReplayCheckpoint = { + const callCheckpoint: ProviderReplayCheckpoint = { version: 1, - messageId: "assistant-message-1", + messageId: callTurn.id, provider: "anthropic", - providerBlocks: [providerCall, providerResult, trailingText].map((block) => ({ - type: "provider-block" as const, - provider: "anthropic" as const, - block, - })), - providerBlockPositions: [0, 1, 2], - totalPartCount: 3, + providerBlocks: [{ type: "provider-block", provider: "anthropic", block: providerCall }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + const resultCheckpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: resultTurn.id, + provider: "anthropic", + providerBlocks: [{ type: "provider-block", provider: "anthropic", block: providerResult }], + providerBlockPositions: [0], + totalPartCount: 1, }; - applyProviderReplayCheckpointsToMessages( - [leadingAssistant, toolSibling, trailingAssistant], - [checkpoint], + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages( + [callTurn, boundary, resultTurn], + [callCheckpoint, resultCheckpoint], + ) ); + }); - assertEquals( - readAttachedProviderMetadata(leadingAssistant), + it("should reject a provider tool result without its provider-owned call", () => { + const providerResult = { + type: "web_search_tool_result", + tool_use_id: "srvtool-web-search", + caller: { type: "direct" }, + content: [], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [{ + type: "tool-result", + toolCallId: providerResult.tool_use_id, + toolName: "web_search", + result: [], + providerExecuted: true, + }], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: providerResult, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + + it("should reject provider tool results that do not match their tool use type", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-web-fetch", + name: "web_fetch", + input: { url: "https://veryfront.com/docs" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, providerResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + + it("should reject malformed MCP result elements at the checkpoint boundary", () => { + const providerCall = { + type: "mcp_tool_use", + id: "srvtool-mcp", + name: "search_docs", + server_name: "docs", + input: { query: "provider replay" }, + }; + const providerResult = { + type: "mcp_tool_result", + tool_use_id: providerCall.id, + is_error: false, + content: [{ type: "image" }], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, providerResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + + it("should accept a provider tool turn whose text was persisted as one concatenated part", () => { + const leadingText = { type: "text", text: "Let me search. " }; + const providerCall = { + type: "server_tool_use", + id: "srvtool-1", + name: "web_search", + input: { query: "q" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const trailingText = { type: "text", text: "Here is what I found." }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { type: "text", text: "Let me search. Here is what I found." }, + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const blocks = [leadingText, providerCall, providerResult, trailingText]; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: blocks.map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1, 2, 3], + totalPartCount: 4, + }; + + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { anthropic: { rawAssistantMessages: [blocks] } }, + "split provider text matches the single persisted transcript text part", + ); + }); + + it("should preserve checkpoint raw assistant message boundaries", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-1", + name: "web_search", + input: { query: "q" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const trailingText = { type: "text", text: "Found it." }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }, + trailingText, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, providerResult, trailingText].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1, 2], + providerMessageBlockCounts: [1, 2], + totalPartCount: 3, + }; + + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals( + readAttachedProviderMetadata(target), + { anthropic: { rawAssistantMessages: [[providerCall], [providerResult, trailingText]] } }, + ); + assertEquals( + convertToTextGenerationRuntimeMessages([target]), + [{ + role: "assistant", + content: [{ type: "text", text: trailingText.text }], + providerMetadata: { + anthropic: { rawAssistantMessages: [[providerCall], [providerResult, trailingText]] }, + }, + }], + "provider-executed blocks replay through raw metadata without duplicating transcript text", + ); + }); + + it("should attach split provider replay segments to same-source assistant turns", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-1", + name: "web_search", + input: { query: "q" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const trailingText = { type: "text", text: "Found it." }; + const leadingAssistant = { + id: "assistant-message-1", + role: "assistant", + parts: [{ + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }], + timestamp: 1, + } as Message; + const toolSibling = { + id: "assistant-message-1", + role: "tool", + parts: [{ + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }], + timestamp: 2, + } as Message; + const trailingAssistant = { + id: "assistant-message-1", + role: "assistant", + parts: [trailingText], + timestamp: 3, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [providerCall, providerResult, trailingText].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1, 2], + totalPartCount: 3, + }; + + applyProviderReplayCheckpointsToMessages( + [leadingAssistant, toolSibling, trailingAssistant], + [checkpoint], + ); + + assertEquals( + readAttachedProviderMetadata(leadingAssistant), { anthropic: { rawAssistantMessages: [[providerCall]] } }, "the leading assistant segment replays only its raw assistant blocks", ); @@ -1504,6 +1929,201 @@ describe("agent/runtime/provider-replay", () => { assertEquals(readAttachedProviderMetadata(toolSibling), undefined); }); + it("should group consecutive provider results with the trailing assistant segment", () => { + const firstProviderCall = { + type: "server_tool_use", + id: "srvtool-1", + name: "web_search", + input: { query: "first" }, + caller: { type: "direct" }, + }; + const secondProviderCall = { + type: "server_tool_use", + id: "srvtool-2", + name: "web_search", + input: { query: "second" }, + caller: { type: "direct" }, + }; + const firstProviderResult = { + type: "web_search_tool_result", + tool_use_id: firstProviderCall.id, + caller: { type: "direct" }, + content: [], + }; + const secondProviderResult = { + type: "web_search_tool_result", + tool_use_id: secondProviderCall.id, + caller: { type: "direct" }, + content: [], + }; + const trailingText = { type: "text", text: "Found both." }; + const leadingAssistant = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: firstProviderCall.id, + toolName: firstProviderCall.name, + args: firstProviderCall.input, + providerExecuted: true, + }, + { + type: "tool-call", + toolCallId: secondProviderCall.id, + toolName: secondProviderCall.name, + args: secondProviderCall.input, + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const toolSibling = { + id: "assistant-message-1", + role: "tool", + parts: [ + { + type: "tool-result", + toolCallId: firstProviderCall.id, + toolName: firstProviderCall.name, + result: [], + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: secondProviderCall.id, + toolName: secondProviderCall.name, + result: [], + providerExecuted: true, + }, + ], + timestamp: 2, + } as Message; + const trailingAssistant = { + id: "assistant-message-1", + role: "assistant", + parts: [trailingText], + timestamp: 3, + } as Message; + const checkpointBlocks = [ + firstProviderCall, + secondProviderCall, + firstProviderResult, + secondProviderResult, + trailingText, + ]; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: checkpointBlocks.map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1, 2, 3, 4], + totalPartCount: 5, + }; + + applyProviderReplayCheckpointsToMessages( + [leadingAssistant, toolSibling, trailingAssistant], + [checkpoint], + ); + + assertEquals( + readAttachedProviderMetadata(leadingAssistant), + { anthropic: { rawAssistantMessages: [[firstProviderCall, secondProviderCall]] } }, + "the leading assistant segment keeps both provider calls together", + ); + assertEquals( + readAttachedProviderMetadata(trailingAssistant), + { + anthropic: { + rawAssistantMessages: [[firstProviderResult, secondProviderResult, trailingText]], + }, + }, + "consecutive provider results stay with the trailing assistant segment", + ); + assertEquals(readAttachedProviderMetadata(toolSibling), undefined); + }); + + it("should preserve declared raw groups when attaching split same-source turns", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-1", + name: "web_search", + input: { query: "q" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: [], + }; + const trailingText = { type: "text", text: "Found it." }; + const leadingAssistant = { + id: "assistant-message-1", + role: "assistant", + parts: [{ + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }], + timestamp: 1, + } as Message; + const toolSibling = { + id: "assistant-message-1", + role: "tool", + parts: [{ + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [], + providerExecuted: true, + }], + timestamp: 2, + } as Message; + const trailingAssistant = { + id: "assistant-message-1", + role: "assistant", + parts: [trailingText], + timestamp: 3, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [providerCall, providerResult, trailingText].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1, 2], + providerMessageBlockCounts: [2, 1], + totalPartCount: 3, + }; + + applyProviderReplayCheckpointsToMessages( + [leadingAssistant, toolSibling, trailingAssistant], + [checkpoint], + ); + + assertEquals( + readAttachedProviderMetadata(leadingAssistant), + { anthropic: { rawAssistantMessages: [[providerCall, providerResult]] } }, + "the first same-source assistant keeps its declared raw provider-result sibling", + ); + assertEquals( + readAttachedProviderMetadata(trailingAssistant), + { anthropic: { rawAssistantMessages: [[trailingText]] } }, + "the second same-source assistant keeps the declared trailing raw text group", + ); + assertEquals(readAttachedProviderMetadata(toolSibling), undefined); + }); + it("should match replay blocks against persisted assistant transcript order", () => { const providerCall = { type: "server_tool_use", @@ -1859,6 +2479,80 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should reject replay groups beyond the provider assistant-message limit", () => { + const checkpoint = createValidCheckpoint() as unknown as Record; + checkpoint.providerBlocks = Array.from({ length: 7 }, (_, index) => ({ + type: "provider-block", + provider: "anthropic", + block: { type: "text", text: `segment ${index}` }, + })); + checkpoint.providerBlockPositions = [0, 1, 2, 3, 4, 5, 6]; + checkpoint.providerMessageBlockCounts = [1, 1, 1, 1, 1, 1, 1]; + checkpoint.totalPartCount = 7; + + assertProviderReplayError(() => parseProviderReplayCheckpoint(checkpoint)); + }); + + it("should reject raw replay blocks beyond provider metadata bounds", () => { + const checkpoint = createValidCheckpoint() as unknown as Record; + let nested: Record = { value: "leaf" }; + for (let depth = 0; depth < 70; depth += 1) { + nested = { nested }; + } + checkpoint.providerBlocks = [{ + type: "provider-block", + provider: "anthropic", + block: { type: "text", text: "visible", nested }, + }]; + checkpoint.providerBlockPositions = [0]; + checkpoint.totalPartCount = 1; + + assertProviderReplayError(() => parseProviderReplayCheckpoint(checkpoint)); + }); + + it("should reject raw replay blocks beyond canonical JSON byte bounds", () => { + const checkpoint = createValidCheckpoint() as unknown as Record; + checkpoint.providerBlocks = [{ + type: "provider-block", + provider: "anthropic", + block: { + type: "text", + text: "\0".repeat(1_400_000), + }, + }]; + checkpoint.providerBlockPositions = [0]; + checkpoint.totalPartCount = 1; + + const error = assertProviderReplayError(() => parseProviderReplayCheckpoint(checkpoint)); + assertEquals( + error.detail, + "checkpoint provider block exceeds raw metadata bounds", + ); + }); + + it("should reject aggregate raw replay metadata beyond provider metadata bounds", () => { + const checkpoint = createValidCheckpoint() as unknown as Record; + const blocks = Array.from({ length: 100 }, (_, index) => ({ + type: "text", + text: `segment ${index}`, + retained: Array.from({ length: 700 }, (__, itemIndex) => itemIndex), + })); + checkpoint.providerBlocks = blocks.map((block) => ({ + type: "provider-block", + provider: "anthropic", + block, + })); + checkpoint.providerBlockPositions = blocks.map((_, index) => index); + checkpoint.providerMessageBlockCounts = [blocks.length]; + checkpoint.totalPartCount = blocks.length; + + const error = assertProviderReplayError(() => parseProviderReplayCheckpoint(checkpoint)); + assertEquals( + error.detail, + "checkpoint raw assistant messages exceeds raw metadata bounds", + ); + }); + it("should fail explicitly when one message internally splits replay state", () => { // One stored assistant turn with an inline tool result followed by more // content still cannot receive one exact metadata attachment: by the time diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index ba662e7840..7c12c6375f 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -4,16 +4,20 @@ import { readAttachedProviderMetadata, } from "#veryfront/agent/runtime/provider-metadata.ts"; import { stringifyChatJson } from "#veryfront/chat/json-value.ts"; +import { snapshotProviderJsonValue } from "#veryfront/provider/runtime-loader.ts"; import type { Message } from "../types.ts"; import { convertAgentRuntimeMessagesToProviderMessages } from "./message-adapter.ts"; +import { + MAX_ANTHROPIC_RAW_ASSISTANT_MESSAGES, + MAX_PROVIDER_REPLAY_RAW_METADATA_DEPTH, + MAX_PROVIDER_REPLAY_RAW_METADATA_NODES, + MAX_PROVIDER_REPLAY_RAW_METADATA_STRING_CHARS, +} from "./provider-replay-limits.ts"; const MAX_PROVIDER_REPLAY_BLOCKS = 100; const MAX_PROVIDER_REPLAY_CHECKPOINTS = 100; const MAX_PROVIDER_REPLAY_TOTAL_PARTS = 10_000; const MAX_PROVIDER_REPLAY_MESSAGE_ID_LENGTH = 256; -// Mirrors MAX_ANTHROPIC_RAW_ASSISTANT_MESSAGES in the anthropic extension, -// which src cannot import from. -const MAX_PROVIDER_REPLAY_MESSAGE_GROUPS = 6; const CHECKPOINT_KEYS = new Set([ "version", @@ -29,10 +33,15 @@ const CHECKPOINT_KEYS = new Set([ const BLOCK_KEYS = new Set(["type", "provider", "block"]); const ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES = new Set([ "web_search_tool_result", + "web_search_tool_result_error", "web_fetch_tool_result", + "web_fetch_tool_result_error", "code_execution_tool_result", + "code_execution_tool_result_error", "bash_code_execution_tool_result", + "bash_code_execution_tool_result_error", "text_editor_code_execution_tool_result", + "text_editor_code_execution_tool_result_error", "mcp_tool_result", ]); const WEB_SEARCH_ERROR_CODES = new Set([ @@ -68,7 +77,6 @@ const TEXT_EDITOR_CODE_EXECUTION_ERROR_CODES = new Set([ ...CODE_EXECUTION_ERROR_CODES, "file_not_found", ]); - /** Providers whose continuation contract can require opaque block replay. */ export type ProviderReplayProvider = "anthropic" | "openai-responses"; @@ -135,6 +143,22 @@ function isNullableNonNegativeSafeInteger(value: unknown): value is number | nul typeof value === "number" && Number.isSafeInteger(value) && value >= 0; } +function assertRawProviderMetadataBounds( + value: unknown, + context?: Record, + detail = "checkpoint provider block exceeds raw metadata bounds", +): void { + try { + snapshotProviderJsonValue(value, { + maxDepth: MAX_PROVIDER_REPLAY_RAW_METADATA_DEPTH, + maxNodes: MAX_PROVIDER_REPLAY_RAW_METADATA_NODES, + maxBytes: MAX_PROVIDER_REPLAY_RAW_METADATA_STRING_CHARS, + }); + } catch { + invalidCheckpoint(detail, context); + } +} + function isSupportedAnthropicServerToolCaller(value: unknown): boolean { if (value === undefined) return true; if (!isRecord(value)) return false; @@ -210,12 +234,6 @@ function validateAnthropicProviderToolResultBlock( if (block.type === "mcp_tool_result" && typeof block.is_error !== "boolean") { invalidCheckpoint("checkpoint provider tool-result block is malformed", context); } - if ( - (block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") && - !isSupportedAnthropicServerToolCaller(block.caller) - ) { - invalidCheckpoint("checkpoint provider tool-result caller is malformed", context); - } } function isAnthropicProviderToolResultBlock(block: Record): boolean { @@ -436,29 +454,275 @@ function hasValidAnthropicProviderToolResultContentForTool( return hasValidAnthropicMcpContent(block.content); } if (toolName === "web_search") { - return block.type === "web_search_tool_result" && + return isExpectedAnthropicResultTypeForTool(block.type, toolName) && + isSupportedAnthropicServerToolCaller(block.caller) && hasValidAnthropicWebSearchContent(block.content); } const content = isRecord(block.content) ? block.content : undefined; if (!content) return false; switch (toolName) { case "web_fetch": - return block.type === "web_fetch_tool_result" && + return isExpectedAnthropicResultTypeForTool(block.type, toolName) && + isSupportedAnthropicServerToolCaller(block.caller) && hasValidAnthropicWebFetchContent(content); case "code_execution": - return block.type === "code_execution_tool_result" && + return isExpectedAnthropicResultTypeForTool(block.type, toolName) && hasValidAnthropicCodeExecutionContent(content); case "bash_code_execution": - return block.type === "bash_code_execution_tool_result" && + return isExpectedAnthropicResultTypeForTool(block.type, toolName) && hasValidAnthropicBashCodeExecutionContent(content); case "text_editor_code_execution": - return block.type === "text_editor_code_execution_tool_result" && + return isExpectedAnthropicResultTypeForTool(block.type, toolName) && hasValidAnthropicTextEditorCodeExecutionContent(content); default: return false; } } +function normalizeAnthropicFileOutputs( + value: unknown, + expectedType: "code_execution_output" | "bash_code_execution_output", +): Array> { + return Array.isArray(value) + ? value.map((item) => { + const record = isRecord(item) ? item : {}; + return { type: expectedType, fileId: record.file_id }; + }) + : []; +} + +function normalizeAnthropicWebSearchContent(content: unknown): unknown { + const error = normalizeAnthropicProviderErrorContent( + content, + "web_search_tool_result_error", + WEB_SEARCH_ERROR_CODES, + ); + if (error !== undefined) return error; + if (!Array.isArray(content)) return content; + return content.map((item) => { + if (!isRecord(item) || item.type !== "web_search_result") return item; + return { + type: "web_search_result", + url: item.url, + title: item.title, + pageAge: item.page_age, + encryptedContent: item.encrypted_content, + }; + }); +} + +function normalizeAnthropicWebFetchSource(value: unknown): unknown { + if (!isRecord(value)) return value; + if (value.type === "text" && value.media_type === "text/plain") { + return { + type: "text", + mediaType: "text/plain", + data: value.data, + }; + } + if (value.type === "base64" && value.media_type === "application/pdf") { + return { + type: "base64", + mediaType: "application/pdf", + data: value.data, + }; + } + return value; +} + +function normalizeAnthropicWebFetchContent(content: unknown): unknown { + const error = normalizeAnthropicProviderErrorContent( + content, + "web_fetch_tool_result_error", + WEB_FETCH_ERROR_CODES, + ); + if (error !== undefined) return error; + if (!isRecord(content) || content.type !== "web_fetch_result") return content; + const document = isRecord(content.content) ? content.content : undefined; + return { + type: "web_fetch_result", + url: content.url, + content: document + ? { + type: "document", + source: normalizeAnthropicWebFetchSource(document.source), + ...(document.title === undefined ? {} : { title: document.title }), + ...(document.citations === undefined ? {} : { citations: document.citations }), + } + : content.content, + retrievedAt: content.retrieved_at, + }; +} + +function normalizeAnthropicCodeExecutionContent(content: unknown): unknown { + const error = normalizeAnthropicProviderErrorContent( + content, + "code_execution_tool_result_error", + CODE_EXECUTION_ERROR_CODES, + ); + if (error !== undefined) return error; + if (!isRecord(content)) return content; + if (content.type === "code_execution_result") { + return { + type: "code_execution_result", + stdout: content.stdout, + stderr: content.stderr, + returnCode: content.return_code, + content: normalizeAnthropicFileOutputs(content.content, "code_execution_output"), + }; + } + if (content.type === "encrypted_code_execution_result") { + return { + type: "encrypted_code_execution_result", + encryptedStdout: content.encrypted_stdout, + stderr: content.stderr, + returnCode: content.return_code, + content: normalizeAnthropicFileOutputs(content.content, "code_execution_output"), + }; + } + return content; +} + +function normalizeAnthropicBashCodeExecutionContent(content: unknown): unknown { + const error = normalizeAnthropicProviderErrorContent( + content, + "bash_code_execution_tool_result_error", + BASH_CODE_EXECUTION_ERROR_CODES, + ); + if (error !== undefined) return error; + if (!isRecord(content) || content.type !== "bash_code_execution_result") return content; + return { + type: "bash_code_execution_result", + stdout: content.stdout, + stderr: content.stderr, + returnCode: content.return_code, + content: normalizeAnthropicFileOutputs(content.content, "bash_code_execution_output"), + }; +} + +function normalizeAnthropicTextEditorCodeExecutionContent(content: unknown): unknown { + const error = normalizeAnthropicProviderErrorContent( + content, + "text_editor_code_execution_tool_result_error", + TEXT_EDITOR_CODE_EXECUTION_ERROR_CODES, + ); + if (error !== undefined) return error; + if (!isRecord(content)) return content; + switch (content.type) { + case "text_editor_code_execution_view_result": + return { + type: "text_editor_code_execution_view_result", + content: content.content, + fileType: content.file_type, + numLines: content.num_lines, + startLine: content.start_line, + totalLines: content.total_lines, + }; + case "text_editor_code_execution_create_result": + return { + type: "text_editor_code_execution_create_result", + isFileUpdate: content.is_file_update, + }; + case "text_editor_code_execution_str_replace_result": + return { + type: "text_editor_code_execution_str_replace_result", + lines: content.lines, + oldStart: content.old_start, + oldLines: content.old_lines, + newStart: content.new_start, + newLines: content.new_lines, + }; + default: + return content; + } +} + +function normalizeAnthropicProviderErrorContent( + content: unknown, + expectedType: string, + allowedCodes: ReadonlySet, +): Record | undefined { + if (!isRecord(content) || !hasValidAnthropicErrorContent(content, expectedType, allowedCodes)) { + return undefined; + } + const detail = expectedType === "text_editor_code_execution_tool_result_error" && + typeof content.error_message === "string" + ? content.error_message + : undefined; + return { + name: "AnthropicServerToolResultError", + provider: "anthropic", + code: content.error_code, + ...(detail === undefined ? {} : { detail }), + }; +} + +function normalizeAnthropicProviderToolResultContent( + block: Record, +): unknown { + const normalized = (() => { + switch (block.type) { + case "web_search_tool_result": + case "web_search_tool_result_error": + return normalizeAnthropicWebSearchContent(block.content); + case "web_fetch_tool_result": + case "web_fetch_tool_result_error": + return normalizeAnthropicWebFetchContent(block.content); + case "code_execution_tool_result": + case "code_execution_tool_result_error": + return normalizeAnthropicCodeExecutionContent(block.content); + case "bash_code_execution_tool_result": + case "bash_code_execution_tool_result_error": + return normalizeAnthropicBashCodeExecutionContent(block.content); + case "text_editor_code_execution_tool_result": + case "text_editor_code_execution_tool_result_error": + return normalizeAnthropicTextEditorCodeExecutionContent(block.content); + default: + return block.content; + } + })(); + if ( + isRecord(normalized) && + normalized.name === "AnthropicServerToolResultError" && + typeof normalized.code === "string" && + typeof block.tool_use_id === "string" + ) { + const toolName = getAnthropicToolNameForResultBlock(block); + if (toolName !== undefined) { + return { + ...normalized, + toolCallId: block.tool_use_id, + toolName, + }; + } + } + return normalized; +} + +function getAnthropicToolNameForResultBlock( + block: Record, +): string | undefined { + switch (block.type) { + case "web_search_tool_result": + case "web_search_tool_result_error": + return "web_search"; + case "web_fetch_tool_result": + case "web_fetch_tool_result_error": + return "web_fetch"; + case "code_execution_tool_result": + case "code_execution_tool_result_error": + return "code_execution"; + case "bash_code_execution_tool_result": + case "bash_code_execution_tool_result_error": + return "bash_code_execution"; + case "text_editor_code_execution_tool_result": + case "text_editor_code_execution_tool_result_error": + return "text_editor_code_execution"; + default: + return undefined; + } +} + function expectedAnthropicResultTypeForTool(toolName: string): string | undefined { switch (toolName) { case "web_search": @@ -476,63 +740,109 @@ function expectedAnthropicResultTypeForTool(toolName: string): string | undefine } } -function assertAnthropicProviderToolResultsMatchUses( - checkpoints: readonly ProviderReplayCheckpoint[], +function isExpectedAnthropicResultTypeForTool( + blockType: unknown, + toolName: string, +): boolean { + if (typeof blockType !== "string") return false; + const expectedType = expectedAnthropicResultTypeForTool(toolName); + return expectedType !== undefined && + (blockType === expectedType || blockType === `${expectedType}_error`); +} + +type PendingAnthropicProviderTool = { + readonly name: string; + readonly type: "server_tool_use" | "mcp_tool_use"; +}; + +type AnthropicProviderToolCorrelationState = { + readonly pendingProviderTools: Map; + readonly providerToolUseIds: Set; +}; + +function createAnthropicProviderToolCorrelationState(): AnthropicProviderToolCorrelationState { + return { + pendingProviderTools: new Map(), + providerToolUseIds: new Set(), + }; +} + +function resetAnthropicProviderToolCorrelationState( + state: AnthropicProviderToolCorrelationState, ): void { - const pendingProviderTools = new Map< - string, - { readonly name: string; readonly type: "server_tool_use" | "mcp_tool_use" } - >(); - const providerToolUseIds = new Set(); + state.pendingProviderTools.clear(); + state.providerToolUseIds.clear(); +} + +function validateAnthropicProviderToolCorrelationBlock( + block: Record, + state: AnthropicProviderToolCorrelationState, +): void { + if (block.type === "server_tool_use" || block.type === "mcp_tool_use") { + const toolUse = toCanonicalAnthropicToolCall(block, true); + const toolCallId = String(toolUse.toolCallId); + if (state.providerToolUseIds.has(toolCallId)) { + invalidCheckpoint("checkpoint provider tool-use id is duplicated"); + } + state.providerToolUseIds.add(toolCallId); + state.pendingProviderTools.set(toolCallId, { + name: String(toolUse.toolName), + type: block.type, + }); + return; + } + if (!isAnthropicProviderToolResultBlock(block)) { + return; + } + validateAnthropicProviderToolResultBlock(block); + const toolCallId = String(block.tool_use_id); + const pendingProviderTool = state.pendingProviderTools.get(toolCallId); + if (!pendingProviderTool) { + invalidCheckpoint( + "checkpoint provider tool-result has no matching preceding provider tool-use", + ); + } + if (block.type === "mcp_tool_result") { + if (pendingProviderTool.type !== "mcp_tool_use") { + invalidCheckpoint("checkpoint MCP tool-result type does not match its tool-use"); + } + if (!hasValidAnthropicMcpContent(block.content)) { + invalidCheckpoint("checkpoint MCP tool-result content is malformed"); + } + state.pendingProviderTools.delete(toolCallId); + return; + } + const toolName = pendingProviderTool.name; + if (!isExpectedAnthropicResultTypeForTool(block.type, toolName)) { + invalidCheckpoint("checkpoint provider tool-result type does not match its tool-use"); + } + if (!hasValidAnthropicProviderToolResultContentForTool(block, toolName)) { + invalidCheckpoint("checkpoint provider tool-result content is malformed"); + } + state.pendingProviderTools.delete(toolCallId); +} +function assertAnthropicProviderToolResultsMatchTranscript( + messages: readonly Message[], + checkpoints: readonly ProviderReplayCheckpoint[], +): void { + const checkpointsByMessageId = new Map(); for (const checkpoint of checkpoints) { + checkpointsByMessageId.set(checkpoint.messageId, checkpoint); + } + const state = createAnthropicProviderToolCorrelationState(); + const visitedCheckpointMessageIds = new Set(); + for (const message of messages) { + if (message.role === "user" || message.role === "system") { + resetAnthropicProviderToolCorrelationState(state); + continue; + } + if (visitedCheckpointMessageIds.has(message.id)) continue; + const checkpoint = checkpointsByMessageId.get(message.id); + if (!checkpoint) continue; + visitedCheckpointMessageIds.add(message.id); for (const replayBlock of checkpoint.providerBlocks) { - const block = replayBlock.block; - if (block.type === "server_tool_use" || block.type === "mcp_tool_use") { - const toolUse = toCanonicalAnthropicToolCall(block, true); - const toolCallId = String(toolUse.toolCallId); - if (providerToolUseIds.has(toolCallId)) { - invalidCheckpoint("checkpoint provider tool-use id is duplicated"); - } - providerToolUseIds.add(toolCallId); - pendingProviderTools.set(toolCallId, { - name: String(toolUse.toolName), - type: block.type, - }); - continue; - } - if ( - !isAnthropicProviderToolResultBlock(block) - ) { - continue; - } - validateAnthropicProviderToolResultBlock(block); - const toolCallId = String(block.tool_use_id); - const pendingProviderTool = pendingProviderTools.get(toolCallId); - if (!pendingProviderTool) { - invalidCheckpoint( - "checkpoint provider tool-result has no matching preceding provider tool-use", - ); - } - if (block.type === "mcp_tool_result") { - if (pendingProviderTool.type !== "mcp_tool_use") { - invalidCheckpoint("checkpoint MCP tool-result type does not match its tool-use"); - } - if (!hasValidAnthropicMcpContent(block.content)) { - invalidCheckpoint("checkpoint MCP tool-result content is malformed"); - } - pendingProviderTools.delete(toolCallId); - continue; - } - const toolName = pendingProviderTool.name; - const expectedType = expectedAnthropicResultTypeForTool(toolName); - if (block.type !== expectedType) { - invalidCheckpoint("checkpoint provider tool-result type does not match its tool-use"); - } - if (!hasValidAnthropicProviderToolResultContentForTool(block, toolName)) { - invalidCheckpoint("checkpoint provider tool-result content is malformed"); - } - pendingProviderTools.delete(toolCallId); + validateAnthropicProviderToolCorrelationBlock(replayBlock.block, state); } } } @@ -559,10 +869,15 @@ function toTranscriptVisibleAnthropicReplayPart( return toCanonicalAnthropicToolCall(block, true); default: { if (isAnthropicProviderToolResultBlock(block)) { + const result = normalizeAnthropicProviderToolResultContent(block); return { type: "tool-result", toolCallId: block.tool_use_id, providerExecuted: true, + result, + ...(block.is_error === true || isNormalizedAnthropicProviderErrorResult(result) + ? { isError: true } + : {}), }; } invalidCheckpoint("checkpoint provider block cannot be projected for validation"); @@ -570,6 +885,15 @@ function toTranscriptVisibleAnthropicReplayPart( } } +function isNormalizedAnthropicProviderErrorResult(value: unknown): boolean { + return isRecord(value) && + value.name === "AnthropicServerToolResultError" && + value.provider === "anthropic" && + typeof value.code === "string" && + typeof value.toolCallId === "string" && + typeof value.toolName === "string"; +} + function getProviderExecutedToolCallIds(target: Message): Set { return new Set( target.parts.flatMap((part) => { @@ -606,20 +930,31 @@ function toTranscriptVisibleProviderPart( ? { providerExecuted: true } : {}), }; - case "tool-result": + case "tool-result": { if (!isNonEmptyString(part.toolCallId)) { invalidCheckpoint("checkpoint anchor tool result is malformed"); } + const hasResult = "result" in part || "output" in part; + const rawResult = "result" in part ? part.result : part.output; return { type: "tool-result", toolCallId: part.toolCallId, ...(providerExecutedToolCallIds.has(part.toolCallId) ? { providerExecuted: true } : {}), + ...(hasResult ? { result: unwrapPreparedProviderResult(rawResult) } : {}), + ...(part.isError === true ? { isError: true } : {}), }; + } default: invalidCheckpoint("checkpoint anchor contains an unsupported provider part"); } } +function unwrapPreparedProviderResult(value: unknown): unknown { + return isRecord(value) && value.type === "json" && Object.hasOwn(value, "value") + ? value.value + : value; +} + // A persisted assistant turn carries at most one leading text part, so provider // text blocks split around tool blocks collapse into that leading transcript // entry before either side is compared. Raw replay order stays untouched. @@ -667,6 +1002,34 @@ function getProviderExecutedToolCallIdsFromMessages( return ids; } +function getMessageSegmentForTarget( + messages: readonly Message[], + target: Message, +): readonly Message[] { + const targetIndex = messages.indexOf(target); + if (targetIndex === -1) return [target]; + let start = targetIndex; + while (start > 0) { + const previous = messages[start - 1]!; + if (previous.role === "user" || previous.role === "system") break; + start -= 1; + } + let end = targetIndex + 1; + while (end < messages.length) { + const next = messages[end]!; + if (next.role === "user" || next.role === "system") break; + end += 1; + } + return messages.slice(start, end); +} + +function getProviderExecutedToolCallIdsForTargetSegment( + messages: readonly Message[], + target: Message, +): Set { + return getProviderExecutedToolCallIdsFromMessages(getMessageSegmentForTarget(messages, target)); +} + function projectProviderToolResults( messages: readonly Message[], providerExecutedToolCallIds: ReadonlySet, @@ -785,11 +1148,14 @@ function getRawAssistantMessagesForCheckpoint( function splitAnthropicAssistantReplayBlocks( checkpoint: ProviderReplayCheckpoint, ): Record[][] { + if (checkpoint.providerMessageBlockCounts !== undefined) { + return getRawAssistantMessagesForCheckpoint(checkpoint); + } const segments: Record[][] = []; let current: Record[] = []; for (const replayBlock of checkpoint.providerBlocks) { if (isAnthropicProviderToolResultBlock(replayBlock.block)) { - if (current.length > 0) { + if (current.some((block) => !isAnthropicProviderToolResultBlock(block))) { segments.push(current); current = []; } @@ -875,6 +1241,9 @@ function parseProviderReplayBlock( if (!isRecord(value.block)) { invalidCheckpoint("provider block content must be an object", { index }); } + if (provider === "anthropic") { + assertRawProviderMetadataBounds(value.block, { index }); + } if (provider === "anthropic" && value.block.type === "thinking") { validateAnthropicThinkingReplayBlock(value.block, { index }); } @@ -968,12 +1337,15 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe if (value.providerMessageBlockCounts !== undefined) { if ( !Array.isArray(value.providerMessageBlockCounts) || - value.providerMessageBlockCounts.length === 0 || - value.providerMessageBlockCounts.length > MAX_PROVIDER_REPLAY_MESSAGE_GROUPS + value.providerMessageBlockCounts.length === 0 ) { - invalidCheckpoint( - `checkpoint providerMessageBlockCounts must contain 1-${MAX_PROVIDER_REPLAY_MESSAGE_GROUPS} entries`, - ); + invalidCheckpoint("checkpoint providerMessageBlockCounts must be a non-empty array"); + } + if ( + provider === "anthropic" && + value.providerMessageBlockCounts.length > MAX_ANTHROPIC_RAW_ASSISTANT_MESSAGES + ) { + invalidCheckpoint("checkpoint providerMessageBlockCounts exceeds provider message limit"); } providerMessageBlockCounts = []; let groupedBlockCount = 0; @@ -1008,7 +1380,7 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe ) { invalidCheckpoint("checkpoint emittedAt must be a non-negative integer"); } - return { + const checkpoint: ProviderReplayCheckpoint = { version: 1, messageId: value.messageId, provider, @@ -1019,6 +1391,14 @@ export function parseProviderReplayCheckpoint(value: unknown): ProviderReplayChe ...(value.elapsedMs !== undefined ? { elapsedMs: value.elapsedMs } : {}), ...(value.emittedAt !== undefined ? { emittedAt: value.emittedAt } : {}), }; + if (provider === "anthropic") { + assertRawProviderMetadataBounds( + getRawAssistantMessagesForCheckpoint(checkpoint), + { field: "rawAssistantMessages" }, + "checkpoint raw assistant messages exceeds raw metadata bounds", + ); + } + return checkpoint; } /** @@ -1102,7 +1482,6 @@ export function applyProviderReplayCheckpointsToMessages( target: Message; rawAssistantMessages: Record[][]; }> = []; - const providerExecutedToolCallIds = getProviderExecutedToolCallIdsFromMessages(messages); // Runtime support is a property of the delivery, not of which turns are // still in context: an unsupported checkpoint fails the run even when its // turn is absent, so deployment skew surfaces immediately. @@ -1118,7 +1497,7 @@ export function applyProviderReplayCheckpointsToMessages( }); } } - assertAnthropicProviderToolResultsMatchUses(checkpoints); + assertAnthropicProviderToolResultsMatchTranscript(messages, checkpoints); for (const checkpoint of checkpoints) { const matches = messages.filter((message) => message.id === checkpoint.messageId); if (matches.length === 0) continue; @@ -1132,6 +1511,10 @@ export function applyProviderReplayCheckpointsToMessages( }); } if (assistantMatches.length === 1) { + const providerExecutedToolCallIds = getProviderExecutedToolCallIdsForTargetSegment( + messages, + target, + ); assertCheckpointMatchesAssistantTurn( target, checkpoint, diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index 8c2d32b657..6a9af6e221 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -1076,6 +1076,39 @@ describe("text-generation-runtime-message-converter", () => { "every trailing assistant message must be stripped so the request never ends on an unanswered tool call", ); }); + + it("keeps trailing assistant messages that carry exact provider replay metadata", () => { + const providerMetadata = { + anthropic: { + rawAssistantMessages: [[{ + type: "thinking", + thinking: "", + signature: "replay-signature", + }]], + }, + }; + const trailingAssistant = attachProviderMetadata({ + id: "assistant-replay", + role: "assistant", + parts: [{ + type: "reasoning", + text: "Private reasoning.", + signature: "replay-signature", + }], + } as unknown as Message, providerMetadata); + const messages: Message[] = [ + { id: "u1", role: "user", parts: [{ type: "text", text: "continue" }] }, + trailingAssistant, + ]; + + const requestMessages = convertToTextGenerationRuntimeRequestMessages(messages); + + assertEquals(requestMessages.at(-1), { + role: "assistant", + content: [{ type: "text", text: "" }], + providerMetadata, + }); + }); }); describe("attachment reachability across the conversion entry points", () => { diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index f940aceaa3..21e3f7d798 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -592,9 +592,16 @@ export function convertToTextGenerationRuntimeRequestMessages( ): TextGenerationRuntimeMessage[] { const requestMessages = convertToTextGenerationRuntimeMessages(messages, options); - while (requestMessages.at(-1)?.role === "assistant") { + while (shouldTrimTrailingAssistantRequestMessage(requestMessages.at(-1))) { requestMessages.pop(); } return requestMessages; } + +function shouldTrimTrailingAssistantRequestMessage( + message: TextGenerationRuntimeMessage | undefined, +): boolean { + if (message?.role !== "assistant") return false; + return message.providerMetadata === undefined; +} From 7cf18a95903da72b83cf07b8c54df935c77aeb2e Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 20:19:56 +0200 Subject: [PATCH 39/56] fix(agent): reject outer provider tool-result error block types Anthropic reports a provider tool failure with the ordinary outer result type and the error record inside `content`; there is no outer `web_search_tool_result_error` block type on the wire. The replay checkpoint validator accepted the five invented outer `*_tool_result_error` types, so such a checkpoint passed the typed boundary and was attached to `rawAssistantMessages`, and `validateAnthropicRawAssistantMessages` then rejected the unsupported outer type with a bare TypeError during request construction. Drop the outer error variants from the accepted set so the checkpoint fails at the typed boundary with PROVIDER_REPLAY_CHECKPOINT_INVALID, and remove the switch labels and the `${expectedType}_error` alternative that only served them. --- src/agent/runtime/provider-replay.test.ts | 135 +++++++++++++++++++++- src/agent/runtime/provider-replay.ts | 22 +--- 2 files changed, 139 insertions(+), 18 deletions(-) diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index e85dd16f76..5c0384affa 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -1380,7 +1380,7 @@ describe("agent/runtime/provider-replay", () => { caller: { type: "direct" }, }; const providerResult = { - type: "web_search_tool_result_error", + type: "web_search_tool_result", tool_use_id: providerCall.id, caller: { type: "direct" }, content: { @@ -1440,6 +1440,139 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should reject outer provider tool-result error block types", () => { + const variants: ReadonlyArray<{ + readonly toolName: string; + readonly resultType: string; + readonly content: Record; + }> = [ + { + toolName: "web_search", + resultType: "web_search_tool_result", + content: { type: "web_search_tool_result_error", error_code: "max_uses_exceeded" }, + }, + { + toolName: "web_fetch", + resultType: "web_fetch_tool_result", + content: { type: "web_fetch_tool_result_error", error_code: "url_not_accessible" }, + }, + { + toolName: "code_execution", + resultType: "code_execution_tool_result", + content: { type: "code_execution_tool_result_error", error_code: "unavailable" }, + }, + { + toolName: "bash_code_execution", + resultType: "bash_code_execution_tool_result", + content: { + type: "bash_code_execution_tool_result_error", + error_code: "output_file_too_large", + }, + }, + { + toolName: "text_editor_code_execution", + resultType: "text_editor_code_execution_tool_result", + content: { + type: "text_editor_code_execution_tool_result_error", + error_code: "file_not_found", + }, + }, + ]; + + for (const variant of variants) { + const providerCall = { + type: "server_tool_use", + id: `srvtool-${variant.toolName}`, + name: variant.toolName, + input: { query: "provider replay" }, + caller: { type: "direct" }, + }; + const providerResult = (resultType: string) => ({ + type: resultType, + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: variant.content, + }); + const createTarget = () => + ({ + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: { + type: "json", + value: { + name: "AnthropicServerToolResultError", + provider: "anthropic", + code: variant.content.error_code, + toolCallId: providerCall.id, + toolName: providerCall.name, + }, + }, + isError: true, + providerExecuted: true, + }, + ], + timestamp: 1, + }) as Message; + const createCheckpoint = (resultType: string): ProviderReplayCheckpoint => ({ + version: 1, + messageId: "assistant-message-1", + provider: "anthropic", + providerBlocks: [providerCall, providerResult(resultType)].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }); + + // Anthropic never emits an outer `*_tool_result_error` block. Accepting one + // here would defer the failure to request construction, where the provider + // parser rejects the unsupported outer type with a bare TypeError. + const rejected = createTarget(); + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages( + [rejected], + [createCheckpoint(`${variant.resultType}_error`)], + ) + ); + assertEquals( + readAttachedProviderMetadata(rejected), + undefined, + `${variant.toolName} outer error block must not attach`, + ); + + // The real wire shape keeps the ordinary outer type and carries the error + // record inside `content`; it must still replay. + const accepted = createTarget(); + applyProviderReplayCheckpointsToMessages( + [accepted], + [createCheckpoint(variant.resultType)], + ); + assertEquals( + readAttachedProviderMetadata(accepted), + { + anthropic: { + rawAssistantMessages: [[providerCall, providerResult(variant.resultType)]], + }, + }, + `${variant.toolName} ordinary error result must replay`, + ); + } + }); + it("should reset provider-executed ids at transcript boundaries before matching anchors", () => { const reusedToolCallId = "reused-tool-call"; const historicalProviderCall = { diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 7c12c6375f..1ece0efb17 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -31,17 +31,16 @@ const CHECKPOINT_KEYS = new Set([ "emittedAt", ]); const BLOCK_KEYS = new Set(["type", "provider", "block"]); +// Anthropic reports provider tool failures with the ordinary outer result type +// and the error record inside `content`; there is no outer `*_tool_result_error` +// block type, so accepting one here would only defer the failure to request +// construction, where the provider parser rejects it with a bare TypeError. const ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES = new Set([ "web_search_tool_result", - "web_search_tool_result_error", "web_fetch_tool_result", - "web_fetch_tool_result_error", "code_execution_tool_result", - "code_execution_tool_result_error", "bash_code_execution_tool_result", - "bash_code_execution_tool_result_error", "text_editor_code_execution_tool_result", - "text_editor_code_execution_tool_result_error", "mcp_tool_result", ]); const WEB_SEARCH_ERROR_CODES = new Set([ @@ -663,19 +662,14 @@ function normalizeAnthropicProviderToolResultContent( const normalized = (() => { switch (block.type) { case "web_search_tool_result": - case "web_search_tool_result_error": return normalizeAnthropicWebSearchContent(block.content); case "web_fetch_tool_result": - case "web_fetch_tool_result_error": return normalizeAnthropicWebFetchContent(block.content); case "code_execution_tool_result": - case "code_execution_tool_result_error": return normalizeAnthropicCodeExecutionContent(block.content); case "bash_code_execution_tool_result": - case "bash_code_execution_tool_result_error": return normalizeAnthropicBashCodeExecutionContent(block.content); case "text_editor_code_execution_tool_result": - case "text_editor_code_execution_tool_result_error": return normalizeAnthropicTextEditorCodeExecutionContent(block.content); default: return block.content; @@ -704,19 +698,14 @@ function getAnthropicToolNameForResultBlock( ): string | undefined { switch (block.type) { case "web_search_tool_result": - case "web_search_tool_result_error": return "web_search"; case "web_fetch_tool_result": - case "web_fetch_tool_result_error": return "web_fetch"; case "code_execution_tool_result": - case "code_execution_tool_result_error": return "code_execution"; case "bash_code_execution_tool_result": - case "bash_code_execution_tool_result_error": return "bash_code_execution"; case "text_editor_code_execution_tool_result": - case "text_editor_code_execution_tool_result_error": return "text_editor_code_execution"; default: return undefined; @@ -746,8 +735,7 @@ function isExpectedAnthropicResultTypeForTool( ): boolean { if (typeof blockType !== "string") return false; const expectedType = expectedAnthropicResultTypeForTool(toolName); - return expectedType !== undefined && - (blockType === expectedType || blockType === `${expectedType}_error`); + return expectedType !== undefined && blockType === expectedType; } type PendingAnthropicProviderTool = { From a5d851db0a08b511ebe4ec8f64a7ba0f4b981a66 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 20:20:04 +0200 Subject: [PATCH 40/56] test(chat): cover tool-pair repair and split replay metadata branches `repairToolPairs` gained a `hasImmediateToolResult` guard for provider-executed calls, and the runtime converter gained `createSplitAnthropicRawAssistantMetadata`; neither had a regression test. Cover the provider-executed guard both ways, the inline provider result, the partial steal that keeps unrelated results in a later tool message, the prepend into an adjacent tool message, the scan that stops at the next user turn, and the split of anthropic raw assistant messages across split assistant turns including the segment-count mismatch that still throws. --- ...neration-runtime-message-converter.test.ts | 48 ++++ src/chat/message-prep.test.ts | 210 +++++++++++++++++- 2 files changed, 256 insertions(+), 2 deletions(-) diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index 6a9af6e221..e9f30153b9 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -545,6 +545,54 @@ describe("text-generation-runtime-message-converter", () => { ); }); + it("splits anthropic raw assistant messages across the split assistant turns", () => { + const rawToolUse = { + type: "tool_use", + id: "call-1", + name: "lookup", + input: { query: "veryfront" }, + }; + const rawText = { type: "text", text: "Found it." }; + const message = attachProviderMetadata({ + id: "a-split", + role: "assistant", + parts: [ + { type: "tool-lookup", toolCallId: "call-1", toolName: "lookup", args: rawToolUse.input }, + { type: "tool-result", toolCallId: "call-1", toolName: "lookup", result: { matches: 1 } }, + { type: "text", text: "Found it." }, + ], + } as unknown as Message, { anthropic: { rawAssistantMessages: [[rawToolUse], [rawText]] } }); + + const converted = convertToTextGenerationRuntimeMessages([message]); + const assistantMetadata = converted + .filter((entry) => entry.role === "assistant") + .map((entry) => entry.providerMetadata); + + assertEquals(assistantMetadata, [ + { anthropic: { rawAssistantMessages: [[rawToolUse]] } }, + { anthropic: { rawAssistantMessages: [[rawText]] } }, + ]); + }); + + it("rejects anthropic raw assistant messages that do not match the split segments", () => { + const message = attachProviderMetadata({ + id: "a-split", + role: "assistant", + parts: [ + { type: "tool-lookup", toolCallId: "call-1", toolName: "lookup", args: { q: "vf" } }, + { type: "tool-result", toolCallId: "call-1", toolName: "lookup", result: { matches: 1 } }, + { type: "text", text: "Found it." }, + ], + } as unknown as Message, { + anthropic: { rawAssistantMessages: [[{ type: "text", text: "Found it." }]] }, + }); + + const error = assertThrows(() => convertToTextGenerationRuntimeMessages([message])); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "provider-metadata-split-unsupported"); + assertEquals(error.context, { assistantSegmentCount: 2 }); + }); + it("omits provider-executed tool-only assistant messages from replay", () => { const messages = [ { id: "u1", role: "user", parts: [{ type: "text", text: "search tax guidance" }] }, diff --git a/src/chat/message-prep.test.ts b/src/chat/message-prep.test.ts index 6c171779ac..f83ef8a9f9 100644 --- a/src/chat/message-prep.test.ts +++ b/src/chat/message-prep.test.ts @@ -1,6 +1,13 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assert, assertEquals, assertMatch, assertStringIncludes, assertThrows } from "#std/assert"; -import type { ChatUiMessage, ProviderModelMessage } from "./types.ts"; +import { + assert, + assertEquals, + assertMatch, + assertStrictEquals, + assertStringIncludes, + assertThrows, +} from "#std/assert"; +import type { ChatToolResultPart, ChatUiMessage, ProviderModelMessage } from "./types.ts"; import { withProviderModelMessageSourceId } from "./conversation.ts"; import type { HistoricalToolInputCompactionDiagnostic } from "./message-prep.ts"; import { @@ -219,6 +226,205 @@ Deno.test("repairToolPairs never steals a matching result from a later user turn ]); }); +Deno.test("repairToolPairs leaves a provider-executed call whose result is the next tool message", () => { + const messages = [ + { + role: "assistant", + content: [{ + type: "tool-call", + toolCallId: "srv-search", + toolName: "web_search", + input: { query: "veryfront" }, + providerExecuted: true, + }], + }, + { + role: "tool", + content: [{ + type: "tool-result", + toolCallId: "srv-search", + toolName: "web_search", + output: { type: "json", value: { results: [] } }, + }], + }, + ] satisfies ProviderModelMessage[]; + + const repaired = repairToolPairs(messages); + + assertStrictEquals(repaired, messages, "an already-paired turn must not be rewritten"); + assertEquals( + JSON.stringify(repaired).match(/\[tool result unavailable\]/g), + null, + "the provider result that follows the call must not be shadowed by a placeholder", + ); +}); + +Deno.test("repairToolPairs appends an inline placeholder for an unanswered provider-executed call", () => { + const messages = [ + { + role: "assistant", + content: [{ + type: "tool-call", + toolCallId: "srv-fetch", + toolName: "web_fetch", + input: { url: "https://example.com" }, + providerExecuted: true, + }], + }, + ] satisfies ProviderModelMessage[]; + + assertEquals(repairToolPairs(messages), [ + { + role: "assistant", + content: [ + messages[0]!.content[0]!, + { + type: "tool-result", + toolCallId: "srv-fetch", + toolName: "web_fetch", + output: { type: "text", value: "[tool result unavailable]" }, + }, + ], + }, + ]); +}); + +Deno.test("repairToolPairs leaves a provider-executed call whose result is already inline", () => { + const messages = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "srv-exec", + toolName: "code_execution", + input: { code: "1 + 1" }, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: "srv-exec", + toolName: "code_execution", + output: { type: "json", value: { stdout: "2" } }, + }, + ], + }, + ] satisfies ProviderModelMessage[]; + + const repaired = repairToolPairs(messages); + + assertStrictEquals(repaired, messages, "an inline provider result must not be rewritten"); + assertEquals( + JSON.stringify(repaired).match(/\[tool result unavailable\]/g), + null, + "the inline provider result must not be duplicated by a placeholder", + ); +}); + +Deno.test("repairToolPairs keeps unrelated results when pulling one out of a later tool message", () => { + const unrelatedResult = { + type: "tool-result", + toolCallId: "call-z", + toolName: "lookup", + output: { type: "json", value: { from: "unrelated call" } }, + } satisfies ChatToolResultPart; + const otherPendingResult = { + type: "tool-result", + toolCallId: "call-b", + toolName: "lookup", + output: { type: "json", value: { from: "other pending call" } }, + } satisfies ChatToolResultPart; + const matchingResult = { + type: "tool-result", + toolCallId: "call-a", + toolName: "lookup", + output: { type: "json", value: { from: "matching call" } }, + } satisfies ChatToolResultPart; + const messages = [ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-a", toolName: "lookup", input: {} }], + }, + { role: "assistant", content: [{ type: "text", text: "Still working." }] }, + { role: "tool", content: [unrelatedResult] }, + { role: "tool", content: [otherPendingResult, matchingResult] }, + ] satisfies ProviderModelMessage[]; + + assertEquals(repairToolPairs(messages), [ + messages[0]!, + { role: "tool", content: [matchingResult] }, + messages[1]!, + messages[2]!, + { role: "tool", content: [otherPendingResult] }, + ]); +}); + +Deno.test("repairToolPairs prepends a recovered result into the adjacent tool message", () => { + const adjacentResult = { + type: "tool-result", + toolCallId: "call-c", + toolName: "lookup", + output: { type: "json", value: { from: "already adjacent" } }, + } satisfies ChatToolResultPart; + const strandedResult = { + type: "tool-result", + toolCallId: "call-a", + toolName: "lookup", + output: { type: "json", value: { from: "stranded result" } }, + } satisfies ChatToolResultPart; + const messages = [ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-a", toolName: "lookup", input: {} }], + }, + { role: "tool", content: [adjacentResult] }, + { role: "assistant", content: [{ type: "text", text: "Still working." }] }, + { role: "tool", content: [strandedResult] }, + ] satisfies ProviderModelMessage[]; + + assertEquals(repairToolPairs(messages), [ + messages[0]!, + { role: "tool", content: [strandedResult, adjacentResult] }, + messages[2]!, + ]); +}); + +Deno.test("repairToolPairs stops scanning for a stranded result at the next user turn", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-a", toolName: "lookup", input: {} }], + }, + { role: "assistant", content: [{ type: "text", text: "Still working." }] }, + { role: "user", content: "Start a new turn." }, + { + role: "tool", + content: [{ + type: "tool-result", + toolCallId: "call-a", + toolName: "lookup", + output: { type: "json", value: { from: "later turn" } }, + }], + }, + ] satisfies ProviderModelMessage[]; + + assertEquals(repairToolPairs(messages), [ + messages[0]!, + { + role: "tool", + content: [{ + type: "tool-result", + toolCallId: "call-a", + toolName: "lookup", + output: { type: "text", value: "[tool result unavailable]" }, + }], + }, + messages[1]!, + messages[2]!, + messages[3]!, + ]); +}); + Deno.test("sanitizeProviderModelMessages drops inline preview screenshots but keeps other data-URL images", () => { const messages = [ { From 988879c238c8081c3eb18e8bd1ce87679d1d28f6 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 20:20:04 +0200 Subject: [PATCH 41/56] chore(docs): regenerate api reference source line anchors `deno task docs` output drifted from the committed reference: the source links for HostedChatRequestParsed point at pre-shift line numbers in src/agent/hosted/chat-request-parser.ts. --- docs/api-reference/veryfront/agent.md | 132 +++++++++++++------------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 67e96a665d..5c8d0b2f29 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -228,29 +228,29 @@ Request payload for parsed hosted chat. | Property | Type | Description | Source | | ------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -| `agentId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L71) | -| `userId` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L72) | -| `authToken` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L73) | -| `serverEnvelopeVerified?` | `true` | True only after a server envelope credential is verified and bound to this run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L75) | -| `serverResolvedProviderReplayCheckpoints?` | `unknown` | Provider-native replay state resolved by the server outside forwardedProps so large opaque provider blocks do not consume the public forwardedProps budget. Ignored unless `serverEnvelopeVerified` is true. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L81) | -| `serverResolvedIntegrationToolNames?` | `readonly string[]` | Integration tools the control plane resolved for this run, taken from the verified run-event token rather than the request body. Absent unless a token verified, so a forged body can never introduce it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L87) | -| `messages` | `ChatUiMessage[]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L88) | -| `validatedContext` | `ChatRequestContext` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L89) | -| `projectId` | `string \| null` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L90) | -| `projectSlug?` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L91) | -| `conversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L92) | -| `parentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L93) | -| `upstreamParentConversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L94) | -| `upstreamParentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L95) | -| `spawnedFromToolCallId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L96) | -| `taskId?` | `string` | Durable task identity supplied only by a signed runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L98) | -| `model` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L99) | -| `allowDelegation` | `boolean \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L100) | -| `forwardedProps` | `HostedChatRequest["forwardedProps"]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L101) | -| `runtimeOverrides` | `ChatRuntimeOverrides \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L102) | -| `durableRootRun` | `DurableRootRunDescriptor \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L103) | -| `persistLatestUserMessageBeforeDurableRun` | `boolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L104) | -| `agentConfig?` | `RuntimeAgentMarkdownDefinition` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L105) | +| `agentId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L72) | +| `userId` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L73) | +| `authToken` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L74) | +| `serverEnvelopeVerified?` | `true` | True only after a server envelope credential is verified and bound to this run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L76) | +| `serverResolvedProviderReplayCheckpoints?` | `unknown` | Provider-native replay state resolved by the server outside forwardedProps so large opaque provider blocks do not consume the public forwardedProps budget. Ignored unless `serverEnvelopeVerified` is true. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L82) | +| `serverResolvedIntegrationToolNames?` | `readonly string[]` | Integration tools the control plane resolved for this run, taken from the verified run-event token rather than the request body. Absent unless a token verified, so a forged body can never introduce it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L88) | +| `messages` | `ChatUiMessage[]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L89) | +| `validatedContext` | `ChatRequestContext` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L90) | +| `projectId` | `string \| null` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L91) | +| `projectSlug?` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L92) | +| `conversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L93) | +| `parentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L94) | +| `upstreamParentConversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L95) | +| `upstreamParentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L96) | +| `spawnedFromToolCallId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L97) | +| `taskId?` | `string` | Durable task identity supplied only by a signed runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L99) | +| `model` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L100) | +| `allowDelegation` | `boolean \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L101) | +| `forwardedProps` | `HostedChatRequest["forwardedProps"]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L102) | +| `runtimeOverrides` | `ChatRuntimeOverrides \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L103) | +| `durableRootRun` | `DurableRootRunDescriptor \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L104) | +| `persistLatestUserMessageBeforeDurableRun` | `boolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L105) | +| `agentConfig?` | `RuntimeAgentMarkdownDefinition` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L106) | ### `PrepareHostedConversationRootRunContextInput` @@ -495,19 +495,19 @@ Input delivered to a hosted agent-service detached execution callback. | `PROJECT_STEERING_FILE_MUTATION_TOOL_NAMES` | Shared project steering file mutation tool names value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/project/steering-mutation.ts#L15) | | `ROOT_OWNED_CHILD_RESULT_INSTRUCTION` | Shared root owned child result instruction value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L39) | | `RUNTIME_LOAD_SKILL_DESCRIPTION` | Shared runtime load skill description value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/load-skill-tool.ts#L68) | -| `RuntimeAgentContextItemSchema` | Schema for runtime agent context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L130) | -| `RuntimeAgentIdSchema` | Schema for runtime agent ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L63) | -| `RuntimeAgentProjectContextSchema` | Schema for runtime agent project context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L260) | -| `RuntimeAgentRunContextSchema` | Schema for runtime agent run context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L343) | -| `RuntimeAgentRunIdSchema` | Schema for runtime agent run ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L32) | -| `RuntimeAgentRunInvocationSchema` | Schema for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L395) | -| `RuntimeAgentServiceIdSchema` | Schema for runtime agent service ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L56) | -| `RuntimeAgentSourceContextSchema` | Schema for runtime agent source context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L153) | -| `RuntimeAgentTargetKindSchema` | Schema for runtime agent target kind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L162) | -| `RuntimeAgentToolCallIdSchema` | Schema for runtime agent tool call ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L44) | -| `RuntimeAgentToolNameSchema` | Schema for runtime agent tool name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L75) | -| `RuntimeAgentToolSchema` | Schema for runtime agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L100) | -| `RuntimeAgentValidatedClaimsSchema` | Schema for runtime agent validated claims. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L274) | +| `RuntimeAgentContextItemSchema` | Schema for runtime agent context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L131) | +| `RuntimeAgentIdSchema` | Schema for runtime agent ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L64) | +| `RuntimeAgentProjectContextSchema` | Schema for runtime agent project context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L261) | +| `RuntimeAgentRunContextSchema` | Schema for runtime agent run context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L344) | +| `RuntimeAgentRunIdSchema` | Schema for runtime agent run ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L33) | +| `RuntimeAgentRunInvocationSchema` | Schema for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L396) | +| `RuntimeAgentServiceIdSchema` | Schema for runtime agent service ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L57) | +| `RuntimeAgentSourceContextSchema` | Schema for runtime agent source context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L154) | +| `RuntimeAgentTargetKindSchema` | Schema for runtime agent target kind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L163) | +| `RuntimeAgentToolCallIdSchema` | Schema for runtime agent tool call ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L45) | +| `RuntimeAgentToolNameSchema` | Schema for runtime agent tool name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L76) | +| `RuntimeAgentToolSchema` | Schema for runtime agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L101) | +| `RuntimeAgentValidatedClaimsSchema` | Schema for runtime agent validated claims. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L275) | | `RuntimeSkillFrontmatterSchema` | Schema for runtime skill frontmatter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L274) | | `SLASH_COMMAND_ARTIFACT_REMINDER` | Shared slash command artifact reminder value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L118) | | `SYNTHESIZE_DELEGATED_FINDINGS_IN_ROOT_VOICE` | Shared synthesize delegated findings in root voice value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L23) | @@ -575,9 +575,9 @@ Input delivered to a hosted agent-service detached execution callback. | `buildInvokeAgentFollowupInstruction` | Builds invoke agent followup instruction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L100) | | `buildInvokeAgentTraceAttributes` | Builds invoke agent trace attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L235) | | `buildParsedAgentServiceAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L564) | +| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L568) | | `buildParsedHostedAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L564) | +| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L568) | | `buildProjectContextPromptBlock` | Builds the shared project-context prompt block (project reference + branch). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L103) | | `buildProjectInstructionsPromptBlock` | Builds the project-instructions prompt block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L120) | | `buildProjectServiceTraceAttributes` | Builds Datadog unified service trace attributes for a hosted project run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L93) | @@ -586,7 +586,7 @@ Input delivered to a hosted agent-service detached execution callback. | `buildRootOwnedChildRunResultHint` | Builds root owned child run result hint. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/result-summary.ts#L348) | | `buildRootOwnedChildRunResultText` | Builds root owned child run result text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/result-summary.ts#L333) | | `buildRootOwnedDelegatedFindingsInstruction` | Builds root owned delegated findings instruction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L34) | -| `buildRuntimeAgentControlPlaneStreamRequestFromInvocation` | Builds runtime agent control plane stream request from invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L452) | +| `buildRuntimeAgentControlPlaneStreamRequestFromInvocation` | Builds runtime agent control plane stream request from invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L453) | | `buildRuntimeAvailableSkillsPromptBlock` | Builds a bounded, injection-safe runtime available-skills prompt. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-prompt.ts#L457) | | `buildRuntimeLoadedSkillResponse` | Build a bounded loaded-skill response and fail closed on invalid metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1097) | | `buildRuntimeSkillDefinition` | Build a bounded, immutable runtime skill definition. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L913) | @@ -867,7 +867,7 @@ Input delivered to a hosted agent-service detached execution callback. | `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeRuntimeSkillReferencePath` | Normalizes and bounds a portable runtime skill reference path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1011) | -| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L571) | +| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L575) | | `parseAgentServiceConfig` | Configuration used by parse agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L159) | | `parseAgUiContextBoolean` | Parses AG-UI context boolean. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L56) | | `parseAgUiContextJsonValue` | Parses AG-UI context JSON value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L27) | @@ -882,12 +882,12 @@ Input delivered to a hosted agent-service detached execution callback. | `parseAppendConversationRunEventsErrorBody` | Parses append conversation run events error body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L54) | | `parseDataStreamSseEvents` | Parses data stream sse events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/data-stream.ts#L14) | | `parseHostedAgentServiceConfig` | Configuration used by parse hosted agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L167) | -| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L571) | +| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L575) | | `parseRuntimeAgentMarkdownDefinition` | Definition for parse runtime agent markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L192) | -| `parseRuntimeAgentRunInvocation` | Parses runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L479) | -| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L610) | -| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L610) | -| `parseRuntimeAgentRunInvocationOrError` | Error shape for parse runtime agent run invocation or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L486) | +| `parseRuntimeAgentRunInvocation` | Parses runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L480) | +| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L614) | +| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L614) | +| `parseRuntimeAgentRunInvocationOrError` | Error shape for parse runtime agent run invocation or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L489) | | `parseRuntimeSkillDocument` | Parses a bounded runtime skill document and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L561) | | `parseRuntimeSkillMetadata` | Parses bounded runtime skill metadata and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L569) | | `parseToolInputObject` | Parses tool input object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-input.ts#L135) | @@ -1001,7 +1001,7 @@ Input delivered to a hosted agent-service detached execution callback. | `toMirroredAgentServiceStreamPart` | Converts a value to mirrored hosted stream part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L89) | | `toMirroredHostedStreamPart` | Converts a value to mirrored hosted stream part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L89) | | `updateDefaultResearchArtifacts` | Update default research artifacts helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/default-research-artifact-support.ts#L166) | -| `validateRuntimeAgentTargetSelection` | Validates runtime agent target selection. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L171) | +| `validateRuntimeAgentTargetSelection` | Validates runtime agent target selection. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L172) | | `verifyHostedRuntimeSourceBinding` | Verify that a control-plane request addresses the exact source snapshot served here. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-source-binding.ts#L25) | | `veryfrontApiMcpServer` | Veryfront API MCP server helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/veryfront-cloud-agent-service.ts#L71) | | `veryfrontStudioMcpServer` | Veryfront Studio MCP server helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/veryfront-cloud-agent-service.ts#L78) | @@ -1088,9 +1088,9 @@ Input delivered to a hosted agent-service detached execution callback. | `AgentServiceAuthOptions` | Options accepted by hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L157) | | `AgentServiceAuthTrace` | Public API contract for hosted service auth trace. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L116) | | `AgentServiceBootstrapExit` | Public API contract for agent service bootstrap exit. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/bootstrap.ts#L17) | -| `AgentServiceChatProjectAccessError` | Error shape for hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L53) | -| `AgentServiceChatProjectAccessResult` | Result returned from hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L60) | -| `AgentServiceChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L47) | +| `AgentServiceChatProjectAccessError` | Error shape for hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L54) | +| `AgentServiceChatProjectAccessResult` | Result returned from hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L61) | +| `AgentServiceChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L48) | | `AgentServiceChatRuntimeAgent` | Public API contract for hosted chat runtime agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L87) | | `AgentServiceChatRuntimeCreationOptions` | Options accepted by hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L120) | | `AgentServiceChatRuntimeCreationResult` | Result returned from hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L94) | @@ -1466,11 +1466,11 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedChatExecutionRuntime` | Public API contract for hosted chat execution runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L77) | | `HostedChatExecutionRuntimeBootstrap` | Public API contract for hosted chat execution runtime bootstrap. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L99) | | `HostedChatExecutionRuntimeLogger` | Public API contract for hosted chat execution runtime logger. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L84) | -| `HostedChatProjectAccessError` | Error shape for hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L53) | -| `HostedChatProjectAccessResult` | Result returned from hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L60) | +| `HostedChatProjectAccessError` | Error shape for hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L54) | +| `HostedChatProjectAccessResult` | Result returned from hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L61) | | `HostedChatRequest` | Request payload for hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L469) | | `HostedChatRequestInput` | Input payload for hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L471) | -| `HostedChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L47) | +| `HostedChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L48) | | `HostedChatRuntimeAgent` | Public API contract for hosted chat runtime agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L87) | | `HostedChatRuntimeAgentAdapterInput` | Input payload for hosted chat runtime agent adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-agent-adapter.ts#L25) | | `HostedChatRuntimeAgentAdapterRunner` | Public API contract for hosted chat runtime agent adapter runner. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-agent-adapter.ts#L14) | @@ -1680,17 +1680,17 @@ Input delivered to a hosted agent-service detached execution callback. | `NormalizedAgentServiceContract` | Public API contract for normalized agent service contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/definition.ts#L114) | | `NormalizedHostedChatRequest` | Request payload for normalized hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L57) | | `OpenToolCalls` | Public API contract for open tool calls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L110) | -| `ParseAgentServiceChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L109) | +| `ParseAgentServiceChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L110) | | `ParseAgUiSseResponseOptions` | Options for `parseAgUiSseResponse()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/sse-parser.ts#L50) | | `ParsedAgentServiceAgUiRequest` | Request payload for parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L59) | -| `ParsedAgentServiceChatRequest` | Request payload for parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L70) | +| `ParsedAgentServiceChatRequest` | Request payload for parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L71) | | `ParsedAgUiSseRun` | Parsed AG-UI SSE response summary for evals, canaries, and host tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/sse-parser.ts#L30) | | `ParsedHostedAgUiRequest` | Request payload for parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L59) | -| `ParsedHostedChatRequest` | Request payload for parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L70) | +| `ParsedHostedChatRequest` | Request payload for parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L71) | | `ParsedRuntimeSkillDocument` | Public API contract for parsed runtime skill document. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L420) | -| `ParseHostedChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L109) | +| `ParseHostedChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L110) | | `ParseRuntimeAgentMarkdownDefinitionInput` | Input payload for parse runtime agent markdown definition. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L120) | -| `ParseRuntimeAgentRunInvocationHostedChatRequestOptions` | Options accepted when parsing a signed control-plane runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L123) | +| `ParseRuntimeAgentRunInvocationHostedChatRequestOptions` | Options accepted when parsing a signed control-plane runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L124) | | `PersistConversationUserMessageFailure` | Public API contract for persist conversation user message failure. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L70) | | `PrepareAgentRuntimeMessagesFromUiMessagesOptions` | Options accepted by prepare agent runtime messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-preparation.ts#L22) | | `PrepareAgentServiceChatRuntimeMessagesOptions` | Options accepted by prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L64) | @@ -1751,17 +1751,17 @@ Input delivered to a hosted agent-service detached execution callback. | `RunFrameworkForkStepInput` | Input payload for run framework fork step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-stream.ts#L279) | | `RunResumeSessionManagerOptions` | Options accepted by run resume session manager. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/resume-session.ts#L78) | | `RunSessionStatus` | Public API contract for run session status. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/resume-session.ts#L4) | -| `RuntimeAgentContextItem` | Public API contract for runtime agent context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L400) | -| `RuntimeAgentControlPlaneStreamRequest` | Request payload for runtime agent control plane stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L429) | +| `RuntimeAgentContextItem` | Public API contract for runtime agent context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L401) | +| `RuntimeAgentControlPlaneStreamRequest` | Request payload for runtime agent control plane stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L430) | | `RuntimeAgentMarkdownDefinition` | Definition for runtime agent markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L100) | -| `RuntimeAgentProjectContext` | Context for runtime agent project. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L412) | -| `RuntimeAgentRunContext` | Context for runtime agent run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L420) | -| `RuntimeAgentRunInvocation` | Public API contract for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L424) | -| `RuntimeAgentSourceContext` | Context for runtime agent source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L404) | -| `RuntimeAgentTargetKind` | Public API contract for runtime agent target kind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L408) | +| `RuntimeAgentProjectContext` | Context for runtime agent project. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L413) | +| `RuntimeAgentRunContext` | Context for runtime agent run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L421) | +| `RuntimeAgentRunInvocation` | Public API contract for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L425) | +| `RuntimeAgentSourceContext` | Context for runtime agent source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L405) | +| `RuntimeAgentTargetKind` | Public API contract for runtime agent target kind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L409) | | `RuntimeAgentThinkingConfig` | Configuration used by runtime agent thinking. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L25) | -| `RuntimeAgentTool` | Public API contract for runtime agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L398) | -| `RuntimeAgentValidatedClaims` | Public API contract for runtime agent validated claims. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L416) | +| `RuntimeAgentTool` | Public API contract for runtime agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L399) | +| `RuntimeAgentValidatedClaims` | Public API contract for runtime agent validated claims. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L417) | | `RuntimeBuiltinSkillEntriesResult` | Result returned from runtime builtin skill entries. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/builtin-skill-files.ts#L61) | | `RuntimeClientCapability` | Public API contract for runtime client capability. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/client-profile.ts#L48) | | `RuntimeClientProfile` | Public API contract for runtime client profile. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/client-profile.ts#L52) | From f09f2a5c208243796ec44fa424badbb9b2982fd9 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 22:25:52 +0200 Subject: [PATCH 42/56] fix(agent): close replay validation gaps --- src/agent/hosted/chat-preparation.ts | 17 ++- src/agent/hosted/chat-request-parser.ts | 2 + src/agent/hosted/chat-request.test.ts | 42 ++++++ src/agent/hosted/chat-request.ts | 2 + .../hosted/context-budget-manager.test.ts | 40 ++++++ src/agent/hosted/context-budget-manager.ts | 56 +++++++- src/agent/runtime/provider-replay.test.ts | 124 ++++++++++++++++++ src/agent/runtime/provider-replay.ts | 20 ++- 8 files changed, 295 insertions(+), 8 deletions(-) diff --git a/src/agent/hosted/chat-preparation.ts b/src/agent/hosted/chat-preparation.ts index fec3f20914..acc10a005a 100644 --- a/src/agent/hosted/chat-preparation.ts +++ b/src/agent/hosted/chat-preparation.ts @@ -582,6 +582,9 @@ export async function prepareHostedChatExecution< }); const submittedFormInputResult = findSubmittedFormInputResult(normalized.effectiveMessages); const historicalToolInputCompactions: HistoricalToolInputCompactionDiagnostic[] = []; + const providerReplayCheckpointMessageIds = input.serverResolvedProviderReplayCheckpoints?.map( + (checkpoint) => checkpoint.messageId, + ); const preparedMessages = await prepareHostedChatRuntimeMessages( normalized.effectiveMessages, { @@ -593,9 +596,7 @@ export async function prepareHostedChatExecution< runtimeConfig: runtimePreparation.runtimeConfig, }), abortSignal: input.abortSignal, - providerReplayCheckpointMessageIds: input.serverResolvedProviderReplayCheckpoints?.map( - (checkpoint) => checkpoint.messageId, - ), + providerReplayCheckpointMessageIds, historicalToolInputRetention: { diagnostics: historicalToolInputCompactions, }, @@ -610,7 +611,15 @@ export async function prepareHostedChatExecution< let budgetedContext: Awaited> | undefined; if (input.contextBudget) { try { - budgetedContext = await applyContextBudget(finalMessages, input.contextBudget); + budgetedContext = await applyContextBudget(finalMessages, { + ...input.contextBudget, + atomicMessageIds: [ + ...new Set([ + ...(input.contextBudget.atomicMessageIds ?? []), + ...(providerReplayCheckpointMessageIds ?? []), + ]), + ], + }); } catch (error) { input.contextBudget.logger?.error?.("Hosted chat context compaction failed", { error: error instanceof Error ? error.message : String(error), diff --git a/src/agent/hosted/chat-request-parser.ts b/src/agent/hosted/chat-request-parser.ts index 0154147ae0..718a4cf5f5 100644 --- a/src/agent/hosted/chat-request-parser.ts +++ b/src/agent/hosted/chat-request-parser.ts @@ -379,6 +379,7 @@ function normalizeHostedChatRequestMessages( toolName, input, state: mapRawToolCallState(state), + ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }); continue; } @@ -399,6 +400,7 @@ function normalizeHostedChatRequestMessages( ...(part.is_error === true ? { errorText: stringifyUnknown(part.output ?? "Tool error") } : {}), + ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; const existingIndex = partIndexByToolCallId.get(toolCallId); diff --git a/src/agent/hosted/chat-request.test.ts b/src/agent/hosted/chat-request.test.ts index 6e4cb7bd89..4bc81da1e7 100644 --- a/src/agent/hosted/chat-request.test.ts +++ b/src/agent/hosted/chat-request.test.ts @@ -1502,6 +1502,48 @@ describe("agent/hosted-chat-request", () => { assertEquals(request.messages[0]?.parts as unknown, rawReplayParts); }); + it("preserves provider ownership on raw replay parts from runtime invocations", async () => { + const providerOwnedParts = rawReplayParts.map((part) => ({ + ...part, + providerExecuted: true, + })); + const invocation = RuntimeAgentRunInvocationSchema.parse({ + ...createRuntimeInvocation(), + messages: [{ + id: "assistant-message-1", + role: "assistant", + parts: providerOwnedParts, + }], + }); + const parsed = await parseRuntimeAgentRunInvocationHostedChatRequestFromRequest( + new Request("https://agent.example.com/api/control-plane/runs/run_root_1/stream", { + method: "POST", + headers: { "X-Veryfront-Run-Event-Token": "verified-event-token" }, + body: JSON.stringify(invocation), + }), + { + authenticate: () => Promise.resolve({ userId, authToken: "token_1" }), + verifyProjectAccess: () => Promise.resolve({ success: true }), + verifyRunEventAppendToken: () => Promise.resolve(true), + runtimeSource, + }, + ); + + if (parsed instanceof Response) { + throw new Error("Expected parsed runtime invocation"); + } + + assertEquals(parsed.messages[0]?.parts, [{ + type: "tool_call", + toolCallId: rawReplayToolCallPart.id, + toolName: replayToolName, + input: rawReplayToolCallPart.input, + state: "output-available", + output: replayOutput, + providerExecuted: true, + }]); + }); + it("normalizes persisted uploaded file parts from runtime invocations", async () => { const invocation = RuntimeAgentRunInvocationSchema.parse({ ...createRuntimeInvocation(), diff --git a/src/agent/hosted/chat-request.ts b/src/agent/hosted/chat-request.ts index 2d0109f3a7..be469bcd47 100644 --- a/src/agent/hosted/chat-request.ts +++ b/src/agent/hosted/chat-request.ts @@ -53,6 +53,7 @@ const getHostedChatRawToolCallPartSchema = defineSchema((v) => name: v.string().min(1), input: v.record(v.string(), v.unknown()), state: v.enum(["streaming", "pending", "completed", "error"]), + providerExecuted: v.boolean().optional(), }).strip() ); @@ -63,6 +64,7 @@ const getHostedChatRawToolResultPartSchema = defineSchema((v) => output: v.unknown(), is_error: v.boolean().optional(), tool_name: v.string().min(1).optional(), + providerExecuted: v.boolean().optional(), }).strip() ); diff --git a/src/agent/hosted/context-budget-manager.test.ts b/src/agent/hosted/context-budget-manager.test.ts index b97b559326..d2559ec1bb 100644 --- a/src/agent/hosted/context-budget-manager.test.ts +++ b/src/agent/hosted/context-budget-manager.test.ts @@ -257,6 +257,46 @@ Deno.test("applyContextBudget keeps tool call and result pairs in the retained t ]); }); +Deno.test("applyContextBudget keeps split checkpoint anchors atomic", async () => { + const checkpointId = "assistant-checkpoint"; + const messages = [ + message("user-old", "user", "Older goal ".repeat(200)), + { + ...toolCallMessage(checkpointId, "provider-tool-1"), + id: checkpointId, + }, + { + ...toolResultMessage(checkpointId, "provider-tool-1"), + id: checkpointId, + }, + message(checkpointId, "assistant", "Trailing provider answer ".repeat(8)), + message("user-latest", "user", "Continue."), + ] satisfies AgentRuntimeMessage[]; + + const result = await applyContextBudget(messages, { + tokenBudget: 300, + reserveTokens: 20, + recentTailTokens: 20, + atomicMessageIds: [checkpointId], + summaryGenerator: ({ messagesToSummarize }) => ({ + text: `Summarized ${messagesToSummarize.map((entry) => entry.id).join(",")}`, + }), + }); + + assertEquals(result.messages.map((entry) => entry.id), [ + `context_compaction_summary:${checkpointId}`, + checkpointId, + checkpointId, + checkpointId, + "user-latest", + ]); + assertEquals( + result.eventPayload?.summary.text, + "Summarized user-old", + "every same-ID checkpoint segment must be retained or summarized together", + ); +}); + Deno.test("applyContextBudget rejects invalid summary output", async () => { await assertRejects( () => diff --git a/src/agent/hosted/context-budget-manager.ts b/src/agent/hosted/context-budget-manager.ts index 6d62be748b..0ee19c62f4 100644 --- a/src/agent/hosted/context-budget-manager.ts +++ b/src/agent/hosted/context-budget-manager.ts @@ -77,6 +77,8 @@ export type ContextBudgetManagerOptions = { reason?: ContextCompactionReason; now?: () => number; summaryGenerator: ContextSummaryGenerator; + /** Message ids whose repeated segments must cross the compaction boundary together. */ + atomicMessageIds?: readonly string[]; }; export type ContextBudgetDiagnostics = { @@ -247,6 +249,54 @@ function expandTailForRecentAssistantTurn( : initialStartIndex; } +function expandTailForAtomicMessageIds( + messages: readonly AgentRuntimeMessage[], + initialStartIndex: number, + atomicMessageIds: readonly string[] | undefined, +): number { + if (!atomicMessageIds || atomicMessageIds.length === 0) return initialStartIndex; + const atomicIds = new Set(atomicMessageIds); + let startIndex = initialStartIndex; + let changed = true; + + while (changed) { + changed = false; + const retainedAtomicIds = new Set( + messages.slice(startIndex) + .map((message) => message.id) + .filter((id) => atomicIds.has(id)), + ); + for (let index = 0; index < startIndex; index += 1) { + const message = messages[index]; + if (message && retainedAtomicIds.has(message.id)) { + startIndex = index; + changed = true; + break; + } + } + } + + return startIndex; +} + +function expandTailForDependencies( + messages: readonly AgentRuntimeMessage[], + initialStartIndex: number, + atomicMessageIds: readonly string[] | undefined, +): number { + let startIndex = initialStartIndex; + while (true) { + const withToolPairs = expandTailForToolPairs(messages, startIndex); + const withAtomicMessages = expandTailForAtomicMessageIds( + messages, + withToolPairs, + atomicMessageIds, + ); + if (withAtomicMessages === startIndex) return startIndex; + startIndex = withAtomicMessages; + } +} + function createSyntheticSummaryMessage(input: { text: string; firstKeptEntryId: string; @@ -351,7 +401,11 @@ export async function applyContextBudget( messages, initialTailStartIndex, ); - const tailStartIndex = expandTailForToolPairs(messages, conversationTailStartIndex); + const tailStartIndex = expandTailForDependencies( + messages, + conversationTailStartIndex, + options.atomicMessageIds, + ); const messagesToSummarize = messages.slice(0, tailStartIndex); const retainedMessages = messages.slice(tailStartIndex); const firstKeptEntryId = retainedMessages[0]?.id; diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index 5c0384affa..bec2d2b626 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -1475,6 +1475,7 @@ describe("agent/runtime/provider-replay", () => { content: { type: "text_editor_code_execution_tool_result_error", error_code: "file_not_found", + error_message: null, }, }, ]; @@ -1855,6 +1856,129 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should normalize MCP result fields before matching the transcript", () => { + const providerCall = { + type: "mcp_tool_use", + id: "srvtool-mcp", + name: "search_docs", + server_name: "docs", + input: { query: "provider replay" }, + }; + const providerResult = { + type: "mcp_tool_result", + tool_use_id: providerCall.id, + is_error: false, + content: [{ + type: "text", + text: "matched", + provider_extension: "ignored by the durable projection", + }], + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: [{ type: "text", text: "matched" }], + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, providerResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + applyProviderReplayCheckpointsToMessages([target], [checkpoint]); + + assertEquals(readAttachedProviderMetadata(target), { + anthropic: { + rawAssistantMessages: [[providerCall, providerResult]], + }, + }); + }); + + it("should reject text-editor errors that omit error_message", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-text-editor", + name: "text_editor_code_execution", + input: { command: "view", path: "example.txt" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "text_editor_code_execution_tool_result", + tool_use_id: providerCall.id, + content: { + type: "text_editor_code_execution_tool_result_error", + error_code: "file_not_found", + }, + }; + const target = { + id: "assistant-message-1", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + args: providerCall.input, + providerExecuted: true, + }, + { + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + result: { + name: "AnthropicServerToolResultError", + provider: "anthropic", + code: "file_not_found", + toolCallId: providerCall.id, + toolName: providerCall.name, + }, + isError: true, + providerExecuted: true, + }, + ], + timestamp: 1, + } as Message; + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: [providerCall, providerResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + }); + it("should accept a provider tool turn whose text was persisted as one concatenated part", () => { const leadingText = { type: "text", text: "Let me search. " }; const providerCall = { diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 1ece0efb17..602e512a80 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -295,6 +295,19 @@ function hasValidAnthropicMcpContent(value: unknown): boolean { }); } +function normalizeAnthropicMcpContent(value: unknown): unknown { + if (typeof value === "string") return value; + if (!Array.isArray(value)) return value; + return value.map((item) => { + if (!isRecord(item)) return item; + return { + type: "text", + text: item.text, + ...(item.citations === undefined ? {} : { citations: item.citations }), + }; + }); +} + function hasValidAnthropicFileOutputs( value: unknown, expectedType: "code_execution_output" | "bash_code_execution_output", @@ -357,9 +370,8 @@ function hasValidAnthropicTextEditorCodeExecutionContent( TEXT_EDITOR_CODE_EXECUTION_ERROR_CODES, ) ) { - return !("error_message" in content) || - content.error_message === null || - typeof content.error_message === "string"; + return "error_message" in content && + (content.error_message === null || typeof content.error_message === "string"); } if (content.type === "text_editor_code_execution_view_result") { return typeof content.content === "string" && @@ -661,6 +673,8 @@ function normalizeAnthropicProviderToolResultContent( ): unknown { const normalized = (() => { switch (block.type) { + case "mcp_tool_result": + return normalizeAnthropicMcpContent(block.content); case "web_search_tool_result": return normalizeAnthropicWebSearchContent(block.content); case "web_fetch_tool_result": From 4e1723f009e1549ff529d78019ba997bd1718464 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 22:32:58 +0200 Subject: [PATCH 43/56] docs(agent): refresh replay source anchors --- docs/api-reference/veryfront/agent.md | 28 +++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 5c8d0b2f29..af1b21f5ce 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -556,9 +556,9 @@ Input delivered to a hosted agent-service detached execution callback. | `buildFinalizedMessageFallbackChunks` | Builds finalized message fallback chunks. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/finalized-message.ts#L126) | | `buildFinalizedMessageState` | State for build finalized message. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/finalized-message.ts#L70) | | `buildForkRuntimeStepFromResponse` | Build a fork runtime step from an agent response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-step-progress.ts#L12) | -| `buildHostedChatRequestForwardedPropsFromRuntimeAgentInvocation` | Builds hosted chat request forwarded props from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L559) | -| `buildHostedChatRequestFromRuntimeAgentInvocation` | Builds hosted chat request from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L613) | -| `buildHostedChatRequestInputFromRuntimeAgentInvocation` | Builds hosted chat request input from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L578) | +| `buildHostedChatRequestForwardedPropsFromRuntimeAgentInvocation` | Builds hosted chat request forwarded props from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L561) | +| `buildHostedChatRequestFromRuntimeAgentInvocation` | Builds hosted chat request from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L615) | +| `buildHostedChatRequestInputFromRuntimeAgentInvocation` | Builds hosted chat request input from runtime agent invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L580) | | `buildHostedChildCompletedLog` | Builds hosted child completed log. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-execution-logging.ts#L69) | | `buildHostedChildConversationBody` | Builds hosted child conversation body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-bootstrap.ts#L35) | | `buildHostedChildErrorLog` | Builds hosted child error log. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-execution-logging.ts#L89) | @@ -575,9 +575,9 @@ Input delivered to a hosted agent-service detached execution callback. | `buildInvokeAgentFollowupInstruction` | Builds invoke agent followup instruction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L100) | | `buildInvokeAgentTraceAttributes` | Builds invoke agent trace attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L235) | | `buildParsedAgentServiceAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L568) | +| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L570) | | `buildParsedHostedAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L568) | +| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L570) | | `buildProjectContextPromptBlock` | Builds the shared project-context prompt block (project reference + branch). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L103) | | `buildProjectInstructionsPromptBlock` | Builds the project-instructions prompt block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L120) | | `buildProjectServiceTraceAttributes` | Builds Datadog unified service trace attributes for a hosted project run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L93) | @@ -867,7 +867,7 @@ Input delivered to a hosted agent-service detached execution callback. | `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeRuntimeSkillReferencePath` | Normalizes and bounds a portable runtime skill reference path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1011) | -| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L575) | +| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L577) | | `parseAgentServiceConfig` | Configuration used by parse agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L159) | | `parseAgUiContextBoolean` | Parses AG-UI context boolean. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L56) | | `parseAgUiContextJsonValue` | Parses AG-UI context JSON value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L27) | @@ -882,11 +882,11 @@ Input delivered to a hosted agent-service detached execution callback. | `parseAppendConversationRunEventsErrorBody` | Parses append conversation run events error body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L54) | | `parseDataStreamSseEvents` | Parses data stream sse events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/data-stream.ts#L14) | | `parseHostedAgentServiceConfig` | Configuration used by parse hosted agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L167) | -| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L575) | +| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L577) | | `parseRuntimeAgentMarkdownDefinition` | Definition for parse runtime agent markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L192) | | `parseRuntimeAgentRunInvocation` | Parses runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L480) | -| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L614) | -| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L614) | +| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L616) | +| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L616) | | `parseRuntimeAgentRunInvocationOrError` | Error shape for parse runtime agent run invocation or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L489) | | `parseRuntimeSkillDocument` | Parses a bounded runtime skill document and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L561) | | `parseRuntimeSkillMetadata` | Parses bounded runtime skill metadata and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L569) | @@ -896,7 +896,7 @@ Input delivered to a hosted agent-service detached execution callback. | `prepareAgentRuntimeMessagesFromUiMessages` | Prepare agent runtime messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-preparation.ts#L35) | | `prepareAgentServiceChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L518) | | `prepareAgentServiceChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L385) | -| `prepareAgentServiceChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L655) | +| `prepareAgentServiceChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L664) | | `prepareAgentServiceConversationRootRunContext` | Context for prepare hosted conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L129) | | `prepareConversationRootRunContext` | Context for prepare conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-context.ts#L128) | | `prepareConversationRootRunLifecycle` | Prepare conversation root run lifecycle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L42) | @@ -909,7 +909,7 @@ Input delivered to a hosted agent-service detached execution callback. | `prepareDefaultHostedChildForkToolSources` | Prepare default hosted child fork tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-tool-sources.ts#L88) | | `prepareHostedChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L518) | | `prepareHostedChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L385) | -| `prepareHostedChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L655) | +| `prepareHostedChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L664) | | `prepareHostedChatRuntimeToolAssembly` | Prepare hosted chat runtime tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L559) | | `prepareHostedChildForkRuntimeStepMessages` | Prepare hosted child fork runtime step messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L138) | | `prepareHostedConversationRootRunContext` | Context for prepare hosted conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L129) | @@ -1468,8 +1468,8 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedChatExecutionRuntimeLogger` | Public API contract for hosted chat execution runtime logger. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L84) | | `HostedChatProjectAccessError` | Error shape for hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L54) | | `HostedChatProjectAccessResult` | Result returned from hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L61) | -| `HostedChatRequest` | Request payload for hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L469) | -| `HostedChatRequestInput` | Input payload for hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L471) | +| `HostedChatRequest` | Request payload for hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L471) | +| `HostedChatRequestInput` | Input payload for hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L473) | | `HostedChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L48) | | `HostedChatRuntimeAgent` | Public API contract for hosted chat runtime agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L87) | | `HostedChatRuntimeAgentAdapterInput` | Input payload for hosted chat runtime agent adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-agent-adapter.ts#L25) | @@ -1892,7 +1892,7 @@ Input delivered to a hosted agent-service detached execution callback. | `hostedAgentProjectSteeringOptionsSchema` | Zod schema for hosted agent project steering options. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/agent-project-steering.ts#L35) | | `hostedAgentServiceConfigSchema` | Zod schema for hosted agent service config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L152) | | `hostedAgUiChatForwardedConfigSchema` | Schema for agent service AG-UI chat forwarded config. Schema for hosted AG-UI chat forwarded config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L39) | -| `hostedChatRequestSchema` | Schema for hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L466) | +| `hostedChatRequestSchema` | Schema for hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L468) | | `hostedChatRuntimeOverridesSchema` | Schema for hosted chat runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L47) | | `hostedChildForkToolInputSchema` | Schema for hosted child fork tool input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-tool-input.ts#L70) | | `hostedChildTerminalErrorCodes` | Shared hosted child terminal error codes value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-status.ts#L16) | From e4996dd0b056c96510e779ea2696382d68eaf524 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 23:02:36 +0200 Subject: [PATCH 44/56] fix(agent): validate replay before transcript matching --- .../src/anthropic-provider.test.ts | 10 +++++++++ .../src/anthropic-provider.ts | 1 + src/agent/runtime/provider-replay.test.ts | 22 +++++++++++++++++++ src/agent/runtime/provider-replay.ts | 13 +++++++++++ 4 files changed, 46 insertions(+) diff --git a/extensions/ext-llm-anthropic/src/anthropic-provider.test.ts b/extensions/ext-llm-anthropic/src/anthropic-provider.test.ts index d881725c3d..5f24104f0d 100644 --- a/extensions/ext-llm-anthropic/src/anthropic-provider.test.ts +++ b/extensions/ext-llm-anthropic/src/anthropic-provider.test.ts @@ -86,6 +86,16 @@ function createSettledLegacyMcpPrompt(toolCallId: string) { } describe("anthropic-provider", () => { + it("keeps canonical provider identity when the runtime label is customized", () => { + const runtime = createAnthropicModelRuntime( + { name: "private-anthropic-gateway" }, + "claude-sonnet-4-6", + ); + + assertEquals(runtime.provider, "private-anthropic-gateway"); + assertEquals(runtime.modelProvider, "anthropic"); + }); + it("creates an Anthropic-compatible language runtime without SDK helpers for generate", async () => { let requestedUrl = ""; let requestedInit: RequestInit | undefined; diff --git a/extensions/ext-llm-anthropic/src/anthropic-provider.ts b/extensions/ext-llm-anthropic/src/anthropic-provider.ts index 22572f889c..f172b586c9 100644 --- a/extensions/ext-llm-anthropic/src/anthropic-provider.ts +++ b/extensions/ext-llm-anthropic/src/anthropic-provider.ts @@ -526,6 +526,7 @@ export function createAnthropicModelRuntime( return { provider: providerName, + modelProvider: "anthropic", modelId, specificationVersion: "v3", supportedUrls: {}, diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index bec2d2b626..c0ef3c3838 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -360,6 +360,28 @@ describe("agent/runtime/provider-replay", () => { }) ); }); + + it("should reject malformed nested provider-result content without a transcript", () => { + assertProviderReplayError(() => + parseProviderReplayCheckpoint({ + version: 1, + messageId: "assistant-compacted-out", + provider: "anthropic", + providerBlocks: [{ + type: "provider-block", + provider: "anthropic", + block: { + type: "mcp_tool_result", + tool_use_id: "srvtool-mcp", + is_error: false, + content: [{ type: "text", text: 123 }], + }, + }], + providerBlockPositions: [0], + totalPartCount: 1, + }) + ); + }); }); describe("parseServerResolvedProviderReplayCheckpoints", () => { diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 602e512a80..76b0bedd7c 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -233,6 +233,19 @@ function validateAnthropicProviderToolResultBlock( if (block.type === "mcp_tool_result" && typeof block.is_error !== "boolean") { invalidCheckpoint("checkpoint provider tool-result block is malformed", context); } + if (block.type === "mcp_tool_result") { + if (!hasValidAnthropicMcpContent(block.content)) { + invalidCheckpoint("checkpoint MCP tool-result content is malformed", context); + } + return; + } + const toolName = getAnthropicToolNameForResultBlock(block); + if ( + toolName === undefined || + !hasValidAnthropicProviderToolResultContentForTool(block, toolName) + ) { + invalidCheckpoint("checkpoint provider tool-result content is malformed", context); + } } function isAnthropicProviderToolResultBlock(block: Record): boolean { From 9dfa6d2212b2c8e7ddd4d57f26718eb4d95349fa Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 23:34:51 +0200 Subject: [PATCH 45/56] fix(agent): close replay fail-open paths --- docs/api-reference/veryfront/agent.md | 20 +++--- src/agent/hosted/chat-request-parser.ts | 13 +++- src/agent/hosted/chat-request.test.ts | 26 ++++++++ .../hosted/runtime-request-config.test.ts | 12 ++++ src/agent/hosted/runtime-request-config.ts | 6 +- .../anthropic-provider-replay-block.ts | 16 +++++ src/agent/runtime/provider-replay.ts | 17 +---- ...neration-runtime-message-converter.test.ts | 63 +++++++++++++++++++ ...xt-generation-runtime-message-converter.ts | 31 ++++++++- 9 files changed, 172 insertions(+), 32 deletions(-) create mode 100644 src/agent/runtime/anthropic-provider-replay-block.ts diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 59e1b4a5ed..fdab8d0002 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -575,9 +575,9 @@ Input delivered to a hosted agent-service detached execution callback. | `buildInvokeAgentFollowupInstruction` | Builds invoke agent followup instruction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L100) | | `buildInvokeAgentTraceAttributes` | Builds invoke agent trace attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L235) | | `buildParsedAgentServiceAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L570) | +| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L581) | | `buildParsedHostedAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L570) | +| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L581) | | `buildProjectContextPromptBlock` | Builds the shared project-context prompt block (project reference + branch). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L103) | | `buildProjectInstructionsPromptBlock` | Builds the project-instructions prompt block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L120) | | `buildProjectServiceTraceAttributes` | Builds Datadog unified service trace attributes for a hosted project run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L93) | @@ -790,8 +790,8 @@ Input delivered to a hosted agent-service detached execution callback. | `getConversationRunEventJsonByteLength` | Return conversation run event JSON byte length. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-event-normalization.ts#L27) | | `getEmptyHostedFinalizedMessageTerminalError` | Error shape for get empty hosted finalized message terminal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-terminal-error.ts#L113) | | `getForkRuntimeAllowedToolNames` | Return fork runtime allowed tool names. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-native-tool-inventory.ts#L90) | -| `getForwardedHostedModelId` | Return forwarded hosted model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L148) | -| `getForwardedHostedRuntimeOverrides` | Return forwarded hosted runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L158) | +| `getForwardedHostedModelId` | Return forwarded hosted model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L150) | +| `getForwardedHostedRuntimeOverrides` | Return forwarded hosted runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L160) | | `getHostedChildWrittenArtifactPath` | Return hosted child written artifact path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-artifact-support.ts#L112) | | `getHostedMirroredAbortErrorText` | Return hosted mirrored abort error text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L163) | | `getHostedServiceTokenFromRequest` | Request payload for get hosted service token from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L234) | @@ -867,7 +867,7 @@ Input delivered to a hosted agent-service detached execution callback. | `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeRuntimeSkillReferencePath` | Normalizes and bounds a portable runtime skill reference path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1011) | -| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L577) | +| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L588) | | `parseAgentServiceConfig` | Configuration used by parse agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L159) | | `parseAgUiContextBoolean` | Parses AG-UI context boolean. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L56) | | `parseAgUiContextJsonValue` | Parses AG-UI context JSON value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L27) | @@ -882,11 +882,11 @@ Input delivered to a hosted agent-service detached execution callback. | `parseAppendConversationRunEventsErrorBody` | Parses append conversation run events error body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L54) | | `parseDataStreamSseEvents` | Parses data stream sse events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/data-stream.ts#L14) | | `parseHostedAgentServiceConfig` | Configuration used by parse hosted agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L167) | -| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L577) | +| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L588) | | `parseRuntimeAgentMarkdownDefinition` | Definition for parse runtime agent markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L192) | | `parseRuntimeAgentRunInvocation` | Parses runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L480) | -| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L616) | -| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L616) | +| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L627) | +| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L627) | | `parseRuntimeAgentRunInvocationOrError` | Error shape for parse runtime agent run invocation or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L489) | | `parseRuntimeSkillDocument` | Parses a bounded runtime skill document and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L561) | | `parseRuntimeSkillMetadata` | Parses bounded runtime skill metadata and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L569) | @@ -938,8 +938,8 @@ Input delivered to a hosted agent-service detached execution callback. | `resolveHostedChildStreamWatchdogState` | State for resolve hosted child stream watchdog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-stream-watchdog.ts#L33) | | `resolveHostedChildTerminalErrorCode` | Resolves a code is a hosted child terminal error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-status.ts#L91) | | `resolveHostedDurableRunSetupErrorResponse` | Response payload for resolve hosted durable run setup error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/durable-chat-run-start.ts#L122) | -| `resolveHostedRuntimeRequestConfig` | Configuration used by resolve hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L248) | -| `resolveHostedRuntimeThinkingOverride` | Resolves hosted runtime thinking override. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L186) | +| `resolveHostedRuntimeRequestConfig` | Configuration used by resolve hosted runtime request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L250) | +| `resolveHostedRuntimeThinkingOverride` | Resolves hosted runtime thinking override. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L188) | | `resolveNodeAgentServiceTelemetryConfig` | Configuration used by resolve node agent service telemetry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L379) | | `resolveNodeHostedAgentServiceTelemetryConfig` | Configuration used by resolve node hosted agent service telemetry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-telemetry.ts#L315) | | `resolveRuntimeAgentDefinitionsDir` | Resolves runtime agent definitions dir. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition-files.ts#L96) | diff --git a/src/agent/hosted/chat-request-parser.ts b/src/agent/hosted/chat-request-parser.ts index 718a4cf5f5..9165240975 100644 --- a/src/agent/hosted/chat-request-parser.ts +++ b/src/agent/hosted/chat-request-parser.ts @@ -188,6 +188,15 @@ async function withVerifiedRunEventAppendToken( ): Promise { const token = request.headers.get(RUN_EVENT_APPEND_TOKEN_HEADER)?.trim(); if (!token) { + if ( + trustServerEnvelope && + Object.hasOwn(parsedRequest, "serverResolvedProviderReplayCheckpoints") + ) { + return Response.json( + { errorCode: "INVALID_RUN_EVENT_APPEND_TOKEN" }, + { status: 403 }, + ); + } return { ...stripUnverifiedServerResolvedRequestState(parsedRequest), forwardedProps: stripUnverifiedServerResolvedForwardedProps( @@ -558,7 +567,9 @@ async function buildParsedHostedChatRequestInternal( model, allowDelegation, forwardedProps, - serverResolvedProviderReplayCheckpoints, + ...(Object.hasOwn(input.chatRequest, "serverResolvedProviderReplayCheckpoints") + ? { serverResolvedProviderReplayCheckpoints } + : {}), runtimeOverrides, durableRootRun, persistLatestUserMessageBeforeDurableRun: false, diff --git a/src/agent/hosted/chat-request.test.ts b/src/agent/hosted/chat-request.test.ts index 4bc81da1e7..ac25795003 100644 --- a/src/agent/hosted/chat-request.test.ts +++ b/src/agent/hosted/chat-request.test.ts @@ -2011,6 +2011,32 @@ describe("agent/hosted-chat-request", () => { }]); }); + it("rejects private provider replay state without a run-event append token", async () => { + const response = await parseRuntimeAgentRunInvocationHostedChatRequestFromRequest( + new Request("https://agent.example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + body: JSON.stringify({ + ...createRuntimeInvocation(), + serverResolvedProviderReplayCheckpoints: [serverResolvedProviderReplayCheckpoint], + }), + }), + { + authenticate: () => Promise.resolve({ userId, authToken: "user-api-token" }), + verifyProjectAccess: () => Promise.resolve({ success: true }), + verifyRunEventAppendToken: () => Promise.resolve(true), + runtimeSource, + }, + ); + + if (!(response instanceof Response)) { + throw new Error("Expected missing run-event token response"); + } + assertEquals(response.status, 403); + assertEquals(await response.json(), { + errorCode: "INVALID_RUN_EVENT_APPEND_TOKEN", + }); + }); + it("accepts private provider replay requests above the generic body limit", async () => { const parsed = await parseRuntimeAgentRunInvocationHostedChatRequestFromRequest( new Request("https://agent.example.com/api/control-plane/runs/run_1/stream", { diff --git a/src/agent/hosted/runtime-request-config.test.ts b/src/agent/hosted/runtime-request-config.test.ts index 8e0e7ec549..f028874038 100644 --- a/src/agent/hosted/runtime-request-config.test.ts +++ b/src/agent/hosted/runtime-request-config.test.ts @@ -144,6 +144,18 @@ it("server-resolved provider replay checkpoints prefer verified private request undefined, "unverified top-level state is ignored", ); + + const nullError = assertThrows(() => + getServerResolvedProviderReplayCheckpoints({ + forwardedProps: { + serverResolvedProviderReplayCheckpoints: [forwardedCheckpoint], + }, + serverResolvedProviderReplayCheckpoints: null, + serverEnvelopeVerified: true, + }) + ); + assertInstanceOf(nullError, VeryfrontError); + assertEquals(nullError.slug, "provider-replay-checkpoint-invalid"); }); it("server-resolved provider replay checkpoints reject providers this runtime cannot replay", () => { diff --git a/src/agent/hosted/runtime-request-config.ts b/src/agent/hosted/runtime-request-config.ts index 8cacbaa176..89cd1b23fa 100644 --- a/src/agent/hosted/runtime-request-config.ts +++ b/src/agent/hosted/runtime-request-config.ts @@ -131,8 +131,10 @@ export function getServerResolvedProviderReplayCheckpoints( serverEnvelopeVerified, } = input; if (!serverEnvelopeVerified) return undefined; - const value = serverResolvedProviderReplayCheckpoints ?? - forwardedProps?.serverResolvedProviderReplayCheckpoints; + const value = typeof maybeServerEnvelopeVerified !== "boolean" && + Object.hasOwn(input, "serverResolvedProviderReplayCheckpoints") + ? serverResolvedProviderReplayCheckpoints + : forwardedProps?.serverResolvedProviderReplayCheckpoints; if (value === undefined) return undefined; const checkpoints = parseServerResolvedProviderReplayCheckpoints(value); // Contract-valid state this runtime version cannot reconstruct (another diff --git a/src/agent/runtime/anthropic-provider-replay-block.ts b/src/agent/runtime/anthropic-provider-replay-block.ts new file mode 100644 index 0000000000..b7b7a93622 --- /dev/null +++ b/src/agent/runtime/anthropic-provider-replay-block.ts @@ -0,0 +1,16 @@ +// Anthropic reports provider tool failures with the ordinary outer result type +// and the error record inside `content`. An outer `*_tool_result_error` block +// would only defer the failure until the provider request parser rejects it. +const ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES = new Set([ + "web_search_tool_result", + "web_fetch_tool_result", + "code_execution_tool_result", + "bash_code_execution_tool_result", + "text_editor_code_execution_tool_result", + "mcp_tool_result", +]); + +/** Identify one Anthropic provider-executed tool-result block. */ +export function isAnthropicProviderToolResultBlock(block: Record): boolean { + return typeof block.type === "string" && ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES.has(block.type); +} diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 76b0bedd7c..e4f121647d 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -13,6 +13,7 @@ import { MAX_PROVIDER_REPLAY_RAW_METADATA_NODES, MAX_PROVIDER_REPLAY_RAW_METADATA_STRING_CHARS, } from "./provider-replay-limits.ts"; +import { isAnthropicProviderToolResultBlock } from "./anthropic-provider-replay-block.ts"; const MAX_PROVIDER_REPLAY_BLOCKS = 100; const MAX_PROVIDER_REPLAY_CHECKPOINTS = 100; @@ -31,18 +32,6 @@ const CHECKPOINT_KEYS = new Set([ "emittedAt", ]); const BLOCK_KEYS = new Set(["type", "provider", "block"]); -// Anthropic reports provider tool failures with the ordinary outer result type -// and the error record inside `content`; there is no outer `*_tool_result_error` -// block type, so accepting one here would only defer the failure to request -// construction, where the provider parser rejects it with a bare TypeError. -const ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES = new Set([ - "web_search_tool_result", - "web_fetch_tool_result", - "code_execution_tool_result", - "bash_code_execution_tool_result", - "text_editor_code_execution_tool_result", - "mcp_tool_result", -]); const WEB_SEARCH_ERROR_CODES = new Set([ "invalid_tool_input", "unavailable", @@ -248,10 +237,6 @@ function validateAnthropicProviderToolResultBlock( } } -function isAnthropicProviderToolResultBlock(block: Record): boolean { - return typeof block.type === "string" && ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES.has(block.type); -} - function validateAnthropicReplayBlock( block: Record, context?: Record, diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index e9f30153b9..4ea4e30594 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -574,6 +574,69 @@ describe("text-generation-runtime-message-converter", () => { ]); }); + it("groups result-only Anthropic responses with the following split assistant turn", () => { + const rawProviderCall = { + type: "server_tool_use", + id: "server-call-1", + name: "web_search", + input: { query: "veryfront" }, + }; + const rawToolUse = { + type: "tool_use", + id: "call-1", + name: "lookup", + input: { query: "veryfront" }, + }; + const rawProviderResult = { + type: "web_search_tool_result", + tool_use_id: "server-call-1", + content: [], + }; + const rawText = { type: "text", text: "Found it." }; + const message = attachProviderMetadata({ + id: "a-split-pause", + role: "assistant", + parts: [ + { + type: "tool-web_search", + toolCallId: "server-call-1", + toolName: "web_search", + args: rawProviderCall.input, + providerExecuted: true, + }, + { type: "tool-lookup", toolCallId: "call-1", toolName: "lookup", args: rawToolUse.input }, + { + type: "tool-result", + toolCallId: "server-call-1", + toolName: "web_search", + result: [], + providerExecuted: true, + }, + { type: "tool-result", toolCallId: "call-1", toolName: "lookup", result: { matches: 1 } }, + { type: "text", text: "Found it." }, + ], + } as unknown as Message, { + anthropic: { + rawAssistantMessages: [ + [rawProviderCall, rawToolUse], + [rawProviderResult], + [rawText], + ], + }, + }); + + const converted = convertToTextGenerationRuntimeMessages([message]); + const assistantMetadata = converted + .filter((entry) => entry.role === "assistant") + .map((entry) => entry.providerMetadata); + + assertEquals(assistantMetadata, [{ + anthropic: { rawAssistantMessages: [[rawProviderCall, rawToolUse]] }, + }, { + anthropic: { rawAssistantMessages: [[rawProviderResult], [rawText]] }, + }]); + }); + it("rejects anthropic raw assistant messages that do not match the split segments", () => { const message = attachProviderMetadata({ id: "a-split", diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 21e3f7d798..1f6d42dbc5 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -21,6 +21,7 @@ import { buildDataFileAnnotation } from "#veryfront/chat/types.ts"; import { PROVIDER_METADATA_SPLIT_UNSUPPORTED } from "#veryfront/errors"; import { getTextFromParts, getToolArguments, type Message, type ToolCallPart } from "../types.ts"; import { readAttachedProviderMetadata } from "./provider-metadata.ts"; +import { isAnthropicProviderToolResultBlock } from "./anthropic-provider-replay-block.ts"; function getStringPartField(part: unknown, key: string): string | undefined { if (!part || typeof part !== "object" || Array.isArray(part)) return undefined; @@ -379,14 +380,38 @@ function createSplitAnthropicRawAssistantMetadata( const anthropic = providerMetadata.anthropic; if (!isRecord(anthropic)) return undefined; const rawAssistantMessages = anthropic.rawAssistantMessages; - if (!Array.isArray(rawAssistantMessages) || rawAssistantMessages.length !== segmentCount) { + if (!Array.isArray(rawAssistantMessages)) { return undefined; } - return rawAssistantMessages.map((rawAssistantMessage) => ({ + + const segmentRawAssistantMessages: unknown[][][] = []; + let pendingResultOnlyMessages: unknown[][] = []; + for (const rawAssistantMessage of rawAssistantMessages) { + if (!Array.isArray(rawAssistantMessage)) return undefined; + const isResultOnly = rawAssistantMessage.length > 0 && + rawAssistantMessage.every((block) => + isRecord(block) && isAnthropicProviderToolResultBlock(block) + ); + if (isResultOnly) { + pendingResultOnlyMessages.push(rawAssistantMessage); + continue; + } + if (segmentRawAssistantMessages.length >= segmentCount) return undefined; + segmentRawAssistantMessages.push([...pendingResultOnlyMessages, rawAssistantMessage]); + pendingResultOnlyMessages = []; + } + const finalSegment = segmentRawAssistantMessages.at(-1); + if (pendingResultOnlyMessages.length > 0) { + if (!finalSegment) return undefined; + finalSegment.push(...pendingResultOnlyMessages); + } + if (segmentRawAssistantMessages.length !== segmentCount) return undefined; + + return segmentRawAssistantMessages.map((segmentMessages) => ({ ...providerMetadata, anthropic: { ...anthropic, - rawAssistantMessages: [rawAssistantMessage], + rawAssistantMessages: segmentMessages, }, })); } From 5e6cd1e008f5b26a4208e191a5a9ecf8e0a135bb Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 23:51:03 +0200 Subject: [PATCH 46/56] fix(agent): keep replay stage 1 off the always-on message path Persisted UI history stamped providerExecuted onto prepared tool parts unconditionally, and the text-generation converter drops provider-executed parts, so any historical provider-executed turn vanished from the model request. Stage 1 must not change runtime message content while emission is disabled: provider-message-conversion, message-part-parsing, message-adapter, and the converter are back to byte-parity with main, and providerExecuted propagation moves to the stage that delivers checkpoints. Also reverts the 10x invocation body-cap raise to the default 1 MiB cap (nothing emits checkpoints yet), removes the now-unthrown provider-metadata-split-unsupported error, and restores the unrelated workflow-list and key-reducer changes to main. --- src/agent/ag-ui/request-shared.ts | 17 +- src/agent/hosted/chat-request-parser.ts | 3 +- src/agent/hosted/chat-request.test.ts | 16 +- .../runtime/agent-invocation-contract.test.ts | 16 +- .../runtime/agent-invocation-contract.ts | 6 +- src/agent/runtime/message-adapter.test.ts | 91 +-------- src/agent/runtime/message-adapter.ts | 31 +-- src/agent/runtime/provider-replay-limits.ts | 1 - ...neration-runtime-message-converter.test.ts | 184 +----------------- ...xt-generation-runtime-message-converter.ts | 75 +------ src/chat/message-part-parsing.ts | 31 +-- src/chat/message-prep.test.ts | 2 - src/chat/provider-message-conversion.ts | 44 +++-- src/errors/error-registry.test.ts | 1 - src/errors/error-registry/agent.ts | 15 -- src/errors/index.ts | 1 - src/workflow/react/use-workflow-list.ts | 11 -- .../react/use-workflow-start.test.tsx | 42 +--- 18 files changed, 72 insertions(+), 515 deletions(-) diff --git a/src/agent/ag-ui/request-shared.ts b/src/agent/ag-ui/request-shared.ts index 1af6b976b0..3f27354a76 100644 --- a/src/agent/ag-ui/request-shared.ts +++ b/src/agent/ag-ui/request-shared.ts @@ -1,23 +1,19 @@ import { INVALID_ARGUMENT, VeryfrontError } from "#veryfront/errors"; -import { DEFAULT_LIMITS } from "#veryfront/security/input-validation/types.ts"; import { isRequestBodyTooLargeError, readBodyWithLimit, } from "#veryfront/security/input-validation/limits.ts"; +import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/index.ts"; -export const AG_UI_MAX_REQUEST_BODY_BYTES = DEFAULT_LIMITS.maxBodySize; +export const AG_UI_MAX_REQUEST_BODY_BYTES = DEFAULT_MAX_BODY_SIZE_BYTES; -export async function parseAgUiJsonBody( - request: Request, - maxBodySizeBytes = AG_UI_MAX_REQUEST_BODY_BYTES, -): Promise { - return JSON.parse(await readBodyWithLimit(request, maxBodySizeBytes)); +export async function parseAgUiJsonBody(request: Request): Promise { + return JSON.parse(await readBodyWithLimit(request, AG_UI_MAX_REQUEST_BODY_BYTES)); } export function createAgUiBodyLimitErrorResponse( error: unknown, errorLabel: string, - maxBodySizeBytes = AG_UI_MAX_REQUEST_BODY_BYTES, ): Response | undefined { if (!isRequestBodyTooLargeError(error)) { return undefined; @@ -28,7 +24,7 @@ export function createAgUiBodyLimitErrorResponse( error: errorLabel, details: [{ path: [], - message: `Request body exceeds ${maxBodySizeBytes} bytes`, + message: `Request body exceeds ${AG_UI_MAX_REQUEST_BODY_BYTES} bytes`, }], }, { status: 413 }, @@ -82,12 +78,11 @@ export function extractRequest(requestOrCtx: unknown): Request { export async function parseAgUiJsonRequestOrError( parseRequest: () => Promise, errorLabel: string, - maxBodySizeBytes = AG_UI_MAX_REQUEST_BODY_BYTES, ): Promise { try { return await parseRequest(); } catch (error) { - const bodyLimitError = createAgUiBodyLimitErrorResponse(error, errorLabel, maxBodySizeBytes); + const bodyLimitError = createAgUiBodyLimitErrorResponse(error, errorLabel); if (bodyLimitError) return bodyLimitError; if (isSchemaValidationError(error)) { diff --git a/src/agent/hosted/chat-request-parser.ts b/src/agent/hosted/chat-request-parser.ts index 9165240975..17d2c4b779 100644 --- a/src/agent/hosted/chat-request-parser.ts +++ b/src/agent/hosted/chat-request-parser.ts @@ -23,7 +23,6 @@ import { readBodyWithLimit, } from "#veryfront/security/input-validation/limits.ts"; import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/index.ts"; -import { MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES } from "#veryfront/agent/runtime/provider-replay-limits.ts"; import { type HostedRuntimeSourceBindingError, type HostedRuntimeSourceIdentity, @@ -633,7 +632,7 @@ export async function parseRuntimeAgentRunInvocationHostedChatRequestFromRequest return authenticatedRequest; } - const requestBody = await parseRequestJson(request, MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES); + const requestBody = await parseRequestJson(request, DEFAULT_MAX_BODY_SIZE_BYTES); if (requestBody instanceof Response) return requestBody; const invocation = getRuntimeAgentRunInvocationSchema().safeParse(requestBody); diff --git a/src/agent/hosted/chat-request.test.ts b/src/agent/hosted/chat-request.test.ts index ac25795003..b4594b8086 100644 --- a/src/agent/hosted/chat-request.test.ts +++ b/src/agent/hosted/chat-request.test.ts @@ -2,7 +2,6 @@ import "#veryfront/schemas/_test-setup.ts"; import { convertUiMessagesToProviderModelMessages } from "../../chat/provider-message-conversion.ts"; import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES } from "#veryfront/agent/runtime/provider-replay-limits.ts"; import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/index.ts"; import { buildHostedChatRequestForwardedPropsFromRuntimeAgentInvocation, @@ -1930,7 +1929,7 @@ describe("agent/hosted-chat-request", () => { const response = await parseHostedChatRequestFromRequest( new Request("https://agent.example.com/api/runs", { method: "POST", - body: JSON.stringify({ padding: "x".repeat(MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES) }), + body: JSON.stringify({ padding: "x".repeat(DEFAULT_MAX_BODY_SIZE_BYTES) }), }), { authenticate: () => Promise.resolve({ userId, authToken: "token_1" }), @@ -2037,7 +2036,7 @@ describe("agent/hosted-chat-request", () => { }); }); - it("accepts private provider replay requests above the generic body limit", async () => { + it("rejects private provider replay requests above the generic body limit", async () => { const parsed = await parseRuntimeAgentRunInvocationHostedChatRequestFromRequest( new Request("https://agent.example.com/api/control-plane/runs/run_1/stream", { method: "POST", @@ -2068,12 +2067,11 @@ describe("agent/hosted-chat-request", () => { }, ); - if (parsed instanceof Response) throw new Error("Expected parsed request"); - assertEquals( - Array.isArray(parsed.serverResolvedProviderReplayCheckpoints), - true, - "private replay state uses the replay request envelope, not the generic body cap", - ); + if (!(parsed instanceof Response)) { + throw new Error("Expected oversized replay request to be rejected"); + } + assertEquals(parsed.status, 413); + assertEquals((await parsed.json()).errorCode, "REQUEST_TOO_LARGE"); }); it("rejects ordinary hosted chat requests above the generic body limit", async () => { diff --git a/src/agent/runtime/agent-invocation-contract.test.ts b/src/agent/runtime/agent-invocation-contract.test.ts index 9c0cc59604..fa59ad2751 100644 --- a/src/agent/runtime/agent-invocation-contract.test.ts +++ b/src/agent/runtime/agent-invocation-contract.test.ts @@ -8,7 +8,6 @@ import { RuntimeAgentRunInvocationSchema, } from "../index.ts"; import { DEFAULT_LIMITS } from "#veryfront/security/input-validation/types.ts"; -import { MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES } from "./provider-replay-limits.ts"; const conversationId = "10000000-1000-4000-8000-100000000001"; const messageId = "10000000-1000-4000-8000-100000000002"; @@ -525,8 +524,8 @@ describe("agent/runtime-agent-invocation-contract", () => { assertEquals(parsed.context.length, 1); }); - it("keeps the replay-sized body limit on runtime agent invocation requests", async () => { - const parsed = await parseRuntimeAgentRunInvocation( + it("keeps the default body limit on runtime agent invocation requests", async () => { + const result = await parseRuntimeAgentRunInvocationOrError( new Request("http://localhost/api/control-plane/runs/run_1/stream", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -551,15 +550,8 @@ describe("agent/runtime-agent-invocation-contract", () => { }), ); - assertEquals( - JSON.stringify(parsed).length < MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES, - true, - "test fixture must stay inside the replay envelope", - ); - assertEquals( - Array.isArray(parsed.serverResolvedProviderReplayCheckpoints), - true, - ); + assertInstanceOf(result, Response); + assertEquals(result.status, 413); }); it("returns a 400 response for malformed runtime agent invocation payloads", async () => { diff --git a/src/agent/runtime/agent-invocation-contract.ts b/src/agent/runtime/agent-invocation-contract.ts index 3931b041fd..3c83cf8e85 100644 --- a/src/agent/runtime/agent-invocation-contract.ts +++ b/src/agent/runtime/agent-invocation-contract.ts @@ -2,7 +2,6 @@ import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema, RefinementCtx } from "#veryfront/extensions/schema/index.ts"; import { ensureBuiltinSchemaValidator } from "#veryfront/extensions/builtin-extensions.ts"; import { parseAgUiJsonBody, parseAgUiJsonRequestOrError } from "../ag-ui/request-shared.ts"; -import { MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES } from "./provider-replay-limits.ts"; import { getRuntimeAgentMarkdownDefinitionSchema } from "./agent-definition.ts"; ensureBuiltinSchemaValidator(); @@ -480,9 +479,7 @@ export function buildRuntimeAgentControlPlaneStreamRequestFromInvocation( export async function parseRuntimeAgentRunInvocation( request: Request, ): Promise { - return getRuntimeAgentRunInvocationSchema().parse( - await parseAgUiJsonBody(request, MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES), - ); + return getRuntimeAgentRunInvocationSchema().parse(await parseAgUiJsonBody(request)); } /** Error shape for parse runtime agent run invocation or. */ @@ -492,6 +489,5 @@ export async function parseRuntimeAgentRunInvocationOrError( return await parseAgUiJsonRequestOrError( () => parseRuntimeAgentRunInvocation(request), "Invalid runtime agent invocation", - MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES, ); } diff --git a/src/agent/runtime/message-adapter.test.ts b/src/agent/runtime/message-adapter.test.ts index bebc33721d..d71835557d 100644 --- a/src/agent/runtime/message-adapter.test.ts +++ b/src/agent/runtime/message-adapter.test.ts @@ -26,18 +26,13 @@ function providerTextPart(text: string): ProviderStructuredPart { return { type: "text", text }; } -function providerToolCallPart( - input: Record, - providerExecuted?: boolean, -): ProviderStructuredPart { - const part: ProviderStructuredPart = { +function providerToolCallPart(input: Record): ProviderStructuredPart { + return { type: "tool-call", toolCallId: TOOL_CALL_ID, toolName: TOOL_NAME, input, - ...(providerExecuted === true ? { providerExecuted: true } : {}), }; - return part; } function jsonOutput(value: ChatToolResultOutput["value"]): ChatToolResultOutput { @@ -47,16 +42,12 @@ function jsonOutput(value: ChatToolResultOutput["value"]): ChatToolResultOutput }; } -function providerToolResultPart( - output: ChatToolResultOutput, - providerExecuted?: boolean, -): ChatToolResultPart { +function providerToolResultPart(output: ChatToolResultOutput): ChatToolResultPart { return { type: "tool-result", toolCallId: TOOL_CALL_ID, toolName: TOOL_NAME, output, - ...(providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -86,27 +77,21 @@ function isRuntimeTextPart( function agentRuntimeToolCallPart( args: Record, type = "tool-call", - providerExecuted?: boolean, ): AgentRuntimeMessagePart { return { type, toolCallId: TOOL_CALL_ID, toolName: TOOL_NAME, args, - ...(providerExecuted === true ? { providerExecuted: true } : {}), }; } -function agentRuntimeToolResultPart( - result: unknown, - providerExecuted?: boolean, -): AgentRuntimeMessagePart { +function agentRuntimeToolResultPart(result: unknown): AgentRuntimeMessagePart { return { type: "tool-result", toolCallId: TOOL_CALL_ID, toolName: TOOL_NAME, result, - ...(providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -135,32 +120,6 @@ describe("agent runtime message adapter", () => { ]); }); - it("preserves provider ownership when converting provider messages into agent runtime messages", () => { - const agentRuntimeMessages = convertProviderMessagesToAgentRuntimeMessages([ - providerMessage({ - role: "assistant", - content: [providerToolCallPart({ query: "cloud side" }, true)], - }), - providerMessage({ - role: "tool", - content: [providerToolResultPart(jsonOutput({ matches: 2 }), true)], - }), - ]); - - assertEquals(agentRuntimeMessages, [ - agentRuntimeMessage( - "assistant", - [agentRuntimeToolCallPart({ query: "cloud side" }, "tool-call", true)], - 0, - ), - agentRuntimeMessage( - "tool", - [agentRuntimeToolResultPart(jsonOutput({ matches: 2 }), true)], - 1, - ), - ]); - }); - it("preserves reasoning parts when converting provider messages into agent runtime messages", () => { const agentRuntimeMessages = convertProviderMessagesToAgentRuntimeMessages([ providerMessage({ @@ -417,48 +376,6 @@ describe("agent runtime message adapter", () => { ]); }); - it("preserves provider ownership when replaying stored runtime tool parts", () => { - const providerMessages = convertAgentRuntimeMessagesToProviderMessages([ - { - role: "assistant", - parts: [ - { - type: "tool_call", - id: TOOL_CALL_ID, - name: TOOL_NAME, - input: { query: "cloud side" }, - providerExecuted: true, - }, - ], - }, - { - role: "tool", - parts: [ - { - type: "tool_result", - tool_call_id: TOOL_CALL_ID, - tool_name: TOOL_NAME, - output: { matches: 2 }, - providerExecuted: true, - }, - ], - }, - ]); - - assertEquals(providerMessages, [ - { - role: "assistant", - content: [providerToolCallPart({ query: "cloud side" }, true)], - }, - { - role: "tool", - content: [ - providerToolResultPart(jsonOutput({ matches: 2 }), true), - ], - }, - ]); - }); - it("replays same-message stored tool results after their matching assistant tool call", () => { const providerMessages = convertAgentRuntimeMessagesToProviderMessages([ { diff --git a/src/agent/runtime/message-adapter.ts b/src/agent/runtime/message-adapter.ts index 962a522d3a..7017f0d866 100644 --- a/src/agent/runtime/message-adapter.ts +++ b/src/agent/runtime/message-adapter.ts @@ -40,14 +40,12 @@ type AgentRuntimeMessageLikePart = toolCallId: string; toolName: string; args: Record; - providerExecuted?: boolean; } | { type: string; toolCallId: string; toolName: string; input: Record; - providerExecuted?: boolean; } | { type: "tool_call"; @@ -59,21 +57,18 @@ type AgentRuntimeMessageLikePart = toolName?: string; input?: Record; args?: Record; - providerExecuted?: boolean; } | { type: "tool-result"; toolCallId: string; toolName: string; result?: unknown; - providerExecuted?: boolean; } | { type: "tool-result"; toolCallId: string; toolName: string; output: unknown; - providerExecuted?: boolean; } | { type: "tool_result"; @@ -83,7 +78,6 @@ type AgentRuntimeMessageLikePart = toolName?: string; result?: unknown; output?: unknown; - providerExecuted?: boolean; } | RuntimeAttachmentLikePart | ChatSourceUrlUiPart @@ -98,14 +92,12 @@ export type AgentRuntimeMessagePart = toolCallId: string; toolName: string; args: Record; - providerExecuted?: boolean; } | { type: "tool-result"; toolCallId: string; toolName: string; result: unknown; - providerExecuted?: boolean; } | RuntimeAttachmentPart | ChatSourceUrlUiPart @@ -140,7 +132,6 @@ type ProviderToolCallPart = { toolCallId: string; toolName: string; input: Record; - providerExecuted?: boolean; }; /** Error shape for agent runtime message conversion. */ @@ -221,7 +212,6 @@ function convertStructuredPart(part: StructuredProviderPart): AgentRuntimeMessag toolCallId: part.toolCallId, toolName: part.toolName, args: "input" in part ? toChildRunToolInputRecord(part.input) : {}, - ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; case "tool-result": @@ -230,7 +220,6 @@ function convertStructuredPart(part: StructuredProviderPart): AgentRuntimeMessag toolCallId: part.toolCallId, toolName: part.toolName, result: "output" in part ? part.output : null, - ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; case "image": @@ -383,12 +372,7 @@ export function getAgentRuntimeReasoningPart(part: unknown): ProviderReasoningPa /** Return a runtime tool-call part when the value carries a tool call. */ export function getAgentRuntimeToolCallPart( part: unknown, -): { - toolCallId: string; - toolName: string; - input: Record; - providerExecuted?: boolean; -} | null { +): { toolCallId: string; toolName: string; input: Record } | null { if (!isRecord(part) || typeof part.type !== "string") { return null; } @@ -412,7 +396,6 @@ export function getAgentRuntimeToolCallPart( toolCallId, toolName, input: toChildRunToolInputRecord(part.args ?? part.input), - ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -420,12 +403,7 @@ export function getAgentRuntimeToolCallPart( export function getAgentRuntimeToolResultPart( part: unknown, toolNameFallback?: string, -): { - toolCallId: string; - toolName: string; - output: unknown; - providerExecuted?: boolean; -} | null { +): { toolCallId: string; toolName: string; output: unknown } | null { if (!isRecord(part) || part.type !== "tool-result" && part.type !== "tool_result") { return null; } @@ -447,7 +425,6 @@ export function getAgentRuntimeToolResultPart( : Object.hasOwn(part, "output") ? part.output : null, - ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -465,14 +442,12 @@ export function createToolResultPart(part: { toolCallId: string; toolName: string; output: unknown; - providerExecuted?: boolean; }): ChatToolResultPart { return { type: "tool-result", toolCallId: part.toolCallId, toolName: part.toolName, output: toToolResultOutput(part.output), - ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -533,7 +508,6 @@ function collectAgentRuntimeProviderContentParts( toolCallId: toolCallPart.toolCallId, toolName: toolCallPart.toolName, input: toolCallPart.input, - ...(toolCallPart.providerExecuted === true ? { providerExecuted: true } : {}), }); } } @@ -643,7 +617,6 @@ function convertAssistantAgentRuntimePartsToProviderMessages( toolCallId: toolCallPart.toolCallId, toolName: toolCallPart.toolName, input: toolCallPart.input, - ...(toolCallPart.providerExecuted === true ? { providerExecuted: true } : {}), }); } } diff --git a/src/agent/runtime/provider-replay-limits.ts b/src/agent/runtime/provider-replay-limits.ts index 6e5d57449b..6725f5f66b 100644 --- a/src/agent/runtime/provider-replay-limits.ts +++ b/src/agent/runtime/provider-replay-limits.ts @@ -2,4 +2,3 @@ export const MAX_ANTHROPIC_RAW_ASSISTANT_MESSAGES = 6; export const MAX_PROVIDER_REPLAY_RAW_METADATA_DEPTH = 64; export const MAX_PROVIDER_REPLAY_RAW_METADATA_NODES = 65_536; export const MAX_PROVIDER_REPLAY_RAW_METADATA_STRING_CHARS = 8 * 1024 * 1024; -export const MAX_PROVIDER_REPLAY_REQUEST_BODY_BYTES = 10 * 1024 * 1024; diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index 4ea4e30594..a305652176 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -1,12 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; -import { - assertEquals, - assertInstanceOf, - assertStringIncludes, - assertThrows, -} from "#veryfront/testing/assert.ts"; +import { assertEquals, assertStringIncludes, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { VeryfrontError } from "#veryfront/errors"; import { convertToTextGenerationRuntimeMessage, convertToTextGenerationRuntimeMessages, @@ -513,149 +507,6 @@ describe("text-generation-runtime-message-converter", () => { }]); }); - it("reports split generic provider metadata without checkpoint-specific wording", () => { - const providerMetadata = { testProvider: { opaque: true } }; - const message = attachProviderMetadata({ - id: "a-split", - role: "assistant", - parts: [ - { - type: "tool-lookup", - toolCallId: "call-1", - toolName: "lookup", - args: { query: "veryfront" }, - }, - { - type: "tool-result", - toolCallId: "call-1", - toolName: "lookup", - result: { matches: 1 }, - }, - { type: "text", text: "Found it." }, - ], - } as unknown as Message, providerMetadata); - - const error = assertThrows(() => convertToTextGenerationRuntimeMessages([message])); - assertInstanceOf(error, VeryfrontError); - - assertEquals(error.slug, "provider-metadata-split-unsupported"); - assertEquals( - error.detail, - "provider metadata cannot be attached after assistant turn splitting", - ); - }); - - it("splits anthropic raw assistant messages across the split assistant turns", () => { - const rawToolUse = { - type: "tool_use", - id: "call-1", - name: "lookup", - input: { query: "veryfront" }, - }; - const rawText = { type: "text", text: "Found it." }; - const message = attachProviderMetadata({ - id: "a-split", - role: "assistant", - parts: [ - { type: "tool-lookup", toolCallId: "call-1", toolName: "lookup", args: rawToolUse.input }, - { type: "tool-result", toolCallId: "call-1", toolName: "lookup", result: { matches: 1 } }, - { type: "text", text: "Found it." }, - ], - } as unknown as Message, { anthropic: { rawAssistantMessages: [[rawToolUse], [rawText]] } }); - - const converted = convertToTextGenerationRuntimeMessages([message]); - const assistantMetadata = converted - .filter((entry) => entry.role === "assistant") - .map((entry) => entry.providerMetadata); - - assertEquals(assistantMetadata, [ - { anthropic: { rawAssistantMessages: [[rawToolUse]] } }, - { anthropic: { rawAssistantMessages: [[rawText]] } }, - ]); - }); - - it("groups result-only Anthropic responses with the following split assistant turn", () => { - const rawProviderCall = { - type: "server_tool_use", - id: "server-call-1", - name: "web_search", - input: { query: "veryfront" }, - }; - const rawToolUse = { - type: "tool_use", - id: "call-1", - name: "lookup", - input: { query: "veryfront" }, - }; - const rawProviderResult = { - type: "web_search_tool_result", - tool_use_id: "server-call-1", - content: [], - }; - const rawText = { type: "text", text: "Found it." }; - const message = attachProviderMetadata({ - id: "a-split-pause", - role: "assistant", - parts: [ - { - type: "tool-web_search", - toolCallId: "server-call-1", - toolName: "web_search", - args: rawProviderCall.input, - providerExecuted: true, - }, - { type: "tool-lookup", toolCallId: "call-1", toolName: "lookup", args: rawToolUse.input }, - { - type: "tool-result", - toolCallId: "server-call-1", - toolName: "web_search", - result: [], - providerExecuted: true, - }, - { type: "tool-result", toolCallId: "call-1", toolName: "lookup", result: { matches: 1 } }, - { type: "text", text: "Found it." }, - ], - } as unknown as Message, { - anthropic: { - rawAssistantMessages: [ - [rawProviderCall, rawToolUse], - [rawProviderResult], - [rawText], - ], - }, - }); - - const converted = convertToTextGenerationRuntimeMessages([message]); - const assistantMetadata = converted - .filter((entry) => entry.role === "assistant") - .map((entry) => entry.providerMetadata); - - assertEquals(assistantMetadata, [{ - anthropic: { rawAssistantMessages: [[rawProviderCall, rawToolUse]] }, - }, { - anthropic: { rawAssistantMessages: [[rawProviderResult], [rawText]] }, - }]); - }); - - it("rejects anthropic raw assistant messages that do not match the split segments", () => { - const message = attachProviderMetadata({ - id: "a-split", - role: "assistant", - parts: [ - { type: "tool-lookup", toolCallId: "call-1", toolName: "lookup", args: { q: "vf" } }, - { type: "tool-result", toolCallId: "call-1", toolName: "lookup", result: { matches: 1 } }, - { type: "text", text: "Found it." }, - ], - } as unknown as Message, { - anthropic: { rawAssistantMessages: [[{ type: "text", text: "Found it." }]] }, - }); - - const error = assertThrows(() => convertToTextGenerationRuntimeMessages([message])); - assertInstanceOf(error, VeryfrontError); - assertEquals(error.slug, "provider-metadata-split-unsupported"); - assertEquals(error.context, { assistantSegmentCount: 2 }); - }); - it("omits provider-executed tool-only assistant messages from replay", () => { const messages = [ { id: "u1", role: "user", parts: [{ type: "text", text: "search tax guidance" }] }, @@ -1187,39 +1038,6 @@ describe("text-generation-runtime-message-converter", () => { "every trailing assistant message must be stripped so the request never ends on an unanswered tool call", ); }); - - it("keeps trailing assistant messages that carry exact provider replay metadata", () => { - const providerMetadata = { - anthropic: { - rawAssistantMessages: [[{ - type: "thinking", - thinking: "", - signature: "replay-signature", - }]], - }, - }; - const trailingAssistant = attachProviderMetadata({ - id: "assistant-replay", - role: "assistant", - parts: [{ - type: "reasoning", - text: "Private reasoning.", - signature: "replay-signature", - }], - } as unknown as Message, providerMetadata); - const messages: Message[] = [ - { id: "u1", role: "user", parts: [{ type: "text", text: "continue" }] }, - trailingAssistant, - ]; - - const requestMessages = convertToTextGenerationRuntimeRequestMessages(messages); - - assertEquals(requestMessages.at(-1), { - role: "assistant", - content: [{ type: "text", text: "" }], - providerMetadata, - }); - }); }); describe("attachment reachability across the conversion entry points", () => { diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 1f6d42dbc5..4641196d63 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -18,10 +18,8 @@ import type { } from "./text-generation-runtime-message-types.ts"; import { assertProviderReachableAttachment } from "./attachment-reachability.ts"; import { buildDataFileAnnotation } from "#veryfront/chat/types.ts"; -import { PROVIDER_METADATA_SPLIT_UNSUPPORTED } from "#veryfront/errors"; import { getTextFromParts, getToolArguments, type Message, type ToolCallPart } from "../types.ts"; import { readAttachedProviderMetadata } from "./provider-metadata.ts"; -import { isAnthropicProviderToolResultBlock } from "./anthropic-provider-replay-block.ts"; function getStringPartField(part: unknown, key: string): string | undefined { if (!part || typeof part !== "object" || Array.isArray(part)) return undefined; @@ -373,49 +371,6 @@ function hasProviderSendableAssistantContent(message: Message): boolean { }); } -function createSplitAnthropicRawAssistantMetadata( - providerMetadata: Record, - segmentCount: number, -): Record[] | undefined { - const anthropic = providerMetadata.anthropic; - if (!isRecord(anthropic)) return undefined; - const rawAssistantMessages = anthropic.rawAssistantMessages; - if (!Array.isArray(rawAssistantMessages)) { - return undefined; - } - - const segmentRawAssistantMessages: unknown[][][] = []; - let pendingResultOnlyMessages: unknown[][] = []; - for (const rawAssistantMessage of rawAssistantMessages) { - if (!Array.isArray(rawAssistantMessage)) return undefined; - const isResultOnly = rawAssistantMessage.length > 0 && - rawAssistantMessage.every((block) => - isRecord(block) && isAnthropicProviderToolResultBlock(block) - ); - if (isResultOnly) { - pendingResultOnlyMessages.push(rawAssistantMessage); - continue; - } - if (segmentRawAssistantMessages.length >= segmentCount) return undefined; - segmentRawAssistantMessages.push([...pendingResultOnlyMessages, rawAssistantMessage]); - pendingResultOnlyMessages = []; - } - const finalSegment = segmentRawAssistantMessages.at(-1); - if (pendingResultOnlyMessages.length > 0) { - if (!finalSegment) return undefined; - finalSegment.push(...pendingResultOnlyMessages); - } - if (segmentRawAssistantMessages.length !== segmentCount) return undefined; - - return segmentRawAssistantMessages.map((segmentMessages) => ({ - ...providerMetadata, - anthropic: { - ...anthropic, - rawAssistantMessages: segmentMessages, - }, - })); -} - function convertAssistantMessageToTextGenerationRuntimeMessages( message: Message, providerExecutedToolCallIds: Set, @@ -519,10 +474,8 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( const providerMetadata = readAttachedProviderMetadata(message); const assistantMessages = messages.filter((entry) => entry.role === "assistant"); - // Provider metadata describes one provider response. Attaching it after - // conversion split that response would pair it with an incomplete - // projection, and dropping it would silently send the provider an unsigned - // canonical rebuild when the metadata came from replay state. + // Exact replay metadata describes one provider response. Attaching it after + // conversion split that response would pair it with an incomplete projection. if (providerMetadata !== undefined && assistantMessages.length === 1) { assistantMessages[0]!.providerMetadata = providerMetadata; } else if (providerMetadata !== undefined && messages.length === 0) { @@ -531,21 +484,6 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( content: [{ type: "text", text: "" }], providerMetadata, }); - } else if (providerMetadata !== undefined) { - const splitMetadata = createSplitAnthropicRawAssistantMetadata( - providerMetadata, - assistantMessages.length, - ); - if (splitMetadata !== undefined) { - for (const [index, assistantMessage] of assistantMessages.entries()) { - assistantMessage.providerMetadata = splitMetadata[index]; - } - return messages; - } - throw PROVIDER_METADATA_SPLIT_UNSUPPORTED.create({ - detail: "provider metadata cannot be attached after assistant turn splitting", - context: { assistantSegmentCount: assistantMessages.length }, - }); } return messages; @@ -617,16 +555,9 @@ export function convertToTextGenerationRuntimeRequestMessages( ): TextGenerationRuntimeMessage[] { const requestMessages = convertToTextGenerationRuntimeMessages(messages, options); - while (shouldTrimTrailingAssistantRequestMessage(requestMessages.at(-1))) { + while (requestMessages.at(-1)?.role === "assistant") { requestMessages.pop(); } return requestMessages; } - -function shouldTrimTrailingAssistantRequestMessage( - message: TextGenerationRuntimeMessage | undefined, -): boolean { - if (message?.role !== "assistant") return false; - return message.providerMetadata === undefined; -} diff --git a/src/chat/message-part-parsing.ts b/src/chat/message-part-parsing.ts index 8e1d656119..86b6e6bd58 100644 --- a/src/chat/message-part-parsing.ts +++ b/src/chat/message-part-parsing.ts @@ -16,10 +16,6 @@ import { } from "./part-field-access.ts"; import type { JsonValue } from "./part-field-access.ts"; -type ParsedToolResultOutput = - | { type: "json"; value: JsonValue } - | { type: "error-text"; value: string }; - export function getToolPart(part: unknown): { toolCallId: string; toolName: string; @@ -27,7 +23,6 @@ export function getToolPart(part: unknown): { state: string; output?: unknown; errorText?: string; - providerExecuted?: boolean; } | null { if (!isRecord(part) || typeof part.type !== "string") { return null; @@ -57,7 +52,6 @@ export function getToolPart(part: unknown): { state, ...(output !== undefined ? { output } : {}), ...(errorText !== undefined ? { errorText } : {}), - ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -68,7 +62,6 @@ export function getRawToolCallPart(part: unknown): { state?: string; output?: unknown; errorText?: string; - providerExecuted?: boolean; } | null { if (!isRecord(part) || part.type !== "tool_call") { return null; @@ -92,15 +85,21 @@ export function getRawToolCallPart(part: unknown): { ...(typeof part.state === "string" ? { state: part.state } : {}), ...(Object.hasOwn(part, "output") ? { output: part.output } : {}), ...(typeof part.errorText === "string" ? { errorText: part.errorText } : {}), - ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } export function getRawToolResultPart(part: unknown): { toolCallId: string; toolName?: string; - output: ParsedToolResultOutput; - providerExecuted?: boolean; + output: + | { + type: "json"; + value: JsonValue; + } + | { + type: "error-text"; + value: string; + }; } | null { if (!isRecord(part) || part.type !== "tool_result") { return null; @@ -131,13 +130,21 @@ export function getRawToolResultPart(part: unknown): { toolCallId, ...(toolName ? { toolName } : {}), output, - ...(part.providerExecuted === true ? { providerExecuted: true } : {}), }; } export function buildToolResultOutput( toolPart: { state: string; output?: unknown; errorText?: string }, -): ParsedToolResultOutput | null { +): + | { + type: "json"; + value: JsonValue; + } + | { + type: "error-text"; + value: string; + } + | null { if (toolPart.state === "output-available") { return { type: "json", diff --git a/src/chat/message-prep.test.ts b/src/chat/message-prep.test.ts index f83ef8a9f9..29ea4c4f19 100644 --- a/src/chat/message-prep.test.ts +++ b/src/chat/message-prep.test.ts @@ -2184,7 +2184,6 @@ Deno.test("prepareProviderModelMessagesFromUiMessages prefers completed tool out toolCallId: "toolu_01Search", toolName: "notion__search_notion", input: { query: "research notes" }, - providerExecuted: true, }, ], }, @@ -2199,7 +2198,6 @@ Deno.test("prepareProviderModelMessagesFromUiMessages prefers completed tool out type: "json", value: { data: [] }, }, - providerExecuted: true, }, ], }, diff --git a/src/chat/provider-message-conversion.ts b/src/chat/provider-message-conversion.ts index 59013bdab1..ed496c8938 100644 --- a/src/chat/provider-message-conversion.ts +++ b/src/chat/provider-message-conversion.ts @@ -7,6 +7,7 @@ * and settles into one ProviderModelMessage[]. */ import { isRecord } from "./part-field-access.ts"; +import type { JsonValue } from "./part-field-access.ts"; import { buildRawToolCallResultOutput, buildToolResultOutput, @@ -33,22 +34,17 @@ type ProviderToolResultContent = { type: "tool-result"; toolCallId: string; toolName: string; - output: ChatToolResultPart["output"]; - providerExecuted?: boolean; + output: + | { + type: "json"; + value: JsonValue; + } + | { + type: "error-text"; + value: string; + }; }; -type AssistantContentPart = - | { type: "text"; text: string } - | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } - | { type: "file" | "image"; mediaType: string; data: string; filename?: string } - | { - type: "tool-call"; - toolCallId: string; - toolName: string; - input: Record; - providerExecuted?: boolean; - }; - function buildToolNameMap(parts: ReadonlyArray): Map { const toolNames = new Map(); @@ -88,7 +84,6 @@ function resolveRawToolResultPart( toolCallId: rawResult.toolCallId, toolName, output: rawResult.output, - ...(rawResult.providerExecuted === true ? { providerExecuted: true } : {}), }; } @@ -160,7 +155,12 @@ function convertAssistantMessage( replayMatches: ProviderVisibleToolReplayMatches, ): ProviderModelMessage[] { const toolNamesById = buildToolNameMap(message.parts); - const assistantContent: AssistantContentPart[] = []; + const assistantContent: Array< + | { type: "text"; text: string } + | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } + | { type: "file" | "image"; mediaType: string; data: string; filename?: string } + | { type: "tool-call"; toolCallId: string; toolName: string; input: Record } + > = []; const deferredAssistantContent: typeof assistantContent = []; const toolResults: ProviderToolResultContent[] = []; const pendingToolCallIds = new Set(); @@ -190,7 +190,13 @@ function convertAssistantMessage( toolResults.length = 0; }; - const pushAssistantPart = (part: AssistantContentPart) => { + const pushAssistantPart = ( + part: + | { type: "text"; text: string } + | { type: "reasoning"; text?: string; signature?: string; redactedData?: string } + | { type: "file" | "image"; mediaType: string; data: string; filename?: string } + | { type: "tool-call"; toolCallId: string; toolName: string; input: Record }, + ) => { if (part.type === "tool-call") { if (deferredAssistantContent.length > 0) { flushAssistantMessage(assistantContent); @@ -229,7 +235,6 @@ function convertAssistantMessage( toolName: string; input: Record; state?: string; - providerExecuted?: boolean; }, resultOutput: ReturnType, ) => { @@ -252,7 +257,6 @@ function convertAssistantMessage( toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, input: toolCall.input, - ...(toolCall.providerExecuted === true ? { providerExecuted: true } : {}), }); if (resultOutput) { @@ -261,7 +265,6 @@ function convertAssistantMessage( toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, output: resultOutput, - ...(toolCall.providerExecuted === true ? { providerExecuted: true } : {}), }); } }; @@ -361,7 +364,6 @@ function convertToolMessage( toolCallId: toolPart.toolCallId, toolName: toolPart.toolName, output, - ...(toolPart.providerExecuted === true ? { providerExecuted: true } : {}), }); } continue; diff --git a/src/errors/error-registry.test.ts b/src/errors/error-registry.test.ts index e6fd571455..b11e2c0a14 100644 --- a/src/errors/error-registry.test.ts +++ b/src/errors/error-registry.test.ts @@ -118,7 +118,6 @@ const PUBLISHED_ERROR_SLUGS: readonly string[] = Object.freeze([ "production-build-required", "project-execution-unavailable", "project-source-empty", - "provider-metadata-split-unsupported", "provider-replay-checkpoint-invalid", "push-conflict", "push-receipt-missing", diff --git a/src/errors/error-registry/agent.ts b/src/errors/error-registry/agent.ts index db5d87c880..b5983cab3d 100644 --- a/src/errors/error-registry/agent.ts +++ b/src/errors/error-registry/agent.ts @@ -94,20 +94,6 @@ export const PROVIDER_REPLAY_CHECKPOINT_INVALID = defineError({ "Verify the trusted source that resolved the run's provider replay checkpoints; do not retry with the same replay state", }); -/** - * Generic provider metadata could not be preserved through a provider request - * conversion. The metadata may be provider-specific state unrelated to replay - * checkpoints, so keep the failure distinct from checkpoint validation. - */ -export const PROVIDER_METADATA_SPLIT_UNSUPPORTED = defineError({ - slug: "provider-metadata-split-unsupported", - category: "AGENT", - status: 500, - title: "Provider metadata cannot be attached after assistant turn splitting", - suggestion: - "Avoid converting one provider response into multiple assistant request messages before provider metadata is consumed", -}); - /** Registry fragment for AGENT errors (slug → definition). */ export const AGENT_REGISTRY = { "agent-error": AGENT_ERROR, @@ -120,5 +106,4 @@ export const AGENT_REGISTRY = { "durable-run-event-persistence-failed": DURABLE_RUN_EVENT_PERSISTENCE_FAILED, "default-model-credential-mismatch": DEFAULT_MODEL_CREDENTIAL_MISMATCH, "provider-replay-checkpoint-invalid": PROVIDER_REPLAY_CHECKPOINT_INVALID, - "provider-metadata-split-unsupported": PROVIDER_METADATA_SPLIT_UNSUPPORTED, } as const; diff --git a/src/errors/index.ts b/src/errors/index.ts index cf82b33438..79cff8dc3c 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -127,7 +127,6 @@ export { PRODUCTION_BUILD_REQUIRED, PROJECT_EXECUTION_UNAVAILABLE, PROJECT_SOURCE_EMPTY, - PROVIDER_METADATA_SPLIT_UNSUPPORTED, PROVIDER_REPLAY_CHECKPOINT_INVALID, PUSH_CONFLICT, PUSH_RECEIPT_MISSING, diff --git a/src/workflow/react/use-workflow-list.ts b/src/workflow/react/use-workflow-list.ts index 0a41442313..c3dbbde07e 100644 --- a/src/workflow/react/use-workflow-list.ts +++ b/src/workflow/react/use-workflow-list.ts @@ -71,10 +71,8 @@ export function useWorkflowList(options: UseWorkflowListOptions = {}): UseWorkfl ); const requestSequence = useRef(0); const activeRequestSequence = useRef(null); - const isMounted = useRef(true); const isCurrentRequest = useCallback( (request: { authorizationContext: typeof authorizationContext; sequence: number }): boolean => - isMounted.current && request.sequence === requestSequence.current && request.authorizationContext === currentAuthorizationContext.current, [], @@ -169,15 +167,6 @@ export function useWorkflowList(options: UseWorkflowListOptions = {}): UseWorkfl ], ); - useEffect(() => { - isMounted.current = true; - return () => { - isMounted.current = false; - requestSequence.current++; - activeRequestSequence.current = null; - }; - }, []); - useEffect(() => { // Data from one authorization context must not remain visible while a // replacement request is pending or after it fails. diff --git a/src/workflow/react/use-workflow-start.test.tsx b/src/workflow/react/use-workflow-start.test.tsx index 48f8aacdd7..f92c2d1a66 100644 --- a/src/workflow/react/use-workflow-start.test.tsx +++ b/src/workflow/react/use-workflow-start.test.tsx @@ -4,7 +4,6 @@ import { createRoot } from "react-dom/client"; import { JSDOM } from "npm:jsdom@28.0.0"; import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; -import { waitFor } from "#veryfront/testing/deno-compat.ts"; import { installMockFetch, restoreMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { useApproval, type UseApprovalResult } from "./use-approval.ts"; import { useWorkflow, type UseWorkflowResult } from "./use-workflow.ts"; @@ -127,9 +126,7 @@ describe("useWorkflowStart", () => { secondResponse.resolve(Response.json({ runId: "run-second" })); assertEquals(await secondStart, "run-second"); - await waitFor(() => hook!.isStarting === false, { - message: "second start should leave the hook idle", - }); + await new Promise((resolve) => setTimeout(resolve, 20)); assertEquals(hook!.isStarting, false); assertEquals(hook!.lastRunId, "run-second"); assertEquals(startedRunIds, ["run-first", "run-second"]); @@ -563,43 +560,6 @@ describe("useWorkflowStart", () => { } }); - it("ignores a pending workflow list failure after unmount", async () => { - const restoreDom = installDom(); - const response = Promise.withResolvers(); - let root: ReturnType | null = null; - let domRestored = false; - const consoleErrors: unknown[][] = []; - const originalConsoleError = console.error; - console.error = (...args: unknown[]): void => { - consoleErrors.push(args); - }; - - installMockFetch((() => response.promise) as typeof fetch); - - function Capture(): null { - useWorkflowList({ autoRefresh: false }); - return null; - } - - try { - root = createRoot(document.getElementById("root")!); - flushSync(() => root!.render()); - flushSync(() => root!.unmount()); - root = null; - - response.reject(new Error("late list failure")); - await new Promise((resolve) => setTimeout(resolve, 20)); - assertEquals(document.getElementById("root")?.textContent, ""); - assertEquals(consoleErrors, []); - restoreDom(); - domRestored = true; - } finally { - console.error = originalConsoleError; - if (root) flushSync(() => root!.unmount()); - if (!domRestored) restoreDom(); - } - }); - it("keeps loading active when an obsolete refresh finishes during replacement loading", async () => { const restoreDom = installDom(); const oldRefreshResponse = Promise.withResolvers(); From 36f17e6a53d0c8e3e2990aae33b808e411f7a46d Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 23:51:11 +0200 Subject: [PATCH 47/56] fix(agent): dedupe shared-id anchor groups and classify hosted runtime providers preserveEmptyAssistantAnchors pushed a source id's whole message group once per same-id source message, duplicating split turns; the emitted-id guard now covers the main pass. getActiveProviderReplayProvider derived the provider from the raw runtime id, so veryfront-cloud/anthropic/* classified as unsupported and would hard-fail every delivered checkpoint; it now normalizes the hosted prefix first. --- src/agent/runtime/index.ts | 5 +- ...ovider-message-anchor-preservation.test.ts | 48 +++++++++++++++++++ .../provider-message-anchor-preservation.ts | 1 + 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/chat/provider-message-anchor-preservation.test.ts diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index df69509d74..d301596308 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -206,10 +206,13 @@ const NativeError = Error; function getActiveProviderReplayProvider( languageModel: ModelRuntime, ): ProviderReplayProvider | "unsupported" { + const modelRuntimeId = getModelRuntimeId(languageModel); const provider = (typeof languageModel.modelProvider === "string" ? languageModel.modelProvider : undefined) ?? getModelRuntimeProvider(languageModel) ?? - getModelRuntimeId(languageModel)?.split("/")[0]; + (modelRuntimeId !== undefined + ? resolveRuntimeGenAiProviderName(modelRuntimeId) ?? modelRuntimeId.split("/")[0] + : undefined); if (provider === "anthropic") return "anthropic"; if (provider === "openai") return "openai-responses"; return "unsupported"; diff --git a/src/chat/provider-message-anchor-preservation.test.ts b/src/chat/provider-message-anchor-preservation.test.ts new file mode 100644 index 0000000000..d254553f78 --- /dev/null +++ b/src/chat/provider-message-anchor-preservation.test.ts @@ -0,0 +1,48 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#std/assert"; +import { preserveEmptyAssistantAnchors } from "./provider-message-anchor-preservation.ts"; +import { withProviderModelMessageSourceId } from "./conversation.ts"; +import type { ChatUiMessage, ProviderModelMessage } from "./types.ts"; + +Deno.test("preserveEmptyAssistantAnchors emits shared-id message groups once", () => { + const firstHalf = withProviderModelMessageSourceId( + { role: "assistant", content: [{ type: "text", text: "first half" }] }, + "a1", + ); + const secondHalf = withProviderModelMessageSourceId( + { role: "assistant", content: [{ type: "text", text: "second half" }] }, + "a1", + ); + const sourceMessages = [ + { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] }, + { id: "a1", role: "assistant", parts: [{ type: "text", text: "first half" }] }, + { id: "a1", role: "assistant", parts: [{ type: "text", text: "second half" }] }, + { id: "u2", role: "user", parts: [{ type: "text", text: "next" }] }, + ] as unknown as readonly ChatUiMessage[]; + + const preserved = preserveEmptyAssistantAnchors( + [firstHalf, secondHalf], + sourceMessages, + ["a1"], + ); + + assertEquals(preserved, [firstHalf, secondHalf]); +}); + +Deno.test("preserveEmptyAssistantAnchors inserts one anchor per preserved empty assistant id", () => { + const sourceMessages = [ + { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] }, + { id: "a1", role: "assistant", parts: [] }, + { id: "u2", role: "user", parts: [{ type: "text", text: "next" }] }, + ] as unknown as readonly ChatUiMessage[]; + const userMessage: ProviderModelMessage = withProviderModelMessageSourceId( + { role: "user", content: [{ type: "text", text: "hi" }] }, + "u1", + ); + + const preserved = preserveEmptyAssistantAnchors([userMessage], sourceMessages, ["a1"]); + + assertEquals(preserved.length, 2); + assertEquals(preserved[0], userMessage); + assertEquals(preserved[1], { role: "assistant", content: [] }); +}); diff --git a/src/chat/provider-message-anchor-preservation.ts b/src/chat/provider-message-anchor-preservation.ts index 2b73d1ca23..9cae0af332 100644 --- a/src/chat/provider-message-anchor-preservation.ts +++ b/src/chat/provider-message-anchor-preservation.ts @@ -43,6 +43,7 @@ export function preserveEmptyAssistantAnchors( const emittedSourceIds = new Set(); for (const sourceMessage of sourceMessages) { const sourceId = sourceMessage.id; + if (emittedSourceIds.has(sourceId)) continue; const existingMessages = messagesBySourceId.get(sourceId); if (existingMessages) { result.push(...existingMessages); From 72744a7c862d0e4d6eff8a4a6279d68e6191e9d8 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 29 Aug 2026 23:51:11 +0200 Subject: [PATCH 48/56] test(agent): pin persisted provider-executed turns at the prompt boundary Runs a persisted provider-executed tool turn through prepareHostedChatRuntimeMessages and convertToTextGenerationRuntimeRequestMessages and asserts the tool call and result reach the request messages - the end-to-end gap that let the erasure regression pass a green suite. Checkpoint-anchored preservation now pins the flag-free stage-1 projection. Regenerates the error reference and api-reference line anchors. --- docs/api-reference/veryfront/agent.md | 152 +++++++++++----------- docs/api-reference/veryfront/errors.md | 1 - docs/guides/errors.md | 7 - src/agent/hosted/chat-preparation.test.ts | 67 +++++++++- 4 files changed, 141 insertions(+), 86 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index fdab8d0002..828a83d255 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -228,29 +228,29 @@ Request payload for parsed hosted chat. | Property | Type | Description | Source | | ------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -| `agentId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L72) | -| `userId` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L73) | -| `authToken` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L74) | -| `serverEnvelopeVerified?` | `true` | True only after a server envelope credential is verified and bound to this run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L76) | -| `serverResolvedProviderReplayCheckpoints?` | `unknown` | Provider-native replay state resolved by the server outside forwardedProps so large opaque provider blocks do not consume the public forwardedProps budget. Ignored unless `serverEnvelopeVerified` is true. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L82) | -| `serverResolvedIntegrationToolNames?` | `readonly string[]` | Integration tools the control plane resolved for this run, taken from the verified run-event token rather than the request body. Absent unless a token verified, so a forged body can never introduce it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L88) | -| `messages` | `ChatUiMessage[]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L89) | -| `validatedContext` | `ChatRequestContext` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L90) | -| `projectId` | `string \| null` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L91) | -| `projectSlug?` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L92) | -| `conversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L93) | -| `parentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L94) | -| `upstreamParentConversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L95) | -| `upstreamParentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L96) | -| `spawnedFromToolCallId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L97) | -| `taskId?` | `string` | Durable task identity supplied only by a signed runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L99) | -| `model` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L100) | -| `allowDelegation` | `boolean \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L101) | -| `forwardedProps` | `HostedChatRequest["forwardedProps"]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L102) | -| `runtimeOverrides` | `ChatRuntimeOverrides \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L103) | -| `durableRootRun` | `DurableRootRunDescriptor \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L104) | -| `persistLatestUserMessageBeforeDurableRun` | `boolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L105) | -| `agentConfig?` | `RuntimeAgentMarkdownDefinition` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L106) | +| `agentId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L71) | +| `userId` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L72) | +| `authToken` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L73) | +| `serverEnvelopeVerified?` | `true` | True only after a server envelope credential is verified and bound to this run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L75) | +| `serverResolvedProviderReplayCheckpoints?` | `unknown` | Provider-native replay state resolved by the server outside forwardedProps so large opaque provider blocks do not consume the public forwardedProps budget. Ignored unless `serverEnvelopeVerified` is true. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L81) | +| `serverResolvedIntegrationToolNames?` | `readonly string[]` | Integration tools the control plane resolved for this run, taken from the verified run-event token rather than the request body. Absent unless a token verified, so a forged body can never introduce it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L87) | +| `messages` | `ChatUiMessage[]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L88) | +| `validatedContext` | `ChatRequestContext` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L89) | +| `projectId` | `string \| null` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L90) | +| `projectSlug?` | `string` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L91) | +| `conversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L92) | +| `parentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L93) | +| `upstreamParentConversationId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L94) | +| `upstreamParentRunId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L95) | +| `spawnedFromToolCallId` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L96) | +| `taskId?` | `string` | Durable task identity supplied only by a signed runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L98) | +| `model` | `string \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L99) | +| `allowDelegation` | `boolean \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L100) | +| `forwardedProps` | `HostedChatRequest["forwardedProps"]` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L101) | +| `runtimeOverrides` | `ChatRuntimeOverrides \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L102) | +| `durableRootRun` | `DurableRootRunDescriptor \| undefined` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L103) | +| `persistLatestUserMessageBeforeDurableRun` | `boolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L104) | +| `agentConfig?` | `RuntimeAgentMarkdownDefinition` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L105) | ### `PrepareHostedConversationRootRunContextInput` @@ -495,19 +495,19 @@ Input delivered to a hosted agent-service detached execution callback. | `PROJECT_STEERING_FILE_MUTATION_TOOL_NAMES` | Shared project steering file mutation tool names value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/project/steering-mutation.ts#L15) | | `ROOT_OWNED_CHILD_RESULT_INSTRUCTION` | Shared root owned child result instruction value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L39) | | `RUNTIME_LOAD_SKILL_DESCRIPTION` | Shared runtime load skill description value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/load-skill-tool.ts#L68) | -| `RuntimeAgentContextItemSchema` | Schema for runtime agent context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L131) | -| `RuntimeAgentIdSchema` | Schema for runtime agent ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L64) | -| `RuntimeAgentProjectContextSchema` | Schema for runtime agent project context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L261) | -| `RuntimeAgentRunContextSchema` | Schema for runtime agent run context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L344) | -| `RuntimeAgentRunIdSchema` | Schema for runtime agent run ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L33) | -| `RuntimeAgentRunInvocationSchema` | Schema for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L396) | -| `RuntimeAgentServiceIdSchema` | Schema for runtime agent service ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L57) | -| `RuntimeAgentSourceContextSchema` | Schema for runtime agent source context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L154) | -| `RuntimeAgentTargetKindSchema` | Schema for runtime agent target kind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L163) | -| `RuntimeAgentToolCallIdSchema` | Schema for runtime agent tool call ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L45) | -| `RuntimeAgentToolNameSchema` | Schema for runtime agent tool name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L76) | -| `RuntimeAgentToolSchema` | Schema for runtime agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L101) | -| `RuntimeAgentValidatedClaimsSchema` | Schema for runtime agent validated claims. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L275) | +| `RuntimeAgentContextItemSchema` | Schema for runtime agent context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L130) | +| `RuntimeAgentIdSchema` | Schema for runtime agent ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L63) | +| `RuntimeAgentProjectContextSchema` | Schema for runtime agent project context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L260) | +| `RuntimeAgentRunContextSchema` | Schema for runtime agent run context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L343) | +| `RuntimeAgentRunIdSchema` | Schema for runtime agent run ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L32) | +| `RuntimeAgentRunInvocationSchema` | Schema for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L395) | +| `RuntimeAgentServiceIdSchema` | Schema for runtime agent service ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L56) | +| `RuntimeAgentSourceContextSchema` | Schema for runtime agent source context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L153) | +| `RuntimeAgentTargetKindSchema` | Schema for runtime agent target kind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L162) | +| `RuntimeAgentToolCallIdSchema` | Schema for runtime agent tool call ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L44) | +| `RuntimeAgentToolNameSchema` | Schema for runtime agent tool name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L75) | +| `RuntimeAgentToolSchema` | Schema for runtime agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L100) | +| `RuntimeAgentValidatedClaimsSchema` | Schema for runtime agent validated claims. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L274) | | `RuntimeSkillFrontmatterSchema` | Schema for runtime skill frontmatter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L274) | | `SLASH_COMMAND_ARTIFACT_REMINDER` | Shared slash command artifact reminder value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L118) | | `SYNTHESIZE_DELEGATED_FINDINGS_IN_ROOT_VOICE` | Shared synthesize delegated findings in root voice value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L23) | @@ -575,9 +575,9 @@ Input delivered to a hosted agent-service detached execution callback. | `buildInvokeAgentFollowupInstruction` | Builds invoke agent followup instruction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L100) | | `buildInvokeAgentTraceAttributes` | Builds invoke agent trace attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L235) | | `buildParsedAgentServiceAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L581) | +| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L580) | | `buildParsedHostedAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L581) | +| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L580) | | `buildProjectContextPromptBlock` | Builds the shared project-context prompt block (project reference + branch). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L103) | | `buildProjectInstructionsPromptBlock` | Builds the project-instructions prompt block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L120) | | `buildProjectServiceTraceAttributes` | Builds Datadog unified service trace attributes for a hosted project run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L93) | @@ -586,7 +586,7 @@ Input delivered to a hosted agent-service detached execution callback. | `buildRootOwnedChildRunResultHint` | Builds root owned child run result hint. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/result-summary.ts#L348) | | `buildRootOwnedChildRunResultText` | Builds root owned child run result text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/result-summary.ts#L333) | | `buildRootOwnedDelegatedFindingsInstruction` | Builds root owned delegated findings instruction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L34) | -| `buildRuntimeAgentControlPlaneStreamRequestFromInvocation` | Builds runtime agent control plane stream request from invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L453) | +| `buildRuntimeAgentControlPlaneStreamRequestFromInvocation` | Builds runtime agent control plane stream request from invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L452) | | `buildRuntimeAvailableSkillsPromptBlock` | Builds a bounded, injection-safe runtime available-skills prompt. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-prompt.ts#L457) | | `buildRuntimeLoadedSkillResponse` | Build a bounded loaded-skill response and fail closed on invalid metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1097) | | `buildRuntimeSkillDefinition` | Build a bounded, immutable runtime skill definition. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L913) | @@ -608,9 +608,9 @@ Input delivered to a hosted agent-service detached execution callback. | `composeAbortSignals` | Compose abort signals helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-stream-watchdog.ts#L61) | | `computeOpenToolCalls` | Compute open tool calls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L174) | | `containsExactArtifactPathValue` | Contains exact artifact path value helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/slash-command-artifact-policy.ts#L195) | -| `convertAgentRuntimeMessagesToProviderMessages` | Convert agent runtime messages to provider messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L736) | +| `convertAgentRuntimeMessagesToProviderMessages` | Convert agent runtime messages to provider messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L709) | | `convertCompactedProviderMessagesToChildForkRuntimeMessages` | Convert compacted provider messages to child fork runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L126) | -| `convertProviderMessagesToAgentRuntimeMessages` | Convert provider messages to agent runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L724) | +| `convertProviderMessagesToAgentRuntimeMessages` | Convert provider messages to agent runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L697) | | `createAgentServiceAgUiValidationErrorResponse` | Response payload for create hosted AG-UI validation error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L151) | | `createAgentServiceAuth` | Create hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L351) | | `createAgentServiceChildMirrorContext` | Context for create hosted child mirror. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L233) | @@ -715,7 +715,7 @@ Input delivered to a hosted agent-service detached execution callback. | `createRuntimePromptBlock` | Create runtime prompt block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/prompt-block.ts#L9) | | `createStreamedStepState` | State for create streamed step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-step-state.ts#L44) | | `createToolExecutionDataEventBridgeStream` | Create tool execution data event bridge stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-execution-data-event-bridge.ts#L41) | -| `createToolResultPart` | Create a chat tool-result part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L464) | +| `createToolResultPart` | Create a chat tool-result part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L441) | | `createVeryfrontCloudAgentServiceChatExecutionRootRunOptions` | Options accepted by create Veryfront Cloud hosted chat execution root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-chat-execution-preparation.ts#L43) | | `createVeryfrontCloudHostedChatExecutionRootRunOptions` | Options accepted by create Veryfront Cloud hosted chat execution root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-chat-execution-preparation.ts#L43) | | `createVeryfrontCloudPreparedAgentServiceChatExecutionRuntimeOptions` | Options accepted by create Veryfront Cloud prepared hosted chat execution runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-prepared-chat-execution-runtime.ts#L83) | @@ -773,9 +773,9 @@ Input delivered to a hosted agent-service detached execution callback. | `formatChildRunStreamPartError` | Error shape for format child run stream part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/execution-support.ts#L30) | | `formatRuntimeSkillMetadata` | Formats bounded runtime skill metadata for prompt use. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-prompt.ts#L342) | | `getAgent` | Return agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/composition/composition.ts#L246) | -| `getAgentRuntimeTextPart` | Return a runtime text part when the value carries text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L358) | -| `getAgentRuntimeToolCallPart` | Return a runtime tool-call part when the value carries a tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L384) | -| `getAgentRuntimeToolResultPart` | Return a runtime tool-result part when the value carries a tool result. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L420) | +| `getAgentRuntimeTextPart` | Return a runtime text part when the value carries text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L347) | +| `getAgentRuntimeToolCallPart` | Return a runtime tool-call part when the value carries a tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L373) | +| `getAgentRuntimeToolResultPart` | Return a runtime tool-result part when the value carries a tool result. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L403) | | `getAgentsAsTools` | Return agents as tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/composition/composition.ts#L295) | | `getAgentServiceTokenFromRequest` | Request payload for get hosted service token from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L234) | | `getAgUiChatUiMessageChunkMetadata` | Return AG-UI chat UI message chunk metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/chat-ui-chunk-encoder.ts#L136) | @@ -867,7 +867,7 @@ Input delivered to a hosted agent-service detached execution callback. | `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeRuntimeSkillReferencePath` | Normalizes and bounds a portable runtime skill reference path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1011) | -| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L588) | +| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L587) | | `parseAgentServiceConfig` | Configuration used by parse agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L159) | | `parseAgUiContextBoolean` | Parses AG-UI context boolean. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L56) | | `parseAgUiContextJsonValue` | Parses AG-UI context JSON value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L27) | @@ -882,12 +882,12 @@ Input delivered to a hosted agent-service detached execution callback. | `parseAppendConversationRunEventsErrorBody` | Parses append conversation run events error body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L54) | | `parseDataStreamSseEvents` | Parses data stream sse events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/data-stream.ts#L14) | | `parseHostedAgentServiceConfig` | Configuration used by parse hosted agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L167) | -| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L588) | +| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L587) | | `parseRuntimeAgentMarkdownDefinition` | Definition for parse runtime agent markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L192) | -| `parseRuntimeAgentRunInvocation` | Parses runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L480) | -| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L627) | -| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L627) | -| `parseRuntimeAgentRunInvocationOrError` | Error shape for parse runtime agent run invocation or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L489) | +| `parseRuntimeAgentRunInvocation` | Parses runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L479) | +| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L626) | +| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L626) | +| `parseRuntimeAgentRunInvocationOrError` | Error shape for parse runtime agent run invocation or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L486) | | `parseRuntimeSkillDocument` | Parses a bounded runtime skill document and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L561) | | `parseRuntimeSkillMetadata` | Parses bounded runtime skill metadata and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L569) | | `parseToolInputObject` | Parses tool input object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-input.ts#L135) | @@ -1001,7 +1001,7 @@ Input delivered to a hosted agent-service detached execution callback. | `toMirroredAgentServiceStreamPart` | Converts a value to mirrored hosted stream part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L89) | | `toMirroredHostedStreamPart` | Converts a value to mirrored hosted stream part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-mirror.ts#L89) | | `updateDefaultResearchArtifacts` | Update default research artifacts helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/default-research-artifact-support.ts#L166) | -| `validateRuntimeAgentTargetSelection` | Validates runtime agent target selection. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L172) | +| `validateRuntimeAgentTargetSelection` | Validates runtime agent target selection. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L171) | | `verifyHostedRuntimeSourceBinding` | Verify that a control-plane request addresses the exact source snapshot served here. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-source-binding.ts#L25) | | `veryfrontApiMcpServer` | Veryfront API MCP server helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/veryfront-cloud-agent-service.ts#L71) | | `veryfrontStudioMcpServer` | Veryfront Studio MCP server helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/veryfront-cloud-agent-service.ts#L78) | @@ -1020,8 +1020,8 @@ Input delivered to a hosted agent-service detached execution callback. | Name | Description | Source | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `AgentRuntime` | Implement agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/index.ts#L1101) | -| `AgentRuntimeMessageConversionError` | Error shape for agent runtime message conversion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L147) | +| `AgentRuntime` | Implement agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/index.ts#L1104) | +| `AgentRuntimeMessageConversionError` | Error shape for agent runtime message conversion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L138) | | `AgentServiceAuthError` | Error shape for hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L14) | | `AppendConversationRunEventsError` | Error shape for append conversation run events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L4) | | `BufferMemory` | Implement buffer memory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/memory/memory.ts#L128) | @@ -1075,8 +1075,8 @@ Input delivered to a hosted agent-service detached execution callback. | `AgentRunEventSink` | Receives events produced within one scoped agent run execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/runtime/model-call-context.ts#L91) | | `AgentRunModelCallContextEvent` | Provider-agnostic input persisted before one model dispatch. System-message provider options contain only validated prompt-cache metadata. Other provider-specific values are excluded because run events are durable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/runtime/model-call-context.ts#L77) | | `AgentRuntimeForkStepRunner` | Public API contract for agent runtime fork step runner. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-stream.ts#L110) | -| `AgentRuntimeMessage` | Message shape for agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L115) | -| `AgentRuntimeMessagePart` | Public API contract for agent runtime message part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L93) | +| `AgentRuntimeMessage` | Message shape for agent runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L107) | +| `AgentRuntimeMessagePart` | Public API contract for agent runtime message part. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L87) | | `AgentServiceActiveSpanAttributes` | Public API contract for hosted agent service active span attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/routes.ts#L57) | | `AgentServiceAgUiChatForwardedConfig` | Configuration used by hosted AG-UI chat forwarded. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L44) | | `AgentServiceAuth` | Public API contract for hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L167) | @@ -1088,9 +1088,9 @@ Input delivered to a hosted agent-service detached execution callback. | `AgentServiceAuthOptions` | Options accepted by hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L157) | | `AgentServiceAuthTrace` | Public API contract for hosted service auth trace. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L116) | | `AgentServiceBootstrapExit` | Public API contract for agent service bootstrap exit. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/bootstrap.ts#L17) | -| `AgentServiceChatProjectAccessError` | Error shape for hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L54) | -| `AgentServiceChatProjectAccessResult` | Result returned from hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L61) | -| `AgentServiceChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L48) | +| `AgentServiceChatProjectAccessError` | Error shape for hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L53) | +| `AgentServiceChatProjectAccessResult` | Result returned from hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L60) | +| `AgentServiceChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L47) | | `AgentServiceChatRuntimeAgent` | Public API contract for hosted chat runtime agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L87) | | `AgentServiceChatRuntimeCreationOptions` | Options accepted by hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L120) | | `AgentServiceChatRuntimeCreationResult` | Result returned from hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L94) | @@ -1466,11 +1466,11 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedChatExecutionRuntime` | Public API contract for hosted chat execution runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L77) | | `HostedChatExecutionRuntimeBootstrap` | Public API contract for hosted chat execution runtime bootstrap. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L99) | | `HostedChatExecutionRuntimeLogger` | Public API contract for hosted chat execution runtime logger. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-execution-runtime.ts#L84) | -| `HostedChatProjectAccessError` | Error shape for hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L54) | -| `HostedChatProjectAccessResult` | Result returned from hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L61) | +| `HostedChatProjectAccessError` | Error shape for hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L53) | +| `HostedChatProjectAccessResult` | Result returned from hosted chat project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L60) | | `HostedChatRequest` | Request payload for hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L471) | | `HostedChatRequestInput` | Input payload for hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request.ts#L473) | -| `HostedChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L48) | +| `HostedChatRequestPrincipal` | Public API contract for hosted chat request principal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L47) | | `HostedChatRuntimeAgent` | Public API contract for hosted chat runtime agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-contract.ts#L87) | | `HostedChatRuntimeAgentAdapterInput` | Input payload for hosted chat runtime agent adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-agent-adapter.ts#L25) | | `HostedChatRuntimeAgentAdapterRunner` | Public API contract for hosted chat runtime agent adapter runner. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-agent-adapter.ts#L14) | @@ -1680,17 +1680,17 @@ Input delivered to a hosted agent-service detached execution callback. | `NormalizedAgentServiceContract` | Public API contract for normalized agent service contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/definition.ts#L114) | | `NormalizedHostedChatRequest` | Request payload for normalized hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L57) | | `OpenToolCalls` | Public API contract for open tool calls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L110) | -| `ParseAgentServiceChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L110) | +| `ParseAgentServiceChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L109) | | `ParseAgUiSseResponseOptions` | Options for `parseAgUiSseResponse()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/sse-parser.ts#L50) | | `ParsedAgentServiceAgUiRequest` | Request payload for parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L59) | -| `ParsedAgentServiceChatRequest` | Request payload for parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L71) | +| `ParsedAgentServiceChatRequest` | Request payload for parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L70) | | `ParsedAgUiSseRun` | Parsed AG-UI SSE response summary for evals, canaries, and host tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/sse-parser.ts#L30) | | `ParsedHostedAgUiRequest` | Request payload for parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L59) | -| `ParsedHostedChatRequest` | Request payload for parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L71) | +| `ParsedHostedChatRequest` | Request payload for parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L70) | | `ParsedRuntimeSkillDocument` | Public API contract for parsed runtime skill document. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L420) | -| `ParseHostedChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L110) | +| `ParseHostedChatRequestOptions` | Options accepted by parse hosted chat request. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L109) | | `ParseRuntimeAgentMarkdownDefinitionInput` | Input payload for parse runtime agent markdown definition. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L120) | -| `ParseRuntimeAgentRunInvocationHostedChatRequestOptions` | Options accepted when parsing a signed control-plane runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L124) | +| `ParseRuntimeAgentRunInvocationHostedChatRequestOptions` | Options accepted when parsing a signed control-plane runtime invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L123) | | `PersistConversationUserMessageFailure` | Public API contract for persist conversation user message failure. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/bootstrap.ts#L70) | | `PrepareAgentRuntimeMessagesFromUiMessagesOptions` | Options accepted by prepare agent runtime messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-preparation.ts#L22) | | `PrepareAgentServiceChatRuntimeMessagesOptions` | Options accepted by prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L64) | @@ -1751,17 +1751,17 @@ Input delivered to a hosted agent-service detached execution callback. | `RunFrameworkForkStepInput` | Input payload for run framework fork step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-stream.ts#L279) | | `RunResumeSessionManagerOptions` | Options accepted by run resume session manager. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/resume-session.ts#L78) | | `RunSessionStatus` | Public API contract for run session status. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/resume-session.ts#L4) | -| `RuntimeAgentContextItem` | Public API contract for runtime agent context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L401) | -| `RuntimeAgentControlPlaneStreamRequest` | Request payload for runtime agent control plane stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L430) | +| `RuntimeAgentContextItem` | Public API contract for runtime agent context item. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L400) | +| `RuntimeAgentControlPlaneStreamRequest` | Request payload for runtime agent control plane stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L429) | | `RuntimeAgentMarkdownDefinition` | Definition for runtime agent markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L100) | -| `RuntimeAgentProjectContext` | Context for runtime agent project. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L413) | -| `RuntimeAgentRunContext` | Context for runtime agent run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L421) | -| `RuntimeAgentRunInvocation` | Public API contract for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L425) | -| `RuntimeAgentSourceContext` | Context for runtime agent source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L405) | -| `RuntimeAgentTargetKind` | Public API contract for runtime agent target kind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L409) | +| `RuntimeAgentProjectContext` | Context for runtime agent project. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L412) | +| `RuntimeAgentRunContext` | Context for runtime agent run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L420) | +| `RuntimeAgentRunInvocation` | Public API contract for runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L424) | +| `RuntimeAgentSourceContext` | Context for runtime agent source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L404) | +| `RuntimeAgentTargetKind` | Public API contract for runtime agent target kind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L408) | | `RuntimeAgentThinkingConfig` | Configuration used by runtime agent thinking. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L25) | -| `RuntimeAgentTool` | Public API contract for runtime agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L399) | -| `RuntimeAgentValidatedClaims` | Public API contract for runtime agent validated claims. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L417) | +| `RuntimeAgentTool` | Public API contract for runtime agent tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L398) | +| `RuntimeAgentValidatedClaims` | Public API contract for runtime agent validated claims. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L416) | | `RuntimeBuiltinSkillEntriesResult` | Result returned from runtime builtin skill entries. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/builtin-skill-files.ts#L61) | | `RuntimeClientCapability` | Public API contract for runtime client capability. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/client-profile.ts#L48) | | `RuntimeClientProfile` | Public API contract for runtime client profile. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/client-profile.ts#L52) | diff --git a/docs/api-reference/veryfront/errors.md b/docs/api-reference/veryfront/errors.md index 56fda38a24..f35ec36c1d 100644 --- a/docs/api-reference/veryfront/errors.md +++ b/docs/api-reference/veryfront/errors.md @@ -132,7 +132,6 @@ throw INVALID_WIDGET.create({ detail: "The widget id is malformed." }); | `PRODUCTION_BUILD_REQUIRED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L27) | | `PROJECT_EXECUTION_UNAVAILABLE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L52) | | `PROJECT_SOURCE_EMPTY` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L109) | -| `PROVIDER_METADATA_SPLIT_UNSUPPORTED` | Generic provider metadata could not be preserved through a provider request conversion. The metadata may be provider-specific state unrelated to replay checkpoints, so keep the failure distinct from checkpoint validation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L102) | | `PROVIDER_REPLAY_CHECKPOINT_INVALID` | Provider replay checkpoint state failed validation at a consumer boundary. Replay state guards provider protocol correctness on resume, so malformed or unappliable state fails closed instead of degrading into an unsigned replay. Details never include checkpoint contents: provider blocks carry signed reasoning material that must stay out of logs and error text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/agent.ts#L88) | | `PUSH_CONFLICT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L84) | | `PUSH_RECEIPT_MISSING` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L76) | diff --git a/docs/guides/errors.md b/docs/guides/errors.md index 32c0c3577b..5d9a0593ca 100644 --- a/docs/guides/errors.md +++ b/docs/guides/errors.md @@ -826,13 +826,6 @@ Provider replay checkpoint is invalid. - **HTTP status:** 500 - **What to do:** Verify the trusted source that resolved the run's provider replay checkpoints; do not retry with the same replay state -### provider-metadata-split-unsupported - -Provider metadata cannot be attached after assistant turn splitting. - -- **HTTP status:** 500 -- **What to do:** Avoid converting one provider response into multiple assistant request messages before provider metadata is consumed - ## General Raised anywhere; these are not specific to one subsystem. diff --git a/src/agent/hosted/chat-preparation.test.ts b/src/agent/hosted/chat-preparation.test.ts index 81525fb215..0aced06c35 100644 --- a/src/agent/hosted/chat-preparation.test.ts +++ b/src/agent/hosted/chat-preparation.test.ts @@ -3,6 +3,8 @@ import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { it } from "#veryfront/testing/bdd.ts"; import { observeFetchRequestInit } from "#veryfront/testing/mock-fetch.ts"; import type { ChatUiMessage } from "#veryfront/chat/types.ts"; +import { convertToTextGenerationRuntimeRequestMessages } from "#veryfront/agent/runtime/text-generation-runtime-message-converter.ts"; +import type { Message } from "#veryfront/agent/types.ts"; import type { HistoricalToolInputCompactionDiagnostic } from "#veryfront/chat/message-prep.ts"; import type { ParsedHostedChatRequest } from "./chat-request-parser.ts"; import { ContextCompactionError } from "./context-budget-manager.ts"; @@ -1745,6 +1747,69 @@ Deno.test("prepareHostedChatRuntimeMessages omits provider-owned remote tool his }]); }); +Deno.test( + "prepareHostedChatRuntimeMessages keeps persisted provider-executed tool turns in the model request", + async () => { + const prepared = await prepareHostedChatRuntimeMessages([ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Search my notes." }], + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ + type: "dynamic-tool", + toolName: "notion__search_notion", + toolCallId: "srvtool-notion-search", + input: { query: "research notes" }, + state: "output-available", + providerExecuted: true, + output: { data: [] }, + }], + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Create a template I can use." }], + }, + ]); + + const requestMessages = convertToTextGenerationRuntimeRequestMessages( + prepared as unknown as Message[], + ); + + assertEquals( + requestMessages.map((message) => message.role), + ["user", "assistant", "tool", "user"], + "persisted provider-executed turns must stay in the model request", + ); + const assistantContent = requestMessages[1]?.content; + if (!Array.isArray(assistantContent)) throw new Error("Expected assistant content parts"); + assertEquals( + assistantContent.some((part) => + typeof part === "object" && part !== null && "type" in part && + part.type === "tool-call" && "toolCallId" in part && + part.toolCallId === "srvtool-notion-search" + ), + true, + "the persisted tool call must reach the prompt", + ); + const toolContent = requestMessages[2]?.content; + if (!Array.isArray(toolContent)) throw new Error("Expected tool content parts"); + assertEquals( + toolContent.some((part) => + typeof part === "object" && part !== null && "type" in part && + part.type === "tool-result" && "toolCallId" in part && + part.toolCallId === "srvtool-notion-search" + ), + true, + "the persisted tool result must reach the prompt", + ); + }, +); + Deno.test( "prepareHostedChatRuntimeMessages preserves checkpoint-anchored provider tool history", async () => { @@ -1791,7 +1856,6 @@ Deno.test( toolCallId: "srvtool-web-search", toolName: "web_search", args: { query: "site:veryfront.com provider replay" }, - providerExecuted: true, }, { type: "tool-result", @@ -1801,7 +1865,6 @@ Deno.test( type: "json", value: [], }, - providerExecuted: true, }, ], "checkpointed provider call and result remain available for replay validation", From 3edc793cad4d7e694e395177ab8c8e22f8188563 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 29 Aug 2026 23:53:38 +0200 Subject: [PATCH 49/56] fix(agent): preserve replay across hosted preparation --- docs/api-reference/veryfront/agent.md | 12 +-- docs/api-reference/veryfront/chat.md | 42 +++++----- src/agent/hosted/chat-request-parser.ts | 8 +- src/agent/hosted/chat-request.test.ts | 28 +++++++ .../anthropic-provider-replay-block.ts | 33 ++++++++ src/agent/runtime/provider-replay.test.ts | 84 +++++++++++++++++-- src/agent/runtime/provider-replay.ts | 39 ++++++--- ...xt-generation-runtime-message-converter.ts | 29 ++----- src/chat/message-prep.test.ts | 20 +++++ src/chat/message-prep.ts | 10 ++- src/chat/provider-message-tool-pair-repair.ts | 28 ++++++- src/chat/provider-tool-history.test.ts | 44 ++++++++++ src/chat/provider-tool-history.ts | 19 ++--- 13 files changed, 314 insertions(+), 82 deletions(-) create mode 100644 src/chat/provider-tool-history.test.ts diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index fdab8d0002..53fa294efd 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -575,9 +575,9 @@ Input delivered to a hosted agent-service detached execution callback. | `buildInvokeAgentFollowupInstruction` | Builds invoke agent followup instruction. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L100) | | `buildInvokeAgentTraceAttributes` | Builds invoke agent trace attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L235) | | `buildParsedAgentServiceAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L581) | +| `buildParsedAgentServiceChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L587) | | `buildParsedHostedAgUiRequest` | Request payload for build parsed hosted AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L173) | -| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L581) | +| `buildParsedHostedChatRequest` | Builds a public hosted chat request without trusting client-supplied runtime targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L587) | | `buildProjectContextPromptBlock` | Builds the shared project-context prompt block (project reference + branch). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L103) | | `buildProjectInstructionsPromptBlock` | Builds the project-instructions prompt block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L120) | | `buildProjectServiceTraceAttributes` | Builds Datadog unified service trace attributes for a hosted project run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L93) | @@ -867,7 +867,7 @@ Input delivered to a hosted agent-service detached execution callback. | `normalizeParsedAgentServiceChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeParsedHostedChatRequest` | Request payload for normalize parsed hosted chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L315) | | `normalizeRuntimeSkillReferencePath` | Normalizes and bounds a portable runtime skill reference path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L1011) | -| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L588) | +| `parseAgentServiceChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L594) | | `parseAgentServiceConfig` | Configuration used by parse agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L159) | | `parseAgUiContextBoolean` | Parses AG-UI context boolean. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L56) | | `parseAgUiContextJsonValue` | Parses AG-UI context JSON value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L27) | @@ -882,11 +882,11 @@ Input delivered to a hosted agent-service detached execution callback. | `parseAppendConversationRunEventsErrorBody` | Parses append conversation run events error body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable-append-errors.ts#L54) | | `parseDataStreamSseEvents` | Parses data stream sse events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/data-stream.ts#L14) | | `parseHostedAgentServiceConfig` | Configuration used by parse hosted agent service. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/config.ts#L167) | -| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L588) | +| `parseHostedChatRequestFromRequest` | Request payload for parse hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L594) | | `parseRuntimeAgentMarkdownDefinition` | Definition for parse runtime agent markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-definition.ts#L192) | | `parseRuntimeAgentRunInvocation` | Parses runtime agent run invocation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L480) | -| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L627) | -| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L627) | +| `parseRuntimeAgentRunInvocationAgentServiceChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L633) | +| `parseRuntimeAgentRunInvocationHostedChatRequestFromRequest` | Request payload for parse runtime agent run invocation hosted chat request from. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-request-parser.ts#L633) | | `parseRuntimeAgentRunInvocationOrError` | Error shape for parse runtime agent run invocation or. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-invocation-contract.ts#L489) | | `parseRuntimeSkillDocument` | Parses a bounded runtime skill document and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L561) | | `parseRuntimeSkillMetadata` | Parses bounded runtime skill metadata and fails closed on invalid input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/skill-metadata.ts#L569) | diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index ac91c624b7..2a374e11ad 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -587,27 +587,27 @@ import { #### Functions -| Name | Description | Source | -| -------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1709) | -| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1579) | -| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1487) | -| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L178) | -| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1736) | -| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1803) | -| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L253) | -| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1678) | -| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L131) | -| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1672) | -| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L62) | -| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L686) | -| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1377) | -| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L692) | -| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1064) | -| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/provider-message-tool-pair-repair.ts#L23) | -| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L719) | -| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L993) | -| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L821) | +| Name | Description | Source | +| -------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `compactForStep` | Compact for step. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1715) | +| `compactHistoricalUiMessageToolInputs` | Compact large historical UI-message tool inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1585) | +| `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1493) | +| `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L181) | +| `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1742) | +| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1809) | +| `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L256) | +| `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1684) | +| `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L134) | +| `estimateOverhead` | Estimate overhead. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1678) | +| `estimateTokens` | Estimate tokens. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L65) | +| `isModelSupportedFileMediaType` | Check whether the model supports the file media type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L689) | +| `maskOldToolOutputs` | Mask old tool outputs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1383) | +| `normalizeMessageFilePartMediaTypes` | Normalizes message file part media types. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L695) | +| `prepareProviderModelMessagesFromUiMessages` | Prepare provider model messages from UI messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1067) | +| `repairToolPairs` | Repair tool pairs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/provider-message-tool-pair-repair.ts#L174) | +| `rewriteUnsupportedFilePartsAsAnnotations` | Rewrite unsupported file parts as annotations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L722) | +| `sanitizeProviderModelMessages` | Sanitize provider model messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L996) | +| `stripPendingToolParts` | Strip pending tool parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L824) | #### Types diff --git a/src/agent/hosted/chat-request-parser.ts b/src/agent/hosted/chat-request-parser.ts index 9165240975..62a3231066 100644 --- a/src/agent/hosted/chat-request-parser.ts +++ b/src/agent/hosted/chat-request-parser.ts @@ -188,9 +188,15 @@ async function withVerifiedRunEventAppendToken( ): Promise { const token = request.headers.get(RUN_EVENT_APPEND_TOKEN_HEADER)?.trim(); if (!token) { + const hasLegacyReplayState = isRecord(parsedRequest.forwardedProps) && + Object.hasOwn( + parsedRequest.forwardedProps, + "serverResolvedProviderReplayCheckpoints", + ); if ( trustServerEnvelope && - Object.hasOwn(parsedRequest, "serverResolvedProviderReplayCheckpoints") + (Object.hasOwn(parsedRequest, "serverResolvedProviderReplayCheckpoints") || + hasLegacyReplayState) ) { return Response.json( { errorCode: "INVALID_RUN_EVENT_APPEND_TOKEN" }, diff --git a/src/agent/hosted/chat-request.test.ts b/src/agent/hosted/chat-request.test.ts index ac25795003..a2a63e7a09 100644 --- a/src/agent/hosted/chat-request.test.ts +++ b/src/agent/hosted/chat-request.test.ts @@ -2037,6 +2037,34 @@ describe("agent/hosted-chat-request", () => { }); }); + it("rejects legacy forwarded replay state without a run-event append token", async () => { + const response = await parseRuntimeAgentRunInvocationHostedChatRequestFromRequest( + new Request("https://agent.example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + body: JSON.stringify({ + ...createRuntimeInvocation(), + forwardedProps: { + serverResolvedProviderReplayCheckpoints: [serverResolvedProviderReplayCheckpoint], + }, + }), + }), + { + authenticate: () => Promise.resolve({ userId, authToken: "user-api-token" }), + verifyProjectAccess: () => Promise.resolve({ success: true }), + verifyRunEventAppendToken: () => Promise.resolve(true), + runtimeSource, + }, + ); + + if (!(response instanceof Response)) { + throw new Error("Expected missing run-event token response"); + } + assertEquals(response.status, 403); + assertEquals(await response.json(), { + errorCode: "INVALID_RUN_EVENT_APPEND_TOKEN", + }); + }); + it("accepts private provider replay requests above the generic body limit", async () => { const parsed = await parseRuntimeAgentRunInvocationHostedChatRequestFromRequest( new Request("https://agent.example.com/api/control-plane/runs/run_1/stream", { diff --git a/src/agent/runtime/anthropic-provider-replay-block.ts b/src/agent/runtime/anthropic-provider-replay-block.ts index b7b7a93622..86f5a811dc 100644 --- a/src/agent/runtime/anthropic-provider-replay-block.ts +++ b/src/agent/runtime/anthropic-provider-replay-block.ts @@ -14,3 +14,36 @@ const ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES = new Set([ export function isAnthropicProviderToolResultBlock(block: Record): boolean { return typeof block.type === "string" && ANTHROPIC_PROVIDER_TOOL_RESULT_TYPES.has(block.type); } + +/** Group raw Anthropic responses around the assistant anchors they project. */ +export function groupAnthropicRawAssistantMessagesByAnchor( + rawAssistantMessages: readonly unknown[], + anchorCount: number, +): Record[][][] | undefined { + const grouped: Record[][][] = []; + let pendingResults: Record[][] = []; + for (const rawAssistantMessage of rawAssistantMessages) { + if ( + !Array.isArray(rawAssistantMessage) || + !rawAssistantMessage.every((block) => + block !== null && typeof block === "object" && !Array.isArray(block) + ) + ) { + return undefined; + } + const blocks = rawAssistantMessage as Record[]; + if (blocks.length > 0 && blocks.every(isAnthropicProviderToolResultBlock)) { + pendingResults.push(blocks); + continue; + } + if (grouped.length >= anchorCount) return undefined; + grouped.push([...pendingResults, blocks]); + pendingResults = []; + } + if (pendingResults.length > 0) { + const finalGroup = grouped.at(-1); + if (!finalGroup) return undefined; + finalGroup.push(...pendingResults); + } + return grouped.length === anchorCount ? grouped : undefined; +} diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index c0ef3c3838..23581e4c7a 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -11,6 +11,9 @@ import { } from "./provider-replay.ts"; import { attachProviderMetadata, readAttachedProviderMetadata } from "./provider-metadata.ts"; import { convertToTextGenerationRuntimeMessages } from "./text-generation-runtime-message-converter.ts"; +import { convertProviderMessagesToAgentRuntimeMessages } from "./message-adapter.ts"; +import { buildToolResultOutput } from "#veryfront/chat/message-part-parsing.ts"; +import { withProviderModelMessageSourceId } from "#veryfront/chat/conversation.ts"; const SIGNATURE = "sig-secret-9f8e7d6c5b4a"; const REDACTED_DATA = "redacted-secret-0a1b2c3d"; @@ -1462,6 +1465,77 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should preserve prepared provider errors through runtime conversion", () => { + const providerCall = { + type: "server_tool_use", + id: "srvtool-prepared-error", + name: "web_search", + input: { query: "provider replay" }, + caller: { type: "direct" }, + }; + const providerResult = { + type: "web_search_tool_result", + tool_use_id: providerCall.id, + caller: { type: "direct" }, + content: { + type: "web_search_tool_result_error", + error_code: "max_uses_exceeded", + }, + }; + const canonicalError = { + name: "AnthropicServerToolResultError", + provider: "anthropic", + code: "max_uses_exceeded", + toolCallId: providerCall.id, + toolName: providerCall.name, + }; + const sourceId = "assistant-prepared-error"; + const preparedError = buildToolResultOutput({ + state: "output-error", + output: canonicalError, + }); + const runtimeMessages = convertProviderMessagesToAgentRuntimeMessages([ + withProviderModelMessageSourceId({ + role: "assistant", + content: [{ + type: "tool-call", + toolCallId: providerCall.id, + toolName: providerCall.name, + input: providerCall.input, + providerExecuted: true, + }], + }, sourceId), + withProviderModelMessageSourceId({ + role: "tool", + content: [{ + type: "tool-result", + toolCallId: providerCall.id, + toolName: providerCall.name, + output: preparedError!, + providerExecuted: true, + }], + }, sourceId), + ]); + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: sourceId, + provider: "anthropic", + providerBlocks: [providerCall, providerResult].map((block) => ({ + type: "provider-block" as const, + provider: "anthropic" as const, + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + applyProviderReplayCheckpointsToMessages(runtimeMessages, [checkpoint]); + + assertEquals(readAttachedProviderMetadata(runtimeMessages[0]!), { + anthropic: { rawAssistantMessages: [[providerCall, providerResult]] }, + }); + }); + it("should reject outer provider tool-result error block types", () => { const variants: ReadonlyArray<{ readonly toolName: string; @@ -2381,7 +2455,7 @@ describe("agent/runtime/provider-replay", () => { block, })), providerBlockPositions: [0, 1, 2], - providerMessageBlockCounts: [2, 1], + providerMessageBlockCounts: [1, 1, 1], totalPartCount: 3, }; @@ -2392,13 +2466,13 @@ describe("agent/runtime/provider-replay", () => { assertEquals( readAttachedProviderMetadata(leadingAssistant), - { anthropic: { rawAssistantMessages: [[providerCall, providerResult]] } }, - "the first same-source assistant keeps its declared raw provider-result sibling", + { anthropic: { rawAssistantMessages: [[providerCall]] } }, + "the first same-source assistant keeps its declared provider call", ); assertEquals( readAttachedProviderMetadata(trailingAssistant), - { anthropic: { rawAssistantMessages: [[trailingText]] } }, - "the second same-source assistant keeps the declared trailing raw text group", + { anthropic: { rawAssistantMessages: [[providerResult], [trailingText]] } }, + "the result-only response is coalesced with the following assistant anchor", ); assertEquals(readAttachedProviderMetadata(toolSibling), undefined); }); diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index e4f121647d..b7b9c3de2c 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -5,6 +5,7 @@ import { } from "#veryfront/agent/runtime/provider-metadata.ts"; import { stringifyChatJson } from "#veryfront/chat/json-value.ts"; import { snapshotProviderJsonValue } from "#veryfront/provider/runtime-loader.ts"; +import { safeJsonParse } from "#veryfront/utils/json.ts"; import type { Message } from "../types.ts"; import { convertAgentRuntimeMessagesToProviderMessages } from "./message-adapter.ts"; import { @@ -13,7 +14,10 @@ import { MAX_PROVIDER_REPLAY_RAW_METADATA_NODES, MAX_PROVIDER_REPLAY_RAW_METADATA_STRING_CHARS, } from "./provider-replay-limits.ts"; -import { isAnthropicProviderToolResultBlock } from "./anthropic-provider-replay-block.ts"; +import { + groupAnthropicRawAssistantMessagesByAnchor, + isAnthropicProviderToolResultBlock, +} from "./anthropic-provider-replay-block.ts"; const MAX_PROVIDER_REPLAY_BLOCKS = 100; const MAX_PROVIDER_REPLAY_CHECKPOINTS = 100; @@ -936,12 +940,13 @@ function toTranscriptVisibleProviderPart( } const hasResult = "result" in part || "output" in part; const rawResult = "result" in part ? part.result : part.output; + const isPreparedError = isRecord(rawResult) && rawResult.type === "error-text"; return { type: "tool-result", toolCallId: part.toolCallId, ...(providerExecutedToolCallIds.has(part.toolCallId) ? { providerExecuted: true } : {}), ...(hasResult ? { result: unwrapPreparedProviderResult(rawResult) } : {}), - ...(part.isError === true ? { isError: true } : {}), + ...(part.isError === true || isPreparedError ? { isError: true } : {}), }; } default: @@ -950,9 +955,11 @@ function toTranscriptVisibleProviderPart( } function unwrapPreparedProviderResult(value: unknown): unknown { - return isRecord(value) && value.type === "json" && Object.hasOwn(value, "value") - ? value.value - : value; + if (!isRecord(value) || !Object.hasOwn(value, "value")) return value; + if (value.type === "json") return value.value; + if (value.type !== "error-text" || typeof value.value !== "string") return value; + const parsed = safeJsonParse(value.value); + return parsed.ok ? parsed.value : value.value; } // A persisted assistant turn carries at most one leading text part, so provider @@ -1147,9 +1154,17 @@ function getRawAssistantMessagesForCheckpoint( function splitAnthropicAssistantReplayBlocks( checkpoint: ProviderReplayCheckpoint, -): Record[][] { + anchorCount: number, +): Record[][][] { if (checkpoint.providerMessageBlockCounts !== undefined) { - return getRawAssistantMessagesForCheckpoint(checkpoint); + const grouped = groupAnthropicRawAssistantMessagesByAnchor( + getRawAssistantMessagesForCheckpoint(checkpoint), + anchorCount, + ); + if (grouped === undefined) { + invalidCheckpoint("checkpoint split assistant segment count does not match its anchor"); + } + return grouped; } const segments: Record[][] = []; let current: Record[] = []; @@ -1167,14 +1182,14 @@ function splitAnthropicAssistantReplayBlocks( if (current.length > 0) { segments.push(current); } - return segments; + return segments.map((segment) => [segment]); } function assertCheckpointMatchesSplitAssistantTurns( sameSourceMessages: readonly Message[], assistantMatches: readonly Message[], checkpoint: ProviderReplayCheckpoint, -): Record[][] { +): Record[][][] { const providerExecutedToolCallIds = getProviderExecutedToolCallIdsFromMessages( sameSourceMessages, ); @@ -1190,13 +1205,13 @@ function assertCheckpointMatchesSplitAssistantTurns( projectProviderToolResults(sameSourceMessages, providerExecutedToolCallIds), ); - const rawSegments = splitAnthropicAssistantReplayBlocks(checkpoint); + const rawSegments = splitAnthropicAssistantReplayBlocks(checkpoint, assistantMatches.length); if (rawSegments.length !== assistantMatches.length) { invalidCheckpoint("checkpoint split assistant segment count does not match its anchor"); } for (const [index, rawSegment] of rawSegments.entries()) { const rawSegmentProjection = projectCheckpointVisibleParts( - createCheckpointForRawBlocks(checkpoint, rawSegment), + createCheckpointForRawBlocks(checkpoint, rawSegment.flat()), ); const rawSegmentAssistantProjection = normalizeTranscriptVisibleProjection( rawSegmentProjection.filter((part) => part.type !== "tool-result"), @@ -1533,7 +1548,7 @@ export function applyProviderReplayCheckpointsToMessages( checkpoint, ); for (const [index, rawBlocks] of rawSegments.entries()) { - attachmentPlan.push({ target: assistantMatches[index]!, rawAssistantMessages: [rawBlocks] }); + attachmentPlan.push({ target: assistantMatches[index]!, rawAssistantMessages: rawBlocks }); } } for (const { target, rawAssistantMessages } of attachmentPlan) { diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 1f6d42dbc5..37c1abad17 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -21,7 +21,7 @@ import { buildDataFileAnnotation } from "#veryfront/chat/types.ts"; import { PROVIDER_METADATA_SPLIT_UNSUPPORTED } from "#veryfront/errors"; import { getTextFromParts, getToolArguments, type Message, type ToolCallPart } from "../types.ts"; import { readAttachedProviderMetadata } from "./provider-metadata.ts"; -import { isAnthropicProviderToolResultBlock } from "./anthropic-provider-replay-block.ts"; +import { groupAnthropicRawAssistantMessagesByAnchor } from "./anthropic-provider-replay-block.ts"; function getStringPartField(part: unknown, key: string): string | undefined { if (!part || typeof part !== "object" || Array.isArray(part)) return undefined; @@ -384,28 +384,11 @@ function createSplitAnthropicRawAssistantMetadata( return undefined; } - const segmentRawAssistantMessages: unknown[][][] = []; - let pendingResultOnlyMessages: unknown[][] = []; - for (const rawAssistantMessage of rawAssistantMessages) { - if (!Array.isArray(rawAssistantMessage)) return undefined; - const isResultOnly = rawAssistantMessage.length > 0 && - rawAssistantMessage.every((block) => - isRecord(block) && isAnthropicProviderToolResultBlock(block) - ); - if (isResultOnly) { - pendingResultOnlyMessages.push(rawAssistantMessage); - continue; - } - if (segmentRawAssistantMessages.length >= segmentCount) return undefined; - segmentRawAssistantMessages.push([...pendingResultOnlyMessages, rawAssistantMessage]); - pendingResultOnlyMessages = []; - } - const finalSegment = segmentRawAssistantMessages.at(-1); - if (pendingResultOnlyMessages.length > 0) { - if (!finalSegment) return undefined; - finalSegment.push(...pendingResultOnlyMessages); - } - if (segmentRawAssistantMessages.length !== segmentCount) return undefined; + const segmentRawAssistantMessages = groupAnthropicRawAssistantMessagesByAnchor( + rawAssistantMessages, + segmentCount, + ); + if (segmentRawAssistantMessages === undefined) return undefined; return segmentRawAssistantMessages.map((segmentMessages) => ({ ...providerMetadata, diff --git a/src/chat/message-prep.test.ts b/src/chat/message-prep.test.ts index f83ef8a9f9..7d26ed15d7 100644 --- a/src/chat/message-prep.test.ts +++ b/src/chat/message-prep.test.ts @@ -9,6 +9,7 @@ import { } from "#std/assert"; import type { ChatToolResultPart, ChatUiMessage, ProviderModelMessage } from "./types.ts"; import { withProviderModelMessageSourceId } from "./conversation.ts"; +import { repairToolPairsForPreparation } from "./provider-message-tool-pair-repair.ts"; import type { HistoricalToolInputCompactionDiagnostic } from "./message-prep.ts"; import { compactForStep, @@ -289,6 +290,25 @@ Deno.test("repairToolPairs appends an inline placeholder for an unanswered provi ]); }); +Deno.test("repairToolPairs leaves checkpoint-preserved provider calls unresolved", () => { + const sourceId = "assistant-checkpoint"; + const message = withProviderModelMessageSourceId({ + role: "assistant", + content: [{ + type: "tool-call", + toolCallId: "srv-deferred", + toolName: "mcp_deferred", + input: { query: "later" }, + providerExecuted: true, + }], + }, sourceId); + + assertEquals( + repairToolPairsForPreparation([message], [sourceId]), + [message], + ); +}); + Deno.test("repairToolPairs leaves a provider-executed call whose result is already inline", () => { const messages = [ { diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index 66da92e5b2..30f0df318f 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -12,7 +12,10 @@ import { type ProviderMessageSanitizationOptions, shouldPreserveEmptyAssistantMessage, } from "./provider-message-anchor-preservation.ts"; -import { repairToolPairs } from "./provider-message-tool-pair-repair.ts"; +import { + repairToolPairs, + repairToolPairsForPreparation, +} from "./provider-message-tool-pair-repair.ts"; import { findProviderVisibleToolReplayMatches } from "./tool-replay-reconciliation.ts"; import { buildDataFileAnnotation, @@ -1097,7 +1100,10 @@ export function prepareProviderModelMessagesFromUiMessages( const compactedInputs = compactOldToolInputs(masked, historicalToolInputRetention); const compacted = enforceTokenBudget(compactedInputs); const filtered = filterValidMessages(compacted, preserveOptions); - return repairToolPairs(filtered); + return repairToolPairsForPreparation( + filtered, + options.preserveProviderOwnedToolSourceMessageIds, + ); } function buildToolCallMap(messages: ProviderModelMessage[]): Map { diff --git a/src/chat/provider-message-tool-pair-repair.ts b/src/chat/provider-message-tool-pair-repair.ts index 59a680a80d..7914c96b44 100644 --- a/src/chat/provider-message-tool-pair-repair.ts +++ b/src/chat/provider-message-tool-pair-repair.ts @@ -1,5 +1,6 @@ import { copyProviderModelMessageSourceId, + getProviderModelMessageSourceId, isToolCallPart, isToolResultPart, } from "./conversation.ts"; @@ -20,9 +21,15 @@ function createSyntheticToolResult(toolCallId: string, toolName: string): ChatTo } /** Repair tool pairs. */ -export function repairToolPairs(messages: ProviderModelMessage[]): ProviderModelMessage[] { +function repairToolPairsWithOptions( + messages: ProviderModelMessage[], + options: { preserveUnresolvedProviderCallSourceMessageIds?: readonly string[] } = {}, +): ProviderModelMessage[] { const result = [...messages]; let mutated = false; + const preservedUnresolvedCallSourceIds = new Set( + options.preserveUnresolvedProviderCallSourceMessageIds ?? [], + ); for (let index = 0; index < result.length; index++) { const message = result[index]; @@ -54,9 +61,11 @@ export function repairToolPairs(messages: ProviderModelMessage[]): ProviderModel const toolName = part.toolName ?? "unknown"; if (part.providerExecuted) { + const sourceId = getProviderModelMessageSourceId(message); if ( !inlineResultIds.has(part.toolCallId) && - !hasImmediateToolResult(nextMessage, part.toolCallId) + !hasImmediateToolResult(nextMessage, part.toolCallId) && + (sourceId === undefined || !preservedUnresolvedCallSourceIds.has(sourceId)) ) { repairedContent.push(createSyntheticToolResult(part.toolCallId, toolName)); mutated = true; @@ -160,3 +169,18 @@ export function repairToolPairs(messages: ProviderModelMessage[]): ProviderModel return mutated ? result : messages; } + +/** Repair tool pairs. */ +export function repairToolPairs(messages: ProviderModelMessage[]): ProviderModelMessage[] { + return repairToolPairsWithOptions(messages); +} + +/** Repair tool pairs while retaining unresolved calls backed by replay checkpoints. */ +export function repairToolPairsForPreparation( + messages: ProviderModelMessage[], + preserveUnresolvedProviderCallSourceMessageIds: readonly string[] | undefined, +): ProviderModelMessage[] { + return repairToolPairsWithOptions(messages, { + preserveUnresolvedProviderCallSourceMessageIds, + }); +} diff --git a/src/chat/provider-tool-history.test.ts b/src/chat/provider-tool-history.test.ts new file mode 100644 index 0000000000..3fb1b88fab --- /dev/null +++ b/src/chat/provider-tool-history.test.ts @@ -0,0 +1,44 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import type { ChatUiMessage } from "./types.ts"; +import { stripProviderOwnedToolParts } from "./provider-tool-history.ts"; + +Deno.test("provider-owned tool preservation stays inside one transcript segment", () => { + const messages = [{ + id: "older-assistant", + role: "assistant", + parts: [{ + type: "tool-web_search", + toolCallId: "reused-provider-id", + toolName: "web_search", + providerExecuted: true, + }], + }, { + id: "older-tool", + role: "tool", + parts: [{ + type: "tool-result", + toolCallId: "reused-provider-id", + toolName: "web_search", + providerExecuted: true, + }], + }, { + id: "new-user", + role: "user", + parts: [{ type: "text", text: "Start another turn" }], + }, { + id: "checkpoint-assistant", + role: "assistant", + parts: [{ + type: "tool-web_search", + toolCallId: "reused-provider-id", + toolName: "web_search", + providerExecuted: true, + }], + }] as ChatUiMessage[]; + + assertEquals( + stripProviderOwnedToolParts(messages, ["web_search"], ["checkpoint-assistant"]), + [{ ...messages[0]!, parts: [] }, { ...messages[1]!, parts: [] }, messages[2]!, messages[3]!], + ); +}); diff --git a/src/chat/provider-tool-history.ts b/src/chat/provider-tool-history.ts index 2d688d01c7..872f7800e7 100644 --- a/src/chat/provider-tool-history.ts +++ b/src/chat/provider-tool-history.ts @@ -38,21 +38,12 @@ export function stripProviderOwnedToolParts( const providerOwnedNames = new Set(providerOwnedToolNames); const preservedMessageIds = new Set(preserveSourceMessageIds ?? []); const preservedToolCallIds = new Set(); - for (const message of messages) { - if (!preservedMessageIds.has(message.id)) continue; - for (const part of message.parts) { - const toolName = getMessagePartToolName(part); - const toolCallId = getMessagePartToolCallId(part); - if (toolCallId && toolName && providerOwnedNames.has(toolName)) { - preservedToolCallIds.add(toolCallId); - } - } - } const providerOwnedToolCallIds = new Set(); return messages.map((message) => { if (message.role === "user" || message.role === "system") { providerOwnedToolCallIds.clear(); + preservedToolCallIds.clear(); return message; } @@ -63,6 +54,14 @@ export function stripProviderOwnedToolParts( const ownedByName = toolName ? providerOwnedNames.has(toolName) : false; const ownedByCallId = toolCallId ? providerOwnedToolCallIds.has(toolCallId) : false; + if ( + preservedMessageIds.has(message.id) && + toolCallId && + (ownedByName || ownedByCallId) + ) { + preservedToolCallIds.add(toolCallId); + return true; + } if (toolCallId && preservedToolCallIds.has(toolCallId)) { return true; } From 1ae3786d52b86df78291cf7100ebc691b8c631b3 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sun, 30 Aug 2026 00:04:39 +0200 Subject: [PATCH 50/56] fix(lint): re-pin message-prep size ceiling after gated repair call site The preparation-scoped repairToolPairsForPreparation call added six lines but the chat antipattern ratchet still pinned the file at its previous size, failing ci (lint) with zero test failures. --- scripts/lint/ban-chat-antipatterns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index dbb656d65e..3f40a593cc 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -112,7 +112,7 @@ const FILE_SIZE_CEILINGS: Record = { // reconciliation). Not React components, so this map does not subject them // to the antipattern ratchets above — it only pins their size. "src/chat/conversation.ts": 557, - "src/chat/message-prep.ts": 1813, + "src/chat/message-prep.ts": 1819, "src/chat/tool-replay-reconciliation.ts": 291, "src/chat/message-part-parsing.ts": 264, "src/chat/part-field-access.ts": 65, From 02aac1787005e4592406763bdb3041b325b9aff8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 30 Aug 2026 00:12:10 +0200 Subject: [PATCH 51/56] fix(agent): retain trailing replay anchors --- ...neration-runtime-message-converter.test.ts | 29 +++++++++++++++++++ ...xt-generation-runtime-message-converter.ts | 5 +++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index a305652176..278536e589 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -1038,6 +1038,35 @@ describe("text-generation-runtime-message-converter", () => { "every trailing assistant message must be stripped so the request never ends on an unanswered tool call", ); }); + + it("keeps a trailing assistant that carries exact provider replay metadata", () => { + const providerMetadata = { + anthropic: { + rawAssistantMessages: [[{ + type: "thinking", + thinking: "", + signature: "sig-trailing-replay", + }]], + }, + }; + const trailing = attachProviderMetadata({ + id: "a-replay", + role: "assistant", + parts: [{ type: "reasoning", signature: "sig-trailing-replay" }], + } as Message, providerMetadata); + + assertEquals( + convertToTextGenerationRuntimeRequestMessages([ + { id: "u1", role: "user", parts: [{ type: "text", text: "continue" }] }, + trailing, + ]), + [{ role: "user", content: "continue" }, { + role: "assistant", + content: [{ type: "text", text: "" }], + providerMetadata, + }], + ); + }); }); describe("attachment reachability across the conversion entry points", () => { diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index a0592604a6..8c083302b9 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -575,7 +575,10 @@ export function convertToTextGenerationRuntimeRequestMessages( ): TextGenerationRuntimeMessage[] { const requestMessages = convertToTextGenerationRuntimeMessages(messages, options); - while (requestMessages.at(-1)?.role === "assistant") { + while ( + requestMessages.at(-1)?.role === "assistant" && + !(requestMessages.at(-1) as TextGenerationRuntimeAssistantMessage).providerMetadata + ) { requestMessages.pop(); } From b3cdb37feefb7812875ebfb142e046e3a24bf73b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 30 Aug 2026 00:22:59 +0200 Subject: [PATCH 52/56] fix(chat): scope tool deduplication by turn --- docs/api-reference/veryfront/chat.md | 2 +- src/chat/message-prep.test.ts | 28 ++++++++++++++++++++++++++++ src/chat/message-prep.ts | 4 ++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index 0c4195e8de..cfeeab286d 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -594,7 +594,7 @@ import { | `compactOldToolInputs` | Compact large historical tool-call inputs after matching results are available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1493) | | `compressTurn` | Compress turn. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L181) | | `dedupeToolHistory` | Dedupe tool history. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1742) | -| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1809) | +| `enforceTokenBudget` | Enforce token budget. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1813) | | `enforceTokenBudgetWithTurnCompression` | Enforce token budget with turn compression. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L256) | | `ensureToolCallInputs` | Ensure tool call inputs helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L1684) | | `estimateMessageTokenBreakdown` | Estimate token categories for provider, UI, or runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/message-prep.ts#L134) | diff --git a/src/chat/message-prep.test.ts b/src/chat/message-prep.test.ts index a722a841d9..0e71a952cd 100644 --- a/src/chat/message-prep.test.ts +++ b/src/chat/message-prep.test.ts @@ -15,6 +15,7 @@ import { compactForStep, compactHistoricalUiMessageToolInputs, compressTurn, + dedupeToolHistory, enforceTokenBudget, estimateTokens, maskOldToolOutputs, @@ -308,6 +309,33 @@ Deno.test("repairToolPairs leaves checkpoint-preserved provider calls unresolved ); }); +Deno.test("dedupeToolHistory scopes reused checkpoint call ids to transcript segments", () => { + const call = { + type: "tool-call" as const, + toolCallId: "reused-provider-call", + toolName: "web_search", + input: { query: "one" }, + }; + const result = { + type: "tool-result" as const, + toolCallId: call.toolCallId, + toolName: call.toolName, + output: { type: "json" as const, value: [] }, + }; + const messages = [ + withProviderModelMessageSourceId({ role: "assistant", content: [call] }, "checkpoint-1"), + withProviderModelMessageSourceId({ role: "tool", content: [result] }, "checkpoint-1"), + { role: "user" as const, content: "Start another turn" }, + withProviderModelMessageSourceId({ + role: "assistant", + content: [{ ...call, input: { query: "two" } }], + }, "checkpoint-2"), + withProviderModelMessageSourceId({ role: "tool", content: [result] }, "checkpoint-2"), + ]; + + assertEquals(dedupeToolHistory(messages), messages); +}); + Deno.test("repairToolPairs leaves a provider-executed call whose result is already inline", () => { const messages = [ { diff --git a/src/chat/message-prep.ts b/src/chat/message-prep.ts index 30f0df318f..635c65e158 100644 --- a/src/chat/message-prep.ts +++ b/src/chat/message-prep.ts @@ -1770,6 +1770,10 @@ export function dedupeToolHistory(messages: ProviderModelMessage[]): ProviderMod }; for (const message of messages) { + if (message.role === "user" || message.role === "system") { + seenToolCallIds.clear(); + seenToolResultIds.clear(); + } if (message.role === "user" && Array.isArray(message.content)) { const { filtered, changed } = filterParts(message.content); if (!changed) { From fae901a8347968c7349c5dae44b2b831e5471518 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sun, 30 Aug 2026 00:24:00 +0200 Subject: [PATCH 53/56] fix(agent): keep trailing replay anchors only for delivered checkpoints The trailing-assistant trim exemption keyed on any provider metadata, but live in-run assistant messages also carry attached metadata, so an ordinary resume ending on an in-run turn would ship an assistant prefill the provider rejects or misreads. Delivered checkpoints now mark their targets, the converter propagates the mark to the built request message, and only marked messages survive the trim. A regression pins that live-attached metadata is still popped. --- src/agent/runtime/provider-metadata.ts | 18 ++++++++++ src/agent/runtime/provider-replay.ts | 5 +-- ...neration-runtime-message-converter.test.ts | 33 ++++++++++++++++--- ...xt-generation-runtime-message-converter.ts | 22 ++++++++++--- 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/agent/runtime/provider-metadata.ts b/src/agent/runtime/provider-metadata.ts index 33f7260533..195db4a181 100644 --- a/src/agent/runtime/provider-metadata.ts +++ b/src/agent/runtime/provider-metadata.ts @@ -19,3 +19,21 @@ export function readAttachedProviderMetadata( ): Record | undefined { return providerMetadataByMessage.get(message); } + +const providerReplayDeliveredValues = new WeakSet(); + +/** + * Mark a message whose provider metadata came from a delivered replay + * checkpoint. Live in-run metadata is never marked, so provider-boundary + * decisions that must apply only to replayed state can tell the two apart. + */ +export function markProviderReplayDelivered(value: T): T { + providerReplayDeliveredValues.add(value); + return value; +} + +/** True when the value was marked as carrying delivered replay-checkpoint metadata. */ +export function isProviderReplayDelivered(value: unknown): boolean { + return typeof value === "object" && value !== null && + providerReplayDeliveredValues.has(value); +} diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 2270e7d1d6..26760d7db6 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -1,6 +1,7 @@ import { PROVIDER_REPLAY_CHECKPOINT_INVALID } from "#veryfront/errors"; import { attachProviderMetadata, + markProviderReplayDelivered, readAttachedProviderMetadata, } from "#veryfront/agent/runtime/provider-metadata.ts"; import { stringifyChatJson } from "#veryfront/chat/json-value.ts"; @@ -1575,10 +1576,10 @@ export function applyProviderReplayCheckpointsToMessages( // In-process metadata attached during this run is the same replay state at // first hand; the durable checkpoint never overrides it. if (readAttachedProviderMetadata(target) !== undefined) continue; - attachProviderMetadata(target, { + markProviderReplayDelivered(attachProviderMetadata(target, { anthropic: { rawAssistantMessages, }, - }); + })); } } diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index 278536e589..3137270d83 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -13,7 +13,7 @@ import type { TextGenerationRuntimeUserMessage, } from "./text-generation-runtime-message-types.ts"; import type { Message } from "../types.ts"; -import { attachProviderMetadata } from "./provider-metadata.ts"; +import { attachProviderMetadata, markProviderReplayDelivered } from "./provider-metadata.ts"; describe("text-generation-runtime-message-converter", () => { describe("convertToTextGenerationRuntimeMessage", () => { @@ -1039,7 +1039,7 @@ describe("text-generation-runtime-message-converter", () => { ); }); - it("keeps a trailing assistant that carries exact provider replay metadata", () => { + it("keeps a trailing assistant whose metadata came from a delivered replay checkpoint", () => { const providerMetadata = { anthropic: { rawAssistantMessages: [[{ @@ -1049,11 +1049,11 @@ describe("text-generation-runtime-message-converter", () => { }]], }, }; - const trailing = attachProviderMetadata({ + const trailing = markProviderReplayDelivered(attachProviderMetadata({ id: "a-replay", role: "assistant", parts: [{ type: "reasoning", signature: "sig-trailing-replay" }], - } as Message, providerMetadata); + } as Message, providerMetadata)); assertEquals( convertToTextGenerationRuntimeRequestMessages([ @@ -1067,6 +1067,31 @@ describe("text-generation-runtime-message-converter", () => { }], ); }); + + it("still trims a trailing assistant whose metadata was attached during the run", () => { + const trailing = attachProviderMetadata({ + id: "a-live", + role: "assistant", + parts: [{ type: "text", text: "streamed continuation" }], + } as Message, { + anthropic: { + rawAssistantMessages: [[{ + type: "thinking", + thinking: "", + signature: "sig-live-turn", + }]], + }, + }); + + assertEquals( + convertToTextGenerationRuntimeRequestMessages([ + { id: "u1", role: "user", parts: [{ type: "text", text: "continue" }] }, + trailing, + ]), + [{ role: "user", content: "continue" }], + "live in-run metadata must not turn a resume into an assistant prefill", + ); + }); }); describe("attachment reachability across the conversion entry points", () => { diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 8c083302b9..232eb7764c 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -19,7 +19,11 @@ import type { import { assertProviderReachableAttachment } from "./attachment-reachability.ts"; import { buildDataFileAnnotation } from "#veryfront/chat/types.ts"; import { getTextFromParts, getToolArguments, type Message, type ToolCallPart } from "../types.ts"; -import { readAttachedProviderMetadata } from "./provider-metadata.ts"; +import { + isProviderReplayDelivered, + markProviderReplayDelivered, + readAttachedProviderMetadata, +} from "./provider-metadata.ts"; import { collectAnthropicProviderToolCallIds } from "./anthropic-provider-replay-block.ts"; function getStringPartField(part: unknown, key: string): string | undefined { @@ -497,12 +501,19 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( // conversion split that response would pair it with an incomplete projection. if (providerMetadata !== undefined && assistantMessages.length === 1) { assistantMessages[0]!.providerMetadata = providerMetadata; + if (isProviderReplayDelivered(message)) { + markProviderReplayDelivered(assistantMessages[0]!); + } } else if (providerMetadata !== undefined && messages.length === 0) { - messages.push({ + const anchorMessage: TextGenerationRuntimeMessage = { role: "assistant", content: [{ type: "text", text: "" }], providerMetadata, - }); + }; + if (isProviderReplayDelivered(message)) { + markProviderReplayDelivered(anchorMessage); + } + messages.push(anchorMessage); } return messages; @@ -575,9 +586,12 @@ export function convertToTextGenerationRuntimeRequestMessages( ): TextGenerationRuntimeMessage[] { const requestMessages = convertToTextGenerationRuntimeMessages(messages, options); + // Only a delivered replay checkpoint may keep a trailing assistant message: + // live in-run metadata also reaches converted messages, and providers reject + // or misread an unexpected trailing prefill on ordinary resumes. while ( requestMessages.at(-1)?.role === "assistant" && - !(requestMessages.at(-1) as TextGenerationRuntimeAssistantMessage).providerMetadata + !isProviderReplayDelivered(requestMessages.at(-1)) ) { requestMessages.pop(); } From a480d0ed012968520da68cdb64ff835dee8f3c0a Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sun, 30 Aug 2026 00:25:05 +0200 Subject: [PATCH 54/56] fix(lint): re-pin message-prep size ceiling after turn-scoped dedupe --- scripts/lint/ban-chat-antipatterns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lint/ban-chat-antipatterns.ts b/scripts/lint/ban-chat-antipatterns.ts index 3f40a593cc..5ca95e10da 100644 --- a/scripts/lint/ban-chat-antipatterns.ts +++ b/scripts/lint/ban-chat-antipatterns.ts @@ -112,7 +112,7 @@ const FILE_SIZE_CEILINGS: Record = { // reconciliation). Not React components, so this map does not subject them // to the antipattern ratchets above — it only pins their size. "src/chat/conversation.ts": 557, - "src/chat/message-prep.ts": 1819, + "src/chat/message-prep.ts": 1823, "src/chat/tool-replay-reconciliation.ts": 291, "src/chat/message-part-parsing.ts": 264, "src/chat/part-field-access.ts": 65, From 1b59b59c5a3747c66c12aedfb218886a2f5fa736 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 30 Aug 2026 00:37:10 +0200 Subject: [PATCH 55/56] fix(agent): distribute split replay metadata --- .../anthropic-provider-replay-block.ts | 3 +- ...neration-runtime-message-converter.test.ts | 40 +++++++++++++++++++ ...xt-generation-runtime-message-converter.ts | 34 ++++++++++++++-- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/agent/runtime/anthropic-provider-replay-block.ts b/src/agent/runtime/anthropic-provider-replay-block.ts index 99136c99e3..a991f6b13e 100644 --- a/src/agent/runtime/anthropic-provider-replay-block.ts +++ b/src/agent/runtime/anthropic-provider-replay-block.ts @@ -17,9 +17,10 @@ export function isAnthropicProviderToolResultBlock(block: Record[][][] | undefined { + if (!Array.isArray(rawAssistantMessages)) return undefined; const grouped: Record[][][] = []; let pendingResults: Record[][] = []; for (const rawAssistantMessage of rawAssistantMessages) { diff --git a/src/agent/runtime/text-generation-runtime-message-converter.test.ts b/src/agent/runtime/text-generation-runtime-message-converter.test.ts index 278536e589..2478a6ac67 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.test.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.test.ts @@ -507,6 +507,46 @@ describe("text-generation-runtime-message-converter", () => { }]); }); + it("distributes replay groups across assistant segments split by a client tool", () => { + const rawToolUse = { + type: "tool_use", + id: "lookup-1", + name: "lookup", + input: { query: "Veryfront" }, + }; + const rawText = { type: "text", text: "Found it." }; + const message = attachProviderMetadata({ + id: "assistant-split-replay", + role: "assistant", + parts: [{ + type: "tool-lookup", + toolCallId: rawToolUse.id, + toolName: rawToolUse.name, + args: rawToolUse.input, + }, { + type: "tool-result", + toolCallId: rawToolUse.id, + toolName: rawToolUse.name, + result: { matches: 1 }, + }, { + type: "text", + text: rawText.text, + }], + } as Message, { + anthropic: { rawAssistantMessages: [[rawToolUse], [rawText]] }, + }); + + const converted = convertToTextGenerationRuntimeMessages([message]); + assertEquals( + converted.filter((entry) => entry.role === "assistant").map((entry) => + entry.providerMetadata + ), + [{ anthropic: { rawAssistantMessages: [[rawToolUse]] } }, { + anthropic: { rawAssistantMessages: [[rawText]] }, + }], + ); + }); + it("omits provider-executed tool-only assistant messages from replay", () => { const messages = [ { id: "u1", role: "user", parts: [{ type: "text", text: "search tax guidance" }] }, diff --git a/src/agent/runtime/text-generation-runtime-message-converter.ts b/src/agent/runtime/text-generation-runtime-message-converter.ts index 8c083302b9..ffaefa2c46 100644 --- a/src/agent/runtime/text-generation-runtime-message-converter.ts +++ b/src/agent/runtime/text-generation-runtime-message-converter.ts @@ -20,7 +20,10 @@ import { assertProviderReachableAttachment } from "./attachment-reachability.ts" import { buildDataFileAnnotation } from "#veryfront/chat/types.ts"; import { getTextFromParts, getToolArguments, type Message, type ToolCallPart } from "../types.ts"; import { readAttachedProviderMetadata } from "./provider-metadata.ts"; -import { collectAnthropicProviderToolCallIds } from "./anthropic-provider-replay-block.ts"; +import { + collectAnthropicProviderToolCallIds, + groupAnthropicRawAssistantMessagesByAnchor, +} from "./anthropic-provider-replay-block.ts"; function getStringPartField(part: unknown, key: string): string | undefined { if (!part || typeof part !== "object" || Array.isArray(part)) return undefined; @@ -390,6 +393,22 @@ function hasProviderSendableAssistantContent(message: Message): boolean { }); } +function splitAnthropicProviderMetadata( + providerMetadata: Record, + segmentCount: number, +): Record[] | undefined { + if (!isRecord(providerMetadata.anthropic)) return undefined; + const anthropic = providerMetadata.anthropic; + const grouped = groupAnthropicRawAssistantMessagesByAnchor( + anthropic.rawAssistantMessages, + segmentCount, + ); + return grouped?.map((rawAssistantMessages) => ({ + ...providerMetadata, + anthropic: { ...anthropic, rawAssistantMessages }, + })); +} + function convertAssistantMessageToTextGenerationRuntimeMessages( message: Message, providerExecutedToolCallIds: Set, @@ -493,8 +512,6 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( const providerMetadata = readAttachedProviderMetadata(message); const assistantMessages = messages.filter((entry) => entry.role === "assistant"); - // Exact replay metadata describes one provider response. Attaching it after - // conversion split that response would pair it with an incomplete projection. if (providerMetadata !== undefined && assistantMessages.length === 1) { assistantMessages[0]!.providerMetadata = providerMetadata; } else if (providerMetadata !== undefined && messages.length === 0) { @@ -503,6 +520,17 @@ function convertAssistantMessageToTextGenerationRuntimeMessages( content: [{ type: "text", text: "" }], providerMetadata, }); + } else if (providerMetadata !== undefined) { + const splitMetadata = splitAnthropicProviderMetadata( + providerMetadata, + assistantMessages.length, + ); + if (splitMetadata === undefined) { + throw new TypeError("Provider replay metadata cannot follow a split assistant turn"); + } + for (const [index, assistantMessage] of assistantMessages.entries()) { + assistantMessage.providerMetadata = splitMetadata[index]; + } } return messages; From 1eaddafffd4f900d9aa427348ce224dda03081f5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 30 Aug 2026 00:49:27 +0200 Subject: [PATCH 56/56] fix(agent): reject duplicate replay tool ids --- src/agent/runtime/provider-replay.test.ts | 42 +++++++++++++++++++++++ src/agent/runtime/provider-replay.ts | 19 +++++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/agent/runtime/provider-replay.test.ts b/src/agent/runtime/provider-replay.test.ts index d76f3d6691..c923c90bce 100644 --- a/src/agent/runtime/provider-replay.test.ts +++ b/src/agent/runtime/provider-replay.test.ts @@ -983,6 +983,48 @@ describe("agent/runtime/provider-replay", () => { ); }); + it("should reject duplicate client tool-use ids and provider-owned collisions", () => { + const clientCall = { + type: "tool_use", + id: "duplicate-tool-use", + name: "lookup", + input: { query: "provider replay" }, + }; + const providerCall = { + type: "server_tool_use", + id: clientCall.id, + name: "web_search", + input: { query: "provider replay" }, + caller: { type: "direct" }, + }; + const target = createAssistantMessage("assistant-message-1"); + + for ( + const blocks of [ + [clientCall, clientCall], + [clientCall, providerCall], + [providerCall, clientCall], + ] + ) { + const checkpoint: ProviderReplayCheckpoint = { + version: 1, + messageId: target.id, + provider: "anthropic", + providerBlocks: blocks.map((block) => ({ + type: "provider-block", + provider: "anthropic", + block, + })), + providerBlockPositions: [0, 1], + totalPartCount: 2, + }; + + assertProviderReplayError(() => + applyProviderReplayCheckpointsToMessages([target], [checkpoint]) + ); + } + }); + it("should reject MCP provider results for server-owned tool uses", () => { const providerCall = { type: "server_tool_use", diff --git a/src/agent/runtime/provider-replay.ts b/src/agent/runtime/provider-replay.ts index 26760d7db6..43b8c8f4f2 100644 --- a/src/agent/runtime/provider-replay.ts +++ b/src/agent/runtime/provider-replay.ts @@ -763,13 +763,13 @@ type PendingAnthropicProviderTool = { type AnthropicProviderToolCorrelationState = { readonly pendingProviderTools: Map; - readonly providerToolUseIds: Set; + readonly toolUseIds: Set; }; function createAnthropicProviderToolCorrelationState(): AnthropicProviderToolCorrelationState { return { pendingProviderTools: new Map(), - providerToolUseIds: new Set(), + toolUseIds: new Set(), }; } @@ -777,20 +777,29 @@ function resetAnthropicProviderToolCorrelationState( state: AnthropicProviderToolCorrelationState, ): void { state.pendingProviderTools.clear(); - state.providerToolUseIds.clear(); + state.toolUseIds.clear(); } function validateAnthropicProviderToolCorrelationBlock( block: Record, state: AnthropicProviderToolCorrelationState, ): void { + if (block.type === "tool_use") { + const toolUse = toCanonicalAnthropicToolCall(block, false); + const toolCallId = String(toolUse.toolCallId); + if (state.toolUseIds.has(toolCallId)) { + invalidCheckpoint("checkpoint tool-use id is duplicated"); + } + state.toolUseIds.add(toolCallId); + return; + } if (block.type === "server_tool_use" || block.type === "mcp_tool_use") { const toolUse = toCanonicalAnthropicToolCall(block, true); const toolCallId = String(toolUse.toolCallId); - if (state.providerToolUseIds.has(toolCallId)) { + if (state.toolUseIds.has(toolCallId)) { invalidCheckpoint("checkpoint provider tool-use id is duplicated"); } - state.providerToolUseIds.add(toolCallId); + state.toolUseIds.add(toolCallId); state.pendingProviderTools.set(toolCallId, { name: String(toolUse.toolName), type: block.type,