diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index eec5905e5d..20c3bf6d21 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -802,14 +802,14 @@ Input delivered to a hosted agent-service detached execution callback. | `getConfirmedProjectContextSwitchId` | Return only the confirmed project id for legacy callers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/project/context.ts#L258) | | `getConversationRun` | Return conversation run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable.ts#L1065) | | `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#L118) | +| `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#L69) | | `getForwardedHostedModelId` | Return forwarded hosted model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L86) | | `getForwardedHostedRuntimeOverrides` | Return forwarded hosted runtime overrides. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-request-config.ts#L96) | | `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#L201) | -| `getHostedStreamErrorText` | Return hosted stream error text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-terminal-error.ts#L113) | +| `getHostedStreamErrorText` | Return hosted stream error text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-terminal-error.ts#L108) | | `getInputRequest` | Request payload for get input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/input/request-protocol.ts#L254) | | `getMaxForkRuntimeStepCount` | Return max fork runtime step count. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-stream.ts#L445) | | `getProjectAgentRuntimeAgentIdCandidates` | Return project agent runtime agent ID candidates. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/project/agent-runtime.ts#L223) | @@ -988,7 +988,7 @@ Input delivered to a hosted agent-service detached execution callback. | `selectProviderCompatibleTools` | Select provider compatible tools helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-tool-compat.ts#L126) | | `shouldBlockHostedChildSameTurnRetry` | Should block hosted child same turn retry helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-status.ts#L49) | | `shouldContinueForkRuntimeStep` | Should continue fork runtime step helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-step-progress.ts#L42) | -| `shouldFailEmptyHostedFinalizedMessage` | Message shape for should fail empty hosted finalized. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-terminal-error.ts#L132) | +| `shouldFailEmptyHostedFinalizedMessage` | Message shape for should fail empty hosted finalized. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-terminal-error.ts#L127) | | `shouldInjectDefaultResearchArtifactPath` | Should inject default research artifact path helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/default-research-artifact-policy.ts#L77) | | `shouldPruneSandboxToolsFromHostedChildRequest` | Request payload for should prune sandbox tools from hosted child. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-requested-tools.ts#L122) | | `shouldReinforceLoadSkillContinuation` | Should reinforce load skill continuation helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/delegation-policy.ts#L239) | @@ -1617,7 +1617,7 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedServiceProjectAccessError` | Error shape for hosted service project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L63) | | `HostedServiceProjectAccessResult` | Result returned from hosted service project access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L70) | | `HostedStreamPartForUiChunkMapping` | Public API contract for hosted stream part for UI chunk mapping. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/hosted-ui-chunk-mapping.ts#L31) | -| `HostedStreamTerminalError` | Error shape for hosted stream terminal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-terminal-error.ts#L59) | +| `HostedStreamTerminalError` | Error shape for hosted stream terminal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-terminal-error.ts#L54) | | `HostedTerminalError` | Error shape for hosted terminal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-finalization.ts#L6) | | `HostedUiChunkMappingOptions` | Options accepted by hosted UI chunk mapping. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/hosted-ui-chunk-mapping.ts#L5) | | `HumanInputField` | Public API contract for human input field. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/input/human-input.ts#L138) | diff --git a/src/agent/hosted/hosted-chat-finalization.test.ts b/src/agent/hosted/hosted-chat-finalization.test.ts index 8687cbf90c..408face333 100644 --- a/src/agent/hosted/hosted-chat-finalization.test.ts +++ b/src/agent/hosted/hosted-chat-finalization.test.ts @@ -698,6 +698,69 @@ describe("agent/hosted-chat-finalization", () => { }); } + it("preserves delivered output when a trailing model step fails after a completed tool handoff", async () => { + const calls: string[] = []; + const terminalStates: HostedLifecycleTerminalState[] = []; + + await finalizeHostedChatRun({ + kind: "response", + responseMessage: createResponseMessage({ parts: [] }), + isAborted: false, + streamResult: createStreamResult({ + text: "The environment panel is open and ready.", + finishReason: "tool-calls", + }), + lifecycleAdapter: createLifecycleAdapter({ + calls, + terminalStates, + mirror: createDurableRunMirror({ calls }), + }), + mirroredToolChunkState: createMirroredToolChunkState(), + capturedMessageId: "assistant-message-1", + incompleteToolCallsPartErrorText: "Tool call did not complete", + cleanup: async () => { + calls.push("cleanup"); + }, + streamError: new Error("Provider request failed with status 502"), + }); + + assertEquals(terminalStates, [{ status: "completed" }]); + }); + + it("fails a watchdog timeout after a completed tool handoff", async () => { + const calls: string[] = []; + const terminalStates: HostedLifecycleTerminalState[] = []; + + await finalizeHostedChatRun({ + kind: "response", + responseMessage: createResponseMessage({ parts: [] }), + isAborted: false, + streamResult: createStreamResult({ + text: "The environment panel is open and ready.", + finishReason: "tool-calls", + }), + lifecycleAdapter: createLifecycleAdapter({ + calls, + terminalStates, + mirror: createDurableRunMirror({ calls }), + }), + mirroredToolChunkState: createMirroredToolChunkState(), + capturedMessageId: "assistant-message-1", + incompleteToolCallsPartErrorText: "Tool call did not complete", + cleanup: async () => { + calls.push("cleanup"); + }, + streamError: new Error("Chat stream idle timeout after 300000ms"), + }); + + assertEquals(terminalStates, [{ + status: "failed", + terminalErrorCode: "STREAM_TIMEOUT", + terminalErrorMessage: + "This run timed out after 5 minutes before the agent finished. Try again to continue, or narrow the request.", + }]); + }); + it("logs and suppresses cleanup errors after terminal dispatch", async () => { const calls: string[] = []; const terminalStates: HostedLifecycleTerminalState[] = []; diff --git a/src/agent/hosted/hosted-chat-finalization.ts b/src/agent/hosted/hosted-chat-finalization.ts index 93406aa051..5474c2a609 100644 --- a/src/agent/hosted/hosted-chat-finalization.ts +++ b/src/agent/hosted/hosted-chat-finalization.ts @@ -9,10 +9,7 @@ import { toConversationHostedTerminalState, } from "../conversation/hosted-terminal.ts"; import type { MirroredToolChunkState } from "../streaming/mirrored-tool-chunk-state.ts"; -import { - hasCompletedStepSignal, - isLateProviderBodyReadError, -} from "../streaming/stream-outcome.ts"; +import { hasCompletedStepSignal, isStreamTimeoutError } from "../streaming/stream-outcome.ts"; import type { HostedChatExecutionLifecycleAdapter } from "./chat-execution-lifecycle-types.ts"; import { buildDetachedFallbackChunks, @@ -246,7 +243,7 @@ function shouldFailStreamError(input: { if ( input.hasOutput && hasFinalStepCompletionSignal(input.finalStep) && - isLateProviderBodyReadError(input.streamError) + !isStreamTimeoutError(input.streamError) ) { return false; } diff --git a/src/agent/hosted/stream-finalization.test.ts b/src/agent/hosted/stream-finalization.test.ts index f8e353edfd..d0600598ee 100644 --- a/src/agent/hosted/stream-finalization.test.ts +++ b/src/agent/hosted/stream-finalization.test.ts @@ -209,6 +209,48 @@ describe("agent/hosted-stream-finalization", () => { ]); }); + it("completes hosted responses after a trailing provider failure", async () => { + const calls: string[] = []; + + await finalizeHostedResponse({ + isAborted: false, + streamError: new Error("Provider request failed with status 502"), + getFinalStep: async () => ({ step: 1, finishReason: "tool-calls" }), + buildState: async () => ({ + persistedMessage: { id: "msg-1" }, + finalizedMessage: { id: "msg-1", parts: ["text"] }, + fallbackChunks: ["chunk-1"], + hasIncompleteToolParts: false, + metadata: { modelId: "anthropic/claude-haiku-4-5" }, + }), + shouldFailEmptyMessage: () => false, + resolveEmptyTerminalError: ({ streamError }) => ({ + code: "STREAM_ERROR", + message: streamError instanceof Error ? streamError.message : String(streamError), + }), + appendFallbackChunk: async (chunk) => { + calls.push(`append:${chunk}`); + }, + flushMirror: async () => { + calls.push("flush"); + }, + dispatchTerminalState: async (state) => { + calls.push(`dispatch:${state.status}:${state.metadata?.modelId}`); + }, + resolveTerminalState: () => ({ status: "completed" }), + cleanup: async () => { + calls.push("cleanup"); + }, + }); + + assertEquals(calls, [ + "append:chunk-1", + "flush", + "dispatch:completed:anthropic/claude-haiku-4-5", + "cleanup", + ]); + }); + it("fails hosted responses with content when a body read fails without a final step finish reason", async () => { const calls: string[] = []; diff --git a/src/agent/hosted/stream-terminal-error.ts b/src/agent/hosted/stream-terminal-error.ts index 603f4da017..5cbc6a9116 100644 --- a/src/agent/hosted/stream-terminal-error.ts +++ b/src/agent/hosted/stream-terminal-error.ts @@ -1,6 +1,7 @@ import { isRecord } from "../../chat/conversation.ts"; import { extractFinalStepTerminalError } from "../../chat/final-step-fallback.ts"; import { parseProviderError } from "../../chat/provider-errors.ts"; +import { isStreamTimeoutError } from "../streaming/stream-outcome.ts"; const EMPTY_RESPONSE_TERMINAL_ERROR_CODE = "EMPTY_RESPONSE"; const EMPTY_RESPONSE_TERMINAL_ERROR_MESSAGE = "Assistant completed without producing a response"; @@ -36,13 +37,7 @@ function formatTimeoutDuration(input: string): string | null { function getHostedStreamTimeoutTerminalError(error: unknown): HostedStreamTerminalError | null { const message = getUnknownErrorMessage(error).trim(); - const normalized = message.toLowerCase(); - const isTimeout = normalized.includes("stream timed out") || - normalized.includes("chat stream idle timeout") || - normalized.includes("chat stream bootstrap timeout") || - normalized.includes("stream timeout"); - - if (!isTimeout) { + if (!isStreamTimeoutError(error)) { return null; } diff --git a/src/agent/streaming/stream-outcome.test.ts b/src/agent/streaming/stream-outcome.test.ts index 70965cfd43..1116ea64b5 100644 --- a/src/agent/streaming/stream-outcome.test.ts +++ b/src/agent/streaming/stream-outcome.test.ts @@ -123,7 +123,7 @@ describe("resolveStreamOutcome", () => { } }); - it("keeps late body-read completion behind output and finish gates", () => { + it("keeps post-completion provider failures behind output and finish gates", () => { assertEquals( resolveStreamOutcome({ snapshot: snapshot("completed", "stop", true), @@ -132,6 +132,22 @@ describe("resolveStreamOutcome", () => { }).status, "completed", ); + assertEquals( + resolveStreamOutcome({ + snapshot: snapshot("tool_handoff", "tool-calls", true), + elapsedMs: 10, + thrownError: new Error("Provider request failed with status 502"), + }).status, + "tool_handoff", + ); + assertEquals( + resolveStreamOutcome({ + snapshot: snapshot("tool_handoff", "tool-calls", true), + elapsedMs: 300_000, + thrownError: new Error("Chat stream idle timeout after 300000ms"), + }).status, + "failed", + ); assertEquals( resolveStreamOutcome({ snapshot: snapshot("streaming", null, true), diff --git a/src/agent/streaming/stream-outcome.ts b/src/agent/streaming/stream-outcome.ts index 1951437998..50621d3da2 100644 --- a/src/agent/streaming/stream-outcome.ts +++ b/src/agent/streaming/stream-outcome.ts @@ -40,6 +40,15 @@ export function isLateProviderBodyReadError(error: unknown): boolean { return /error reading a body from connection/i.test(getStreamErrorMessage(error)); } +/** True when a thrown stream error represents a lifecycle or watchdog timeout. */ +export function isStreamTimeoutError(error: unknown): boolean { + const normalized = getStreamErrorMessage(error).trim().toLowerCase(); + return normalized.includes("stream timed out") || + normalized.includes("chat stream idle timeout") || + normalized.includes("chat stream bootstrap timeout") || + normalized.includes("stream timeout"); +} + /** True when the provider finish reason marks a completed step. */ export function hasCompletedStepSignal(finishReason: string | null): boolean { switch (finishReason) { @@ -132,7 +141,7 @@ export function resolveStreamOutcome( !( input.snapshot.hasStreamOutput && hasCompletedStepSignal(input.snapshot.finishReason) && - isLateProviderBodyReadError(input.thrownError) + !isStreamTimeoutError(input.thrownError) ) ) { return failedClassifiedProviderOutcome(