@@ -158,7 +167,10 @@ export function AssistantErrorMessage({ message }: { message: UiMessage }) {
{summary}
- {error.code}
+
+ {error.code}
+ {networkCode ? ` · ${networkCode}` : ""}
+
{
+ const [transcript, activityGroup] = await Promise.all([
+ readTranscriptSource(),
+ read("src/features/chat/transcript/ActivityGroup.tsx"),
+ ]);
+ const card = transcript.slice(
+ transcript.indexOf("function AssistantErrorMessage"),
+ transcript.indexOf("const TOOL_ACTION_KEYS"),
+ );
+
+ assert.match(card, /error\.details/);
+ assert.match(card, /networkCode/);
+ assert.match(activityGroup, /retryError\.networkCode/);
+});
});
diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md
index c859a6e536..2b99eb4292 100644
--- a/docs/spec/03-runtime/01-ipc-protocol.md
+++ b/docs/spec/03-runtime/01-ipc-protocol.md
@@ -734,9 +734,13 @@ context. Manual compaction never silently falls back.
Provider `error` events may include bounded diagnostic fields in
`AppError.details`: `phase` (`request` or `stream`), `providerStatus`,
-`providerCode`, `providerWaitMs`, `streamMs`, and `retryAttempt`. These fields
-are additive and redacted; they never carry credentials or an unrestricted
-provider response. A transient stream failure may be replayed once inside the
+`providerCode`, `providerWaitMs`, `streamMs`, `retryAttempt`, and, for a
+network failure, `networkCategory`, `networkCode`, `networkSyscall` and
+`networkHost` plus the request correlation fields `requestMessages`,
+`requestBytes` and `compactionGeneration`. These fields are additive and
+redacted; they never carry credentials or an unrestricted provider response,
+and the request fields are counts and byte sizes only. A transient stream
+failure may be replayed once inside the
same turn without a terminal `error` event or a duplicate assistant message.
The second failure emits the terminal normalized `STREAM_FAILED` error.
diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md
index b904f5e13f..c567050a02 100644
--- a/docs/spec/03-runtime/02-agent-runtime.md
+++ b/docs/spec/03-runtime/02-agent-runtime.md
@@ -227,8 +227,11 @@ codes, budget size, and precedence.
When the retry budget is exhausted, the final assistant error and lifecycle
`error` are emitted once. Provider failures carry bounded diagnostics in
`AppError.details` when available: `phase` (`request` or `stream`),
-`providerStatus`, `providerCode`, `providerWaitMs`, `streamMs`, and
-`retryAttempt`. For a persistent 429 or non-429 transient failure,
+`providerStatus`, `providerCode`, `providerWaitMs`, `streamMs`,
+`retryAttempt`, the network diagnosis (`networkCategory`, `networkCode`,
+`networkSyscall`, `networkHost`) and the request correlation fields
+(`requestMessages`, `requestBytes`, `compactionGeneration`). For a persistent
+429 or non-429 transient failure,
`retryAttempt` is `10`. Credentials and unrestricted response bodies never
enter the event or log. The active-turn status shows the remaining backoff and
the retry budget as `Retrying in 0s · attempt 9/10` in English.
diff --git a/docs/spec/03-runtime/08-error-codes.md b/docs/spec/03-runtime/08-error-codes.md
index 33bd913ef4..60b8c7b84b 100644
--- a/docs/spec/03-runtime/08-error-codes.md
+++ b/docs/spec/03-runtime/08-error-codes.md
@@ -296,6 +296,17 @@ request is replayed; the session and its tool state are untouched. A
non-retryable `PROVIDER_ERROR` from a
malformed 400/422 request never enters either budget.
+A `NETWORK_ERROR` carries the failing transport layer as bounded `details`:
+`networkCategory` (`dns`, `tls`, `timeout`, `refused`, `unreachable`, `reset`,
+`proxy`, or `unknown` when nothing survived), `networkCode` (the errno, e.g.
+`ENOTFOUND`, `ECONNRESET`, `EPROTO`, `UND_ERR_SOCKET`), and, when the transport
+reported them, `networkSyscall` and `networkHost`. Only the bare hostname is
+kept — never a URL, port, path, query, or credential — and `providerCode` is
+omitted when it would repeat `networkCode`. Per-layer codes (`DNS_ERROR`,
+`TLS_ERROR`, `SOCKET_RESET`, …) are deliberately not introduced: the category
+splits the layers without adding user-visible codes and locale strings for
+each of them.
+
### Permission timeout
UI/host timeout emits `PERMISSION_TIMEOUT` internally, tool result presented as denied (`TOOL_DENIED`) to agent.
@@ -328,8 +339,16 @@ with an accessible details disclosure containing the redacted provider response,
provider ID, and model ID. Provider detail is capped at 600 characters and
common credential/header values are redacted before event emission or
persistence. When available, the details disclosure may also show bounded
-`phase`, `providerStatus`, `providerCode`, `providerWaitMs`, `streamMs`, and
-`retryAttempt` fields. The assistant error card offers a localized
+`phase`, `providerStatus`, `providerCode`, `providerWaitMs`, `streamMs`,
+`retryAttempt`, `networkCategory`, `networkCode`, `networkSyscall`,
+`networkHost`, `requestMessages`, `requestBytes`, and `compactionGeneration`
+fields. The request fields are counts and byte sizes only and the compaction
+field is the checkpoint generation counter; none of them carries message
+content. While a transient provider failure retries, the activity indicator's
+reason popover shows the localized summary, the stable code, and — for a
+network failure — the transport errno (`NETWORK_ERROR · ENOTFOUND`), so the
+failing layer is visible during the retry loop as well as in the log record.
+The assistant error card offers a localized
Continue action that resends the continuation prompt (`继续当前任务` /
`Continue the current task`) in the same session without truncating the failed
turn. The session-scoped failed-turn recovery card is used only when no
diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
index fadb1b076c..7400334f0f 100644
--- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
+++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
@@ -616,9 +616,11 @@ type AgentEvent =
提供程序 `error` 事件可能包括以下中的有限诊断字段:
`AppError.details`:`phase`(`request` 或 `stream`)、`providerStatus`、
-`providerCode`、`providerWaitMs`、`streamMs` 和 `retryAttempt`。这些领域
-是添加和编辑的;他们从不携带凭证或不受限制的
-提供商响应。瞬时流故障可能会在内部重播
+`providerCode`、`providerWaitMs`、`streamMs`、`retryAttempt`,以及网络故障
+时的 `networkCategory`、`networkCode`、`networkSyscall`、`networkHost` 和请求
+关联字段 `requestMessages`、`requestBytes`、`compactionGeneration`。这些字段
+都是新增且经过编辑的;它们从不携带凭据或不受限制的提供商响应,请求字段
+只有计数与字节大小。瞬时流故障可能会在内部重播
同一回合,没有终端 `error` 事件或重复的辅助消息。
第二次失败会发出终端标准化 `STREAM_FAILED` 错误。
diff --git a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md
index 91506bb945..883846ef2e 100644
--- a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md
+++ b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md
@@ -186,7 +186,10 @@ HTTP 429 处理是一个逻辑回合策略。此路径禁用了 pi-ai 的嵌套
当 429 预算耗尽时,最终的助手错误和生命周期 `error` 只发出一次。
提供程序故障在可用时于 `AppError.details` 中携带有界诊断:
`phase`(`request` 或 `stream`)、`providerStatus`、`providerCode`、
-`providerWaitMs`、`streamMs` 和 `retryAttempt`。对于持续的 429,
+`providerWaitMs`、`streamMs`、`retryAttempt`、网络诊断
+(`networkCategory`、`networkCode`、`networkSyscall`、`networkHost`)以及
+请求关联字段(`requestMessages`、`requestBytes`、`compactionGeneration`)。
+对于持续的 429,
`retryAttempt` 为 `5`;对于持续的非 429 瞬时故障,它为 `4`。凭据与不受限制的
响应正文永远不会进入事件或日志。
diff --git a/docs/zh-CN/spec/03-runtime/08-error-codes.md b/docs/zh-CN/spec/03-runtime/08-error-codes.md
index 089b85bcdb..fd31da3cc2 100644
--- a/docs/zh-CN/spec/03-runtime/08-error-codes.md
+++ b/docs/zh-CN/spec/03-runtime/08-error-codes.md
@@ -289,6 +289,16 @@ Node sidecar 将提供商 SDK 错误映射到:
不可重试 `PROVIDER_ERROR` 永远不会进入任何预算。预算耗尽后的失败仍然是
致命的。
+`NETWORK_ERROR` 以有界的 `details` 携带真正失败的传输层:
+`networkCategory`(`dns`、`tls`、`timeout`、`refused`、`unreachable`、
+`reset`、`proxy`,或在没有留下任何线索时为 `unknown`)、`networkCode`
+(errno,例如 `ENOTFOUND`、`ECONNRESET`、`EPROTO`、`UND_ERR_SOCKET`),
+以及传输层提供时的 `networkSyscall` 和 `networkHost`。只保留裸主机名——
+绝不包含 URL、端口、路径、查询串或凭据——当 `providerCode` 会重复
+`networkCode` 时省略它。刻意不引入按层划分的错误码(`DNS_ERROR`、
+`TLS_ERROR`、`SOCKET_RESET` 等):分类字段已能区分这些层,而无需为每一层
+增加用户可见的错误码与本地化文案。
+
### 权限超时
UI/host 超时在内部发出 `PERMISSION_TIMEOUT`,工具结果向代理显示为拒绝 (`TOOL_DENIED`)。
@@ -317,9 +327,15 @@ UI/host 超时在内部发出 `PERMISSION_TIMEOUT`,工具结果向代理显示
助手错误消息显示本地化摘要和稳定代码,并带有
包含经过编辑的提供商响应的可访问详细信息披露,
提供商 ID 和模型 ID。提供商详细信息上限为 600 个字符,并且
-公共 credential/header 值在事件发射之前进行编辑或
+公共 credential/header 值在事件发射或持久化之前进行编辑。
详细信息披露也可能显示有界的 `phase`、`providerStatus`、`providerCode`、
-`providerWaitMs`、`streamMs` 和 `retryAttempt` 字段。
+`providerWaitMs`、`streamMs`、`retryAttempt`、`networkCategory`、
+`networkCode`、`networkSyscall`、`networkHost`、`requestMessages`、
+`requestBytes` 和 `compactionGeneration` 字段。请求字段只有计数与字节大小,
+压缩字段是检查点世代计数器,均不携带消息内容。当瞬时提供商故障正在重试时,
+活动指示器的原因气泡会显示本地化摘要、稳定错误码,并在网络故障时显示传输层
+errno(`NETWORK_ERROR · ENOTFOUND`),因此失败层级在重试期间与日志记录中
+同样可见。
## 6. i18n 按键约定
diff --git a/packages/agent-runtime/src/agent-errors.test.ts b/packages/agent-runtime/src/agent-errors.test.ts
index a6bab3e764..539d77115c 100644
--- a/packages/agent-runtime/src/agent-errors.test.ts
+++ b/packages/agent-runtime/src/agent-errors.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
-import { classifyAgentError } from "./agent-errors.js";
+import {
+ classifyAgentError,
+ describeNetworkFailure,
+} from "./agent-errors.js";
describe("classifyAgentError", () => {
it("classifies auth failures from status fields", () => {
@@ -57,9 +60,139 @@ describe("classifyAgentError", () => {
expect(classifyAgentError(err)).toMatchObject({
code: "NETWORK_ERROR",
retriable: true,
+ details: { networkCategory: "refused", networkCode: "ECONNREFUSED" },
});
expect(classifyAgentError("getaddrinfo ENOTFOUND api.example.com"))
- .toMatchObject({ code: "NETWORK_ERROR" });
+ .toMatchObject({
+ code: "NETWORK_ERROR",
+ details: {
+ networkCategory: "dns",
+ networkCode: "ENOTFOUND",
+ networkHost: "api.example.com",
+ },
+ });
+ });
+
+ it("names the failing layer behind a nested DNS cause", () => {
+ const err = new TypeError("fetch failed");
+ (err as any).cause = Object.assign(
+ new Error("getaddrinfo ENOTFOUND api.example.com"),
+ {
+ code: "ENOTFOUND",
+ syscall: "getaddrinfo",
+ hostname: "api.example.com",
+ },
+ );
+
+ // `providerCode` repeats the same errno, so it is folded into the
+ // network-namespaced key instead of being logged twice.
+ expect(classifyAgentError(err).details).toEqual({
+ networkCategory: "dns",
+ networkCode: "ENOTFOUND",
+ networkSyscall: "getaddrinfo",
+ networkHost: "api.example.com",
+ });
+ });
+
+ it("reads the errno out of an undici aggregate cause", () => {
+ const aggregate = new AggregateError(
+ [
+ Object.assign(new Error("connect ECONNREFUSED ::1:443"), {
+ code: "ECONNREFUSED",
+ }),
+ Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:443"), {
+ code: "ECONNREFUSED",
+ }),
+ ],
+ "all connection attempts failed",
+ );
+ const err = Object.assign(new TypeError("fetch failed"), {
+ cause: aggregate,
+ });
+
+ expect(classifyAgentError(err)).toMatchObject({
+ code: "NETWORK_ERROR",
+ details: { networkCategory: "refused", networkCode: "ECONNREFUSED" },
+ });
+ });
+
+ it("separates TLS, timeout and dropped-socket causes", () => {
+ const tls = Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(new Error("self signed certificate"), {
+ code: "DEPTH_ZERO_SELF_SIGNED_CERT",
+ }),
+ });
+ const tlsProto = Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(new Error("write EPROTO"), { code: "EPROTO" }),
+ });
+ const timeout = Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(new Error("Connect Timeout Error"), {
+ code: "UND_ERR_CONNECT_TIMEOUT",
+ }),
+ });
+ const reset = Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(new Error("other side closed"), {
+ code: "UND_ERR_SOCKET",
+ }),
+ });
+ const proxy = Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(new Error("proxy connection failed"), {
+ code: "ERR_PROXY_CONNECTION_FAILED",
+ }),
+ });
+
+ expect(classifyAgentError(tls).details).toMatchObject({
+ networkCategory: "tls",
+ networkCode: "DEPTH_ZERO_SELF_SIGNED_CERT",
+ });
+ expect(classifyAgentError(tlsProto).details).toMatchObject({
+ networkCategory: "tls",
+ networkCode: "EPROTO",
+ });
+ expect(classifyAgentError(timeout).details).toMatchObject({
+ networkCategory: "timeout",
+ networkCode: "UND_ERR_CONNECT_TIMEOUT",
+ });
+ expect(classifyAgentError(reset).details).toMatchObject({
+ networkCategory: "reset",
+ networkCode: "UND_ERR_SOCKET",
+ });
+ expect(classifyAgentError(proxy).details).toMatchObject({
+ networkCategory: "proxy",
+ networkCode: "ERR_PROXY_CONNECTION_FAILED",
+ });
+ });
+
+ it("says the layer is unknown rather than guessing when no cause survives", () => {
+ // The reporter's shape: a bare `fetch failed` with no cause chain kept.
+ expect(classifyAgentError(new TypeError("fetch failed"))).toMatchObject({
+ code: "NETWORK_ERROR",
+ retriable: true,
+ details: { networkCategory: "unknown" },
+ });
+ });
+
+ it("never leaks credentials through network diagnostics", () => {
+ const err = Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(
+ new Error(
+ "connect ECONNRESET https://api.example.com/v1/chat?api_key=sk-live-secret Authorization: Bearer token-secret",
+ ),
+ { code: "ECONNRESET", hostname: "user:pass@api.example.com" },
+ ),
+ });
+ const classified = classifyAgentError(err);
+ const serialized = JSON.stringify(classified);
+
+ expect(serialized).not.toContain("sk-live-secret");
+ expect(serialized).not.toContain("token-secret");
+ expect(serialized).not.toContain("user:pass");
+ expect(serialized).not.toContain("?api_key");
+ // Only the errno survives: no hostname, no port, no URL, no query.
+ expect(classified.details).toEqual({
+ networkCategory: "reset",
+ networkCode: "ECONNRESET",
+ });
});
it("classifies aborts, timeouts and unknown errors", () => {
@@ -132,4 +265,59 @@ describe("classifyAgentError", () => {
const { message } = classifyAgentError(`500: ${"x".repeat(5000)}`);
expect(message.length).toBeLessThan(700);
});
+
+ it("keeps a pseudo errno out of the reported network code", () => {
+ // A long errno-shaped run inside a provider body must not become an
+ // unbounded detail: the message is untrusted text.
+ const flood = `EDNS${"A".repeat(3_000)}`;
+ expect(describeNetworkFailure(flood, flood).code).toBeUndefined();
+ expect(classifyAgentError(flood).details).not.toHaveProperty("networkCode");
+
+ // A body word that merely contains "proxy" is not a proxy layer either.
+ const bodyWord = Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(new Error("blocked EBLOCKEDBYPROXY"), {
+ code: "EBLOCKEDBYPROXY",
+ }),
+ });
+ const details = classifyAgentError(bodyWord).details ?? {};
+ expect(details.networkCategory).toBe("unknown");
+ expect(details).not.toHaveProperty("networkCode");
+ });
+
+ it("reads a proxy failure as a proxy failure wherever the errno sits", () => {
+ // undici reports the proxy's own socket errno as the deeper cause; the
+ // proxy is still the layer that failed.
+ const err = Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(new Error("proxy connect ECONNREFUSED"), {
+ code: "ERR_PROXY_CONNECTION_FAILED",
+ cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:7890"), {
+ code: "ECONNREFUSED",
+ }),
+ }),
+ });
+
+ expect(classifyAgentError(err).details).toEqual({
+ networkCategory: "proxy",
+ networkCode: "ERR_PROXY_CONNECTION_FAILED",
+ });
+ });
+
+ it("does not turn a credential-shaped message token into a hostname", () => {
+ const token = classifyAgentError(
+ "getaddrinfo ENOTFOUND sk-live-abcdef012345",
+ );
+ expect(token.details).toMatchObject({
+ networkCategory: "dns",
+ networkCode: "ENOTFOUND",
+ });
+ expect(token.details).not.toHaveProperty("networkHost");
+
+ // Truncating `user:pass@host` at the colon must not report `user` as the
+ // host either.
+ expect(
+ classifyAgentError(
+ "getaddrinfo ENOTFOUND user:pass@api.example.com?api_key=sk-1",
+ ).details,
+ ).not.toHaveProperty("networkHost");
+ });
});
diff --git a/packages/agent-runtime/src/agent-errors.ts b/packages/agent-runtime/src/agent-errors.ts
index db799e5fce..be37504fda 100644
--- a/packages/agent-runtime/src/agent-errors.ts
+++ b/packages/agent-runtime/src/agent-errors.ts
@@ -98,6 +98,223 @@ function extractErrorCode(err: unknown): string | number | undefined {
return undefined;
}
+/**
+ * Coarse transport layer behind a `NETWORK_ERROR`. Deliberately low-cardinality:
+ * it answers "which layer failed" — name lookup, TLS handshake, connect, a
+ * timeout, a connection that opened and then died, the proxy — without
+ * inventing a user-visible error code per cause (and therefore without an i18n
+ * string per cause).
+ */
+export type NetworkFailureCategory =
+ | "dns"
+ | "tls"
+ | "timeout"
+ | "refused"
+ | "unreachable"
+ | "reset"
+ | "proxy"
+ | "unknown";
+
+export type NetworkFailure = {
+ category: NetworkFailureCategory;
+ /** errno-style code from the cause chain, e.g. ENOTFOUND or UND_ERR_SOCKET. */
+ code?: string;
+ /** Node syscall that failed, e.g. getaddrinfo, connect, read. */
+ syscall?: string;
+ /** Bare hostname only; never a URL, port, path, query or credentials. */
+ hostname?: string;
+};
+
+/** errno-ish shapes only, so provider/proxy free text can never pass through. */
+const SAFE_NETWORK_CODE_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
+const SAFE_NETWORK_SYSCALL_PATTERN = /^[a-z][a-z_]{0,31}$/;
+/**
+ * A hostname, not a URL: only letters, digits, dots and inner hyphens may
+ * appear, so `user:pass@host`, `host:8080`, `https://host/path?k=v`, IPv6
+ * literals and any path/query text can never match.
+ */
+const SAFE_HOSTNAME_PATTERN =
+ /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
+/**
+ * A hostname read out of the *message* is untrusted text, so it additionally
+ * has to be dotted: a bare API-key-shaped token, or the `user` left over when
+ * `getaddrinfo ENOTFOUND user:pass@host` is truncated at the colon, is not a
+ * hostname and must not be reported as one.
+ */
+const SAFE_MESSAGE_HOSTNAME_PATTERN =
+ /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
+
+const NETWORK_CATEGORY_PATTERNS: ReadonlyArray<
+ readonly [RegExp, NetworkFailureCategory]
+> = [
+ // Anchored on the code, not on any occurrence of "proxy" inside it, so a
+ // provider body word cannot be read as a proxy layer.
+ [/(?:^|_)PROXY(?:_|$)|^EPROXY/i, "proxy"],
+ [/^(?:ENOTFOUND|EAI_(?:AGAIN|FAIL|NODATA|NONAME))$|^ERR_DNS_/i, "dns"],
+ [
+ /(?:^|_)(?:CERT|TLS|SSL)(?:_|$)|^EPROTO$|^UNABLE_TO_|^HPE_|^CERT_|^DEPTH_ZERO_SELF_SIGNED_CERT$|^SELF_SIGNED_CERT_IN_CHAIN$/i,
+ "tls",
+ ],
+ [/^(?:ETIMEDOUT|ESOCKETTIMEDOUT|ETIME)$|TIMEOUT$/i, "timeout"],
+ [/^ECONNREFUSED$/i, "refused"],
+ [
+ /^(?:ENETUNREACH|EHOSTUNREACH|ENETDOWN|EHOSTDOWN|EADDRNOTAVAIL|ENONET)$/i,
+ "unreachable",
+ ],
+ [
+ /^(?:ECONNRESET|ECONNABORTED|EPIPE|ERR_STREAM_PREMATURE_CLOSE|UND_ERR_SOCKET|UND_ERR_CLOSED)$/i,
+ "reset",
+ ],
+];
+
+function networkCategoryForCode(
+ code: string,
+): NetworkFailureCategory | undefined {
+ for (const [pattern, category] of NETWORK_CATEGORY_PATTERNS) {
+ if (pattern.test(code)) return category;
+ }
+ return undefined;
+}
+
+/**
+ * Pick the errno worth reporting and its category. A code naming the proxy wins
+ * wherever it sits in the chain, because that is the layer that actually failed
+ * — undici reports the proxy's own socket errno as a deeper cause.
+ */
+function pickNetworkCode(codes: readonly string[]): {
+ code?: string;
+ category?: NetworkFailureCategory;
+} {
+ for (const candidate of codes) {
+ if (networkCategoryForCode(candidate) === "proxy") {
+ return { code: candidate, category: "proxy" };
+ }
+ }
+ for (const candidate of codes) {
+ const category = networkCategoryForCode(candidate);
+ if (category !== undefined) return { code: candidate, category };
+ }
+ return { code: codes.find((candidate) => NETWORK_PATTERN.test(candidate)) };
+}
+
+/**
+ * Last resort when no errno survived: classify by wording. Order matters —
+ * "proxy" outranks the transport wording it usually wraps.
+ */
+function networkCategoryFromText(text: string): NetworkFailureCategory {
+ if (/proxy|tunnel/i.test(text)) return "proxy";
+ if (/getaddrinfo|ENOTFOUND|EAI_AGAIN|dns|name resolution/i.test(text)) {
+ return "dns";
+ }
+ if (
+ /certificate|self[- ]signed|(?:^|[^a-z])(?:tls|ssl)(?:[^a-z]|$)|EPROTO|handshake/i.test(
+ text,
+ )
+ ) {
+ return "tls";
+ }
+ if (/timed?\s?out|timeout/i.test(text)) return "timeout";
+ if (/ECONNREFUSED|connection refused/i.test(text)) return "refused";
+ if (/ENETUNREACH|EHOSTUNREACH|no route to host|unreachable/i.test(text)) {
+ return "unreachable";
+ }
+ if (
+ /ECONNRESET|ECONNABORTED|EPIPE|socket hang up|premature close|reset by peer|connection (?:was )?(?:closed|lost|reset)/i.test(
+ text,
+ )
+ ) {
+ return "reset";
+ }
+ return "unknown";
+}
+
+/** Every field is validated against a strict shape; no raw text is retained. */
+function networkDetailFields(network: NetworkFailure): Record {
+ return {
+ networkCategory: network.category,
+ ...(network.code ? { networkCode: network.code } : {}),
+ ...(network.syscall ? { networkSyscall: network.syscall } : {}),
+ ...(network.hostname ? { networkHost: network.hostname } : {}),
+ };
+}
+
+/**
+ * Summarize the transport failure behind a network error for the log record and
+ * the error details. The cause chain is where node/undici keep the real errno
+ * (`fetch failed` alone names nothing), including undici's happy-eyeballs
+ * `AggregateError.errors`; pi-ai also flattens causes into `errorMessage`, so a
+ * bare "getaddrinfo ENOTFOUND host" string has to yield the same fields.
+ */
+export function describeNetworkFailure(
+ err: unknown,
+ message: string,
+): NetworkFailure {
+ const codes: string[] = [];
+ const seen = new Set();
+ let syscall: string | undefined;
+ let hostname: string | undefined;
+
+ const visit = (node: unknown, depth: number): void => {
+ if (depth > 5 || seen.size > 12) return;
+ if (!node || typeof node !== "object" || seen.has(node)) return;
+ seen.add(node);
+ const record = node as Record;
+ if (
+ typeof record.code === "string" &&
+ SAFE_NETWORK_CODE_PATTERN.test(record.code)
+ ) {
+ codes.push(record.code);
+ }
+ if (
+ syscall === undefined &&
+ typeof record.syscall === "string" &&
+ SAFE_NETWORK_SYSCALL_PATTERN.test(record.syscall)
+ ) {
+ syscall = record.syscall;
+ }
+ if (hostname === undefined) {
+ for (const key of ["hostname", "host"]) {
+ const value = record[key];
+ if (typeof value === "string" && SAFE_HOSTNAME_PATTERN.test(value)) {
+ hostname = value;
+ break;
+ }
+ }
+ }
+ visit(record.cause, depth + 1);
+ if (Array.isArray(record.errors)) {
+ for (const child of record.errors.slice(0, 4)) visit(child, depth + 1);
+ }
+ };
+ visit(err, 0);
+
+ // The message is untrusted provider text, so a candidate must look like an
+ // errno (bounded, errno-shaped) before it can be reported; the object chain
+ // above is probed first and its codes are kept.
+ for (const match of message.matchAll(
+ /\b(?:E[A-Z]{3,}|UND_ERR_[A-Z_]+|ERR_[A-Z0-9_]+|HPE_[A-Z_]+)\b/g,
+ )) {
+ if (codes.length >= 16) break;
+ if (SAFE_NETWORK_CODE_PATTERN.test(match[0])) codes.push(match[0]);
+ }
+ if (hostname === undefined) {
+ const dnsHost = message.match(
+ /(?:ENOTFOUND|EAI_AGAIN|EAI_NONAME|EAI_FAIL)\s+([A-Za-z0-9][A-Za-z0-9.-]{0,252})/,
+ );
+ if (dnsHost && SAFE_MESSAGE_HOSTNAME_PATTERN.test(dnsHost[1])) {
+ hostname = dnsHost[1];
+ }
+ }
+
+ const picked = pickNetworkCode(codes);
+ return {
+ category: picked.category ?? networkCategoryFromText(message),
+ ...(picked.code ? { code: picked.code } : {}),
+ ...(syscall ? { syscall } : {}),
+ ...(hostname ? { hostname } : {}),
+ };
+}
+
export function classifyAgentError(err: unknown): ClassifiedAgentError {
const rawMessage =
typeof err === "string"
@@ -112,16 +329,33 @@ export function classifyAgentError(err: unknown): ClassifiedAgentError {
: safeMessage;
const status = extractStatus(err, rawMessage);
const providerCode = extractErrorCode(err);
- const details = {
+ const details: Record = {
...(status !== undefined ? { providerStatus: status } : {}),
...(providerCode !== undefined ? { providerCode } : {}),
};
- const result = (code: string, retriable: boolean): ClassifiedAgentError => ({
- code,
- message,
- retriable,
- ...(Object.keys(details).length > 0 ? { details } : {}),
- });
+ const result = (
+ code: string,
+ retriable: boolean,
+ extra?: Record,
+ ): ClassifiedAgentError => {
+ const merged = { ...details, ...extra };
+ // `extractErrorCode` walks the cause chain, so a network failure usually
+ // repeats its own transport errno as `providerCode`. Keep the
+ // network-namespaced key and drop the duplicate instead of logging one
+ // string twice.
+ if (
+ merged.networkCode !== undefined &&
+ merged.networkCode === merged.providerCode
+ ) {
+ delete merged.providerCode;
+ }
+ return {
+ code,
+ message,
+ retriable,
+ ...(Object.keys(merged).length > 0 ? { details: merged } : {}),
+ };
+ };
// An abort wins over every other classification: a user Stop that lands
// while a checkpoint is being summarized fails the compaction with an abort
@@ -136,9 +370,15 @@ export function classifyAgentError(err: unknown): ClassifiedAgentError {
return result("CONTEXT_COMPACTION_FAILED", false);
}
// Network failures never carry an HTTP status; probe before status logic so
- // "fetch failed" causes don't fall through to the generic bucket.
+ // "fetch failed" causes don't fall through to the generic bucket. The cause
+ // chain is summarized as a coarse category plus the transport errno, so the
+ // failing layer is identifiable without a user-visible code per layer.
if (hasNetworkCause(err, rawMessage)) {
- return result("NETWORK_ERROR", true);
+ return result(
+ "NETWORK_ERROR",
+ true,
+ networkDetailFields(describeNetworkFailure(err, rawMessage)),
+ );
}
if (status !== undefined) {
diff --git a/packages/agent-runtime/src/provider-retry.test.ts b/packages/agent-runtime/src/provider-retry.test.ts
index 24e1ec8028..4f6dd0b918 100644
--- a/packages/agent-runtime/src/provider-retry.test.ts
+++ b/packages/agent-runtime/src/provider-retry.test.ts
@@ -251,6 +251,54 @@ describe("provider rate-limit retry", () => {
expect(snapshot).toBeUndefined();
});
+ it("reports the request body size even when the request dies first", async () => {
+ const seen: Array<[number | undefined, number | undefined]> = [];
+ const body = JSON.stringify({ model: "gpt-5.6-sol", input: "hello" });
+ const wrapped = captureProviderResponse(
+ async () => {
+ throw new Error("fetch failed");
+ },
+ (response, requestBytes) => {
+ seen.push([response?.status, requestBytes]);
+ },
+ );
+
+ await expect(
+ wrapped("https://provider.invalid", { method: "POST", body }),
+ ).rejects.toThrow("fetch failed");
+
+ // The clearing call, then the failure: only the size survives, never the
+ // body content, and a request that never got headers still reports it.
+ expect(seen).toEqual([
+ [undefined, undefined],
+ [undefined, Buffer.byteLength(body, "utf8")],
+ ]);
+ });
+
+ it("reports no request size when the body cannot be measured unread", async () => {
+ const sizes: Array = [];
+ const wrapped = captureProviderResponse(
+ async () => new Response("ok", { status: 200 }),
+ (_response, requestBytes) => {
+ sizes.push(requestBytes);
+ },
+ );
+
+ // A body the transport does not expose as a string/buffer/blob reports no
+ // size instead of throwing or being read.
+ await wrapped("https://provider.invalid", {
+ method: "POST",
+ body: new FormData(),
+ });
+ expect(sizes.at(-1)).toBeUndefined();
+
+ await wrapped("https://provider.invalid", {
+ method: "POST",
+ body: new Uint8Array([1, 2, 3, 4]),
+ });
+ expect(sizes.at(-1)).toBe(4);
+ });
+
it("rejects an abortable retry delay without waiting for the timer", async () => {
vi.useFakeTimers();
const controller = new AbortController();
diff --git a/packages/agent-runtime/src/provider-retry.ts b/packages/agent-runtime/src/provider-retry.ts
index be563a6d57..69da128dc8 100644
--- a/packages/agent-runtime/src/provider-retry.ts
+++ b/packages/agent-runtime/src/provider-retry.ts
@@ -304,24 +304,48 @@ export function delayWithAbort(
});
}
+/**
+ * Serialized request body size, without ever inspecting the body: only the byte
+ * length is retained, never the content. Request size is the one correlation
+ * signal from the reporter of issue #234 that is safe to keep on every attempt.
+ */
+function requestBodyBytes(body: unknown): number | undefined {
+ if (typeof body === "string") return Buffer.byteLength(body, "utf8");
+ if (body instanceof ArrayBuffer) return body.byteLength;
+ if (ArrayBuffer.isView(body)) return body.byteLength;
+ if (typeof Blob !== "undefined" && body instanceof Blob) return body.size;
+ return undefined;
+}
+
/** Capture HTTP status/headers, including failed 429 responses that pi-ai's
- * onResponse callback intentionally does not expose. */
+ * onResponse callback intentionally does not expose. The second argument is the
+ * outgoing request size, reported even when the request dies before headers —
+ * exactly the case worth correlating with a network failure (issue #234). */
export function captureProviderResponse(
fetchFn: FetchFunction | undefined,
- onResponse: (response?: ProviderResponseSnapshot) => void,
+ onResponse: (
+ response?: ProviderResponseSnapshot,
+ requestBytes?: number,
+ ) => void,
): FetchFunction {
const baseFetch = fetchFn ?? globalThis.fetch;
return async (input, init) => {
// Clear the previous response before a new fetch. If this request fails
// before receiving headers, a prior 429 must not classify the new failure.
onResponse();
- const response = await baseFetch(input, init);
- const headers: Record = {};
- response.headers.forEach((value, key) => {
- headers[key.toLowerCase()] = value;
- });
- onResponse({ status: response.status, headers });
- return response;
+ const requestBytes = requestBodyBytes(init?.body);
+ try {
+ const response = await baseFetch(input, init);
+ const headers: Record = {};
+ response.headers.forEach((value, key) => {
+ headers[key.toLowerCase()] = value;
+ });
+ onResponse({ status: response.status, headers }, requestBytes);
+ return response;
+ } catch (error) {
+ onResponse(undefined, requestBytes);
+ throw error;
+ }
};
}
diff --git a/packages/agent-runtime/src/runtime.test.ts b/packages/agent-runtime/src/runtime.test.ts
index 8bb7c14bf3..533f89d777 100644
--- a/packages/agent-runtime/src/runtime.test.ts
+++ b/packages/agent-runtime/src/runtime.test.ts
@@ -1327,6 +1327,113 @@ describe("DesktopAgentRuntime live activity", () => {
await runtime.dispose();
});
+ it("surfaces the transport errno while a network failure retries", async () => {
+ const onEvent = vi.fn();
+ const runtime = createRuntime({ onEvent });
+ const classified = classifyAgentError(
+ Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(
+ new Error("getaddrinfo ENOTFOUND api.example.com"),
+ {
+ code: "ENOTFOUND",
+ syscall: "getaddrinfo",
+ hostname: "api.example.com",
+ },
+ ),
+ }),
+ );
+ const activityError = (runtime as any).retryActivityError(classified);
+
+ // The retry popover renders exactly this object, so the user learns which
+ // transport layer is failing instead of only that the provider is
+ // unreachable.
+ expect(activityError).toEqual({
+ code: "NETWORK_ERROR",
+ message: "fetch failed",
+ networkCode: "ENOTFOUND",
+ });
+
+ (runtime as any).setAgentActivity({
+ phase: "retrying",
+ since: 100,
+ attempt: 2,
+ retryDelayMs: 2_000,
+ error: activityError,
+ });
+ const statusEvent = onEvent.mock.calls
+ .map(([envelope]) => (envelope as any).event)
+ .find((event) => event.type === "status");
+ expect(statusEvent?.status.activity).toMatchObject({
+ phase: "retrying",
+ error: { networkCode: "ENOTFOUND" },
+ });
+
+ await runtime.dispose();
+ });
+
+ it("stamps request size, message count and compaction generation on provider diagnostics", async () => {
+ const runtime = createRuntime({ onEvent: vi.fn() });
+ (runtime as any).providerRequestMessages = 42;
+ (runtime as any).providerRequestBytes = 180_000;
+ (runtime as any).activeCompaction = { details: { generation: 3 } };
+
+ expect(
+ (runtime as any).providerErrorWithDiagnostics(
+ {
+ code: "NETWORK_ERROR",
+ message: "fetch failed",
+ retriable: true,
+ details: { networkCategory: "dns", networkCode: "ENOTFOUND" },
+ },
+ "stream",
+ 1_500,
+ 2,
+ ).details,
+ ).toEqual({
+ networkCategory: "dns",
+ networkCode: "ENOTFOUND",
+ phase: "stream",
+ requestMessages: 42,
+ requestBytes: 180_000,
+ compactionGeneration: 3,
+ providerWaitMs: 1_500,
+ streamMs: 2,
+ });
+
+ await runtime.dispose();
+ });
+
+ it("keeps the network diagnosis on the emitted assistant error", async () => {
+ const onEvent = vi.fn();
+ const runtime = createRuntime({ onEvent });
+ const error = classifyAgentError(
+ Object.assign(new TypeError("fetch failed"), {
+ cause: Object.assign(new Error("connect ECONNRESET"), {
+ code: "ECONNRESET",
+ }),
+ }),
+ );
+
+ (runtime as any).finalizeCurrentAssistant("error", error);
+
+ // The event envelope is what the logger persists as `agent/session.log`
+ // and what the renderer shows, so this is the surface the diagnosis has to
+ // survive on (ADR 0212).
+ const events = onEvent.mock.calls.map(([envelope]) => envelope as any);
+ expect(events.at(-1)?.event).toMatchObject({
+ type: "message_end",
+ message: {
+ role: "assistant",
+ error: {
+ code: "NETWORK_ERROR",
+ details: { networkCategory: "reset", networkCode: "ECONNRESET" },
+ },
+ },
+ });
+
+ await runtime.dispose();
+ });
+
it("emits status phases for quiet provider and delegation intervals", async () => {
const onEvent = vi.fn();
const runtime = createRuntime({ onEvent });
diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts
index 6312867236..e5c9f0efad 100644
--- a/packages/agent-runtime/src/runtime.ts
+++ b/packages/agent-runtime/src/runtime.ts
@@ -1459,6 +1459,13 @@ export class DesktopAgentRuntime {
private delegationWaitTargets?: DelegationRecord[];
private providerResponseStatus?: number;
private providerRetryHeaders?: Record;
+ /**
+ * Size and message count of the provider attempt in flight. A failed request
+ * has to be correlatable with how much context it carried, and on a network
+ * failure the request never returns a response to read it from (issue #234).
+ */
+ private providerRequestBytes?: number;
+ private providerRequestMessages?: number;
private pendingProviderRetry?: ReturnType;
/**
* Shared bounded retry count for non-rate-limit transient failures, counted
@@ -1653,6 +1660,8 @@ Delegation rules:
this.setAgentActivity({ phase: "waiting-model", since: Date.now() });
this.providerResponseStatus = undefined;
this.providerRetryHeaders = undefined;
+ this.providerRequestBytes = undefined;
+ this.providerRequestMessages = context.messages?.length;
const requestOptions: SimpleStreamOptions = withProviderHeaders(
withOpenCodeSessionHeaders(
{
@@ -1661,8 +1670,9 @@ Delegation rules:
sessionId: this.sessionId,
// pi-ai only exposes onResponse after a request succeeds. Capture the
// failed response separately so a 429 can honor Retry-After headers.
- fetch: captureProviderResponse(options?.fetch, (response) => {
+ fetch: captureProviderResponse(options?.fetch, (response, requestBytes) => {
this.providerResponseStatus = response?.status;
+ this.providerRequestBytes = requestBytes;
// A gateway 502/503 can also state Retry-After, so keep headers for
// every status whose delay is usable instead of only for 429.
this.providerRetryHeaders = carriesRetryDelayHeaders(
@@ -4730,6 +4740,9 @@ Delegation rules:
const detailStatus = isRecord(error.details)
? error.details.providerStatus
: undefined;
+ const detailNetworkCode = isRecord(error.details)
+ ? error.details.networkCode
+ : undefined;
const providerStatus =
typeof detailStatus === "number"
? detailStatus
@@ -4738,6 +4751,12 @@ Delegation rules:
code: error.code,
message: error.message,
...(typeof providerStatus === "number" ? { providerStatus } : {}),
+ // While the turn is still retrying, the transport errno is the only thing
+ // that tells a DNS failure from a TLS failure from a dropped socket; the
+ // localized summary cannot (issue #234).
+ ...(typeof detailNetworkCode === "string"
+ ? { networkCode: detailNetworkCode }
+ : {}),
};
}
@@ -4797,6 +4816,23 @@ Delegation rules:
details: {
...existingDetails,
phase,
+ // Correlation for a failure that produced no response to inspect: how
+ // much context and how many bytes the attempt carried, and which
+ // compaction generation the session was on (issue #234). Counts, flags
+ // and sizes only — never message content.
+ ...(this.providerRequestMessages !== undefined
+ ? { requestMessages: this.providerRequestMessages }
+ : {}),
+ ...(this.providerRequestBytes !== undefined
+ ? { requestBytes: this.providerRequestBytes }
+ : {}),
+ ...(this.activeCompaction
+ ? {
+ compactionGeneration: checkpointGeneration(
+ this.activeCompaction.details,
+ ),
+ }
+ : {}),
...(providerWaitMs !== undefined ? { providerWaitMs } : {}),
...(streamMs !== undefined ? { streamMs } : {}),
...(this.providerResponseStatus !== undefined &&
diff --git a/packages/shared/src/types/sessions.ts b/packages/shared/src/types/sessions.ts
index 8a1184b1eb..f793effd8b 100644
--- a/packages/shared/src/types/sessions.ts
+++ b/packages/shared/src/types/sessions.ts
@@ -134,6 +134,8 @@ export type AgentActivityError = {
code: string;
message: string;
providerStatus?: number;
+ /** Transport errno behind a NETWORK_ERROR, e.g. ENOTFOUND or ECONNRESET. */
+ networkCode?: string;
};
/** Coarse child-agent action shown while the parent waits on delegates. */