fix(core,web): retry transport and quota LLM errors, disable dead sessions on auth failure (#82)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-07-27 22:29:16 +08:00
1 parent c369e089a7
commit 3d3495528c
54 files changed
+2278 -161

No files matched your search

+2 -2
View File
@@ -26,7 +26,7 @@ session.run(newMessages, { approve, signal })
│ │ output streams back │
│ └─ LLMOutcome: │
│ timeout / malformed ──► reconnect within the turn │
│ (≤2, with [turn_retried]; tools not rerun) │
│ (≤5, with [turn_retried]; tools not rerun) │
│ token_usage + request_end (at LLM-stream end; not waiting │
│ for tools) │
│ │
@@ -91,7 +91,7 @@ While a Task is running, the host can queue a user message with `session.steer(t
## Automatic reconnect
Only LLM-side `timeout` (network timeouts, rate limits, 5xx) and `malformed` (truncated streams, JSON parse failures) trigger an in-run reconnect: the engine re-sends the original input plus a `[turn_retried]` block carrying the previous partial output, so tools are never re-executed. Default limit is 2 reconnects with linear backoff (base 250ms); beyond that the turn settles as `failed`. Tool errors are never retried — they are fed back to the model as `tool_call_output` and the model decides what to do next.
Only LLM-side `timeout` (network timeouts, transport disconnects such as a dropped socket, rate limits, 5xx, and transient provider quota/subscription errors like `insufficient_user_quota`) and `malformed` (truncated streams, JSON parse failures) trigger an in-run reconnect: the engine re-sends the original input plus a `[turn_retried]` block carrying the previous partial output, so tools are never re-executed. Default limit is 5 reconnects with exponential backoff under a ceiling (base 250ms, cap 30s: 250ms, 500ms, 1s, 2s, 4s ≈ 7.75s of total patience — one shared schedule growing toward the slower retryable classes, with the first steps as fast as a transport blip needs); beyond that the turn settles as `failed`. Each failure's `request_end` announces the planned wait as `retry_in_ms` (same formula as the sleep), which the Web App renders as a live countdown with "retry now" (skips the remaining wait via `Session.skipReconnectWait` — the attempt counter is unchanged) and "give up" (the ordinary abort; the engine's abort-during-backoff path ends the turn) controls. Compaction requests use their own tighter cap (3 retries): a failed compaction keeps the original context and retries on the next trigger, so failing fast beats stalling the session. Authentication errors are classified before any retry heuristic and never retry: the request ends with its own terminal status `auth` (only the model reference is fixed at Session creation — credentials are read from the current Project config when the Session loads), and the Web App disables that Session's composer until the model's credential is updated (which auto-unlocks it) or the notice is dismissed for a retry. Tool errors are never retried — they are fed back to the model as `tool_call_output` and the model decides what to do next.
## Compaction
+2 -2
View File
@@ -24,7 +24,7 @@ session.run(newMessages, { approve, signal })
│ │ ▼ │
│ │ Environment.executeTool ──► 并发执行,输出流式回传 │
│ └─ LLMOutcome: │
│ timeout / malformed ──► 同轮自动重连(≤2 次, │
│ timeout / malformed ──► 同轮自动重连(≤5 次, │
│ 附 [turn_retried],工具不重跑) │
│ token_usage + request_end(LLM 流结束即产出,不等工具) │
│ │
@@ -88,7 +88,7 @@ Task 运行期间,宿主可通过 `session.steer(text)` 排队一条用户消
## 自动重连
只有 LLM 侧的 `timeout`(网络超时、限流、5xx)与 `malformed`(流截断、JSON 解析失败)会触发引擎内自动重连:同一次 `run` 内重发原始输入,并附加 `[turn_retried]` 块携带上一次的部分输出,避免工具重复执行。默认最多重连 2 次,线性退避(基数 250ms);超限后该轮以 `failed` 收场。工具错误从不重试——它们作为 `tool_call_output` 反馈给模型,由模型决定下一步。
只有 LLM 侧的 `timeout`(网络超时、传输层断连如 socket 被对端关闭、限流、5xx,以及 `insufficient_user_quota` 这类瞬时的供应商额度/订阅错误)与 `malformed`(流截断、JSON 解析失败)会触发引擎内自动重连:同一次 `run` 内重发原始输入,并附加 `[turn_retried]` 块携带上一次的部分输出,避免工具重复执行。默认最多重连 5 次,指数退避并设上限(基数 250ms、上限 30s:250ms、500ms、1s、2s、4s,总耐心约 7.75s——所有可重试类别共用一张时间表,向较慢的类别递增,头几步仍与传输层抖动所需的一样快);超限后该轮以 `failed` 收场。每次失败的 `request_end` 会以 `retry_in_ms` 宣告计划中的等待(与实际休眠同一公式),Web App 据此实时倒计时,并提供「立即重试」(经 `Session.skipReconnectWait` 跳过剩余等待——重试计数不变)与「放弃」(普通中断;引擎的退避中中断路径结束本轮)两个内联按钮。压缩请求使用独立的更小上限(重试 3 次):压缩失败本就有优雅语义——保留原上下文、等下一次触发再试——快速失败好过让会话干等。鉴权错误在任何重试启发式之前判定、从不重试:请求以专属终态 `auth` 收场(Session 锁定的只是模型引用,凭据在会话装载时取自当前 Project 配置),Web App 据此禁用该 Session 的输入框,直到该模型的凭据被更新(更新后自动解锁)或用户点击「重试」。工具错误从不重试——它们作为 `tool_call_output` 反馈给模型,由模型决定下一步。
## 上下文压缩(Compaction)
+1 -1
View File
@@ -129,7 +129,7 @@ The Server keeps an additional SQLite index (users, authorization, usage stats)
## Key design decisions
- **One protocol, three jobs**: OmniMessage is simultaneously the SDK's external interface, the Trace on-disk format and the engine's internal currency — what streams, what is stored and what the model sees are the same thing.
- **Errors converge into messages**: the LLM and Environment never throw into the engine; results carry a five-value `stop_reason` (`completed | failed | aborted | timeout | malformed`), and only LLM-side `timeout / malformed` trigger an in-run reconnect.
- **Errors converge into messages**: the LLM and Environment never throw into the engine; results carry a six-value `stop_reason` (`completed | failed | aborted | timeout | malformed | auth`), and only LLM-side `timeout / malformed` trigger an in-run reconnect (up to 5 times with an exponential-with-ceiling backoff — `timeout` covers network timeouts, transport disconnects and transient provider quota errors; `auth` stops like `failed`).
- **A thin model layer**: core defines only `LLMInterface`; provider adaptation lives entirely in AgentHub (`@prismshadow/agenthub`), which is what makes any OpenAI-compatible endpoint reachable. See [Models & Providers](/models).
Source entry points: `packages/core/src/engine/context-engine.ts`, `packages/core/src/interfaces.ts`.
+1 -1
View File
@@ -129,7 +129,7 @@ Server 额外维护一个 SQLite 索引库(用户、授权、用量统计),但
## 关键设计决策
- **一个协议,三种职责**:OmniMessage 同时是 SDK 对外接口、Trace 落盘格式与引擎内部通货——「流出去的」「存下来的」「模型看到的」是同一种东西。
- **错误收敛为消息**:LLM 与 Environment 从不向引擎抛异常;结果携带五值 `stop_reason`(`completed | failed | aborted | timeout | malformed`),仅 LLM 侧的 `timeout / malformed` 触发引擎内重连。
- **错误收敛为消息**:LLM 与 Environment 从不向引擎抛异常;结果携带六值 `stop_reason`(`completed | failed | aborted | timeout | malformed | auth`),仅 LLM 侧的 `timeout / malformed` 触发引擎内重连(至多 5 次、指数退避设上限——`timeout` 涵盖网络超时、传输层断连与瞬时的供应商额度错误;`auth` 与 `failed` 同样直接停止)。
- **薄模型层**:core 只定义 `LLMInterface`,Provider 适配全部下沉到 AgentHub(`@prismshadow/agenthub`),因此支持任意 OpenAI 兼容端点,见[模型与 Provider](/models)。
源码入口:`packages/core/src/engine/context-engine.ts`、`packages/core/src/interfaces.ts`。
+7 -4
View File
@@ -50,18 +50,21 @@ The generator yields `partial_*` fragments and complete messages, emits Token us
```ts
interface LLMOutcome {
status: StopReason; // completed | timeout | malformed | aborted | failed
message?: string; // display text when failed
status: StopReason; // completed | timeout | malformed | aborted | failed | auth
message?: string; // failure detail: on failed/auth, and on timeout/malformed when a
// concrete error was caught — carried onto request_end so the
// errors panel shows the real reason behind a retried request
}
```
| status | Meaning | Engine reaction |
| --- | --- | --- |
| `completed` | finished normally (token_usage already emitted) | proceed |
| `timeout` | timeout / lost connection | auto-reconnect within the run |
| `timeout` | timeout / transport disconnect / transient provider quota error | auto-reconnect within the run |
| `malformed` | response parse failure | auto-reconnect within the run |
| `aborted` | user interrupt | stop, hand back to the user |
| `failed` | non-retryable (auth/params, …) | stop, hand back to the user |
| `failed` | non-retryable (params, …) | stop, hand back to the user |
| `auth` | credentials rejected | stop like `failed`; hosts gate input until the model's API key is updated |
Implementation constraints: never throw; no internal retries — reconnecting is the engine's job (see [The Agent Loop](/agent-loop)).
+7 -4
View File
@@ -50,18 +50,21 @@ interface GenerativeModelParameters {
```ts
interface LLMOutcome {
status: StopReason; // completed | timeout | malformed | aborted | failed
message?: string; // failed 时的展示文案
status: StopReason; // completed | timeout | malformed | aborted | failed | auth
message?: string; // 失败详情:failed/auth 时携带;timeout/malformed 捕获到具体
// 错误时也携带——透传到 request_end,错误面板据此展示被重试
// 请求背后的真实原因
}
```
| status | 含义 | 引擎的反应 |
| --- | --- | --- |
| `completed` | 正常完成(已产出 token_usage) | 继续下一步 |
| `timeout` | 超时/断连 | 同一 run 内自动重连 |
| `timeout` | 超时/传输层断连/瞬时的供应商额度错误 | 同一 run 内自动重连 |
| `malformed` | 响应解析失败 | 同一 run 内自动重连 |
| `aborted` | 用户中断 | 停止交还用户 |
| `failed` | 鉴权/参数等不可重试错误 | 停止交还用户 |
| `failed` | 参数等不可重试错误 | 停止交还用户 |
| `auth` | 凭据被拒绝 | 与 `failed` 同样停止;宿主据此禁用输入,直到该模型的 API key 被更新 |
实现约束:从不抛异常;不做内部重试(重连是引擎的职责,见 [Agent 运行循环](/agent-loop))。
+1 -1
View File
@@ -102,7 +102,7 @@ Therefore **a renderer must never reconstruct the context from arrival order**
| Case | Observable order on the stream |
| --- | --- |
| User interrupt | (messages produced so far) → the `abort` event — the last message before `run` returns; carry-over goes to the model context only, never streamed, never written to Trace |
| Automatic reconnect | `request_end(timeout \| malformed)` → a fresh `request_begin`; the `[turn_retried]` block is model-visible only |
| Automatic reconnect | `request_end(timeout \| malformed)` → a fresh `request_begin` (up to 5 times, exponential backoff with a 30s ceiling; `timeout` covers transport disconnects and transient provider quota errors too); the `[turn_retried]` block is model-visible only |
| Compaction | `compaction_begin` → the compaction request runs against the old context (its streamed output is **not** forwarded, only written to Trace) → that request's `token_usage` → `compaction_end(status)` |
| max_turns reached | a length notice → the run ends; unsubmitted input is kept as carry-over |
| The Prompt itself | written to Trace, not echoed back onto the stream (the caller already has it) |
+1 -1
View File
@@ -101,7 +101,7 @@ Human ──run(newMessages)──► engine
| 情形 | 流上可见的顺序 |
| --- | --- |
| 用户中断 | (已产出的消息)→ `abort` 事件——`run` 返回前的最后一条;补发内容只进模型上下文,不上流、不进 Trace |
| 自动重连 | `request_end(timeout \| malformed)` → 新的 `request_begin`;`[turn_retried]` 块仅模型可见 |
| 自动重连 | `request_end(timeout \| malformed)` → 新的 `request_begin`(至多 5 次,指数退避、上限 30s;`timeout` 同样涵盖传输层断连与瞬时的供应商额度错误);`[turn_retried]` 块仅模型可见 |
| 上下文压缩 | `compaction_begin` → 压缩请求在旧上下文中执行(其流式输出**不上行**,只写 Trace)→ 该请求的 `token_usage` → `compaction_end(status)` |
| 达到 max_turns | 长度提示消息 → 结束;未提交的输入按补发保留 |
| Prompt 本身 | 写入 Trace,但不回流(输入方已有) |
+9 -4
View File
@@ -1,6 +1,6 @@
---
title: The OmniMessage Protocol
description: One envelope, three message types, a five-value stop_reason — the unified protocol behind the SDK, the Trace and SSE, field by field.
description: One envelope, three message types, a six-value stop_reason — the unified protocol behind the SDK, the Trace and SSE, field by field.
---
OmniMessage is PenguinHarness's unified message protocol: the SDK yields it, the Trace stores it line by line, and the Server pushes it verbatim over SSE. What streams, what is stored and what the model sees are one structure — there is no second format between front end, back end and storage.
@@ -190,6 +190,10 @@ interface RequestBeginPayload {
interface RequestEndPayload {
type: "request_end";
status: StopReason; // "completed" is the mechanical commit criterion for replay
message?: string; // failure detail (LLMOutcome.message), non-completed only:
// the real reason behind a retried/failed Request (e.g. a
// provider quota code) — read by the Cost center's errors
// panel; additive, old Traces replay unchanged
}
interface ApprovalDecisionPayload {
@@ -242,19 +246,20 @@ interface SubagentPayload {
## stop_reason
A five-value enum used across messages and interface results (`LLMOutcome.status` uses the same set — see [Core Interfaces](/interfaces)):
A six-value enum used across messages and interface results (`LLMOutcome.status` uses the same set — see [Core Interfaces](/interfaces)):
```ts
type StopReason = "completed" | "failed" | "aborted" | "timeout" | "malformed";
type StopReason = "completed" | "failed" | "aborted" | "timeout" | "malformed" | "auth";
```
| Value | Meaning | Engine reaction |
| --- | --- | --- |
| `completed` | finished normally | continue |
| `aborted` | user interrupt | stop, hand back to the user |
| `timeout` | LLM timeout / lost connection | LLM side only: auto-reconnect within the run |
| `timeout` | LLM timeout / transport disconnect / transient provider quota error | LLM side only: auto-reconnect within the run |
| `malformed` | parse failure / truncated stream | LLM side only: auto-reconnect within the run |
| `failed` | other non-retryable error | stop, hand back to the user |
| `auth` | the provider rejected the credentials | stop like `failed`; hosts gate input until the model's API key is updated (credentials come from the current Project config) |
Errors never cross an interface boundary as exceptions — they *are* messages. See [The Agent Loop](/agent-loop).
+8 -4
View File
@@ -1,6 +1,6 @@
---
title: OmniMessage 协议
description: 一个信封、三类消息、五值 stop_reason——贯穿 SDK、Trace 与 SSE 的统一消息协议,逐字段定义。
description: 一个信封、三类消息、六值 stop_reason——贯穿 SDK、Trace 与 SSE 的统一消息协议,逐字段定义。
---
OmniMessage 是 PenguinHarness 的统一消息协议:SDK 对外产出它,Trace 逐行存储它,Server 经 SSE 原样推送它。「流出去的」「存下来的」「模型看到的」是同一种结构,前后端与存储之间不存在第二套格式。
@@ -189,6 +189,9 @@ interface RequestBeginPayload {
interface RequestEndPayload {
type: "request_end";
status: StopReason; // completed 是回放判定「该轮已提交」的机械标准
message?: string; // 失败详情(LLMOutcome.message),仅非 completed 携带:
// 被重试/失败的 Request 背后的真实原因(如供应商额度码),
// 供成本中心错误面板读取;增量字段,旧 Trace 回放不受影响
}
interface ApprovalDecisionPayload {
@@ -241,19 +244,20 @@ interface SubagentPayload {
## stop_reason
五值枚举,贯穿消息与接口返回(`LLMOutcome.status` 使用同一集合,见[接口契约](/interfaces)):
六值枚举,贯穿消息与接口返回(`LLMOutcome.status` 使用同一集合,见[接口契约](/interfaces)):
```ts
type StopReason = "completed" | "failed" | "aborted" | "timeout" | "malformed";
type StopReason = "completed" | "failed" | "aborted" | "timeout" | "malformed" | "auth";
```
| 值 | 语义 | 引擎的反应 |
| --- | --- | --- |
| `completed` | 正常完成 | 继续 |
| `aborted` | 用户中断 | 停止并交还用户 |
| `timeout` | LLM 超时/断连 | 仅 LLM 侧:同一 run 内自动重连 |
| `timeout` | LLM 超时/传输层断连/瞬时的供应商额度错误 | 仅 LLM 侧:同一 run 内自动重连 |
| `malformed` | 响应解析失败/流截断 | 仅 LLM 侧:同一 run 内自动重连 |
| `failed` | 其他不可重试错误 | 停止并交还用户 |
| `auth` | 供应商拒绝了凭据 | 与 `failed` 同样停止;宿主据此禁用输入,直到该模型的 API key 被更新(凭据取自当前 Project 配置) |
错误从不以异常形式穿过接口边界——它们就是消息,见 [Agent 运行循环](/agent-loop)。
+5
View File
@@ -89,6 +89,8 @@ Member writes are owner-only.
Every endpoint that names a model takes the complete `(provider, modelId)` pair. Nothing is inferred: a request carrying only one half is a 400, never a lookup. Where the reference itself is optional (Session creation, Schedules), omitting both halves selects the Project's default model.
`PUT /models` also invalidates the Project's cached Session runtimes (same effective-value semantics as a vault update): no hot swap into a run already in flight, but the next Task on any Session of the Project re-resumes and reads the new `api_key` / `base_url`. It additionally publishes a `credentials_updated` event to the Project's open Session channels (see Streaming below), and the models response carries `updatedAt` (the config file's mtime) — the Web App compares it against the last auth failure to decide whether an auth-dead composer should stay disabled.
### Agents
The paths below omit the `/api/projects/:projectId` prefix.
@@ -148,6 +150,7 @@ The paths below omit the `/api/sessions/:sessionId` prefix. For the storage mode
| POST | /steer | Mid-run steering: `{text}` queues a message for the running Task (delivered between turns as a standalone `[user_steering]` user message) → 202; 409 `not_running` when no Task is in progress |
| POST | /approvals/:toolCallId | Approval decision: `{decision}` is `allow` or `deny` → 204 |
| POST | /abort | Interrupt the current Task: 202 when triggered, 204 when idle |
| POST | /retry-now | "Retry now" on the reconnect countdown: skips the in-progress backoff wait, firing the next retry immediately (attempt counter unchanged) → 200 `{skipped}` — `skipped:false` is the benign "no wait in progress" case, never an error |
| POST | /compact | Trigger context compaction: 202; 409 `nothing_to_compact` when there is nothing to compact |
| GET | /files?path= | Browse the Workspace directory |
| GET | /files/content?path=&download=&preview= | Read a Workspace file (`download=1` serves it as an attachment, `preview=1` renders it in a sandbox — see below) |
@@ -230,6 +233,7 @@ export type ServerEvent =
| { type: "task_state"; state: "idle" | "running" | "compacting" }
| { type: "session_title"; sessionId: string; title: string }
| { type: "resync_required" }
| { type: "credentials_updated" }
| { type: "hello" }
| { type: "session_created"; projectId: string; agentId: string; sessionId: string; source: SessionSource }
| { type: "schedule_fired"; projectId: string; agentId: string; name: string; sessionId: string }
@@ -242,6 +246,7 @@ export type ServerEvent =
| task_state | The Session's run state flips (idle / running / compacting) |
| session_title | The model-generated title after the first turn has been persisted |
| resync_required | The Last-Event-ID was evicted from the buffer; the client must refetch history |
| credentials_updated | The Project's model credentials changed (`PUT /models`): cached runtimes were invalidated, so the client clears any auth-dead composer state |
| hello | Handshake on the user channel |
| session_created | A new Session was registered (e.g. a subagent session) |
| schedule_fired | A scheduled task fired and was delivered |
+5
View File
@@ -89,6 +89,8 @@ curl -c cookies.txt -H "Content-Type: application/json" \
所有涉及模型的接口都要求完整的 `(provider, modelId)` 二元组,不做任何推断:只带一半的请求一律 400,绝不会退化为一次查找。模型引用本身可省略的场景(创建 Session、定时任务)省略的是整对,两半都不给即选用 Project 默认模型。
`PUT /models` 同时会使该 Project 已缓存的 Session 运行时失效(与 vault 更新同一套生效语义):进行中的运行不做热替换,但该 Project 下任何 Session 的下一个 Task 都会重新装载并读到新的 `api_key` / `base_url`。它还会向该 Project 已打开的 Session 通道发布 `credentials_updated` 事件(见下文「流式推送」),且模型响应携带 `updatedAt`(配置文件 mtime)——Web App 用它与最近一次鉴权失败的时间比较,决定鉴权失败的输入框是否继续禁用。
### Agent
以下路径均省略前缀 `/api/projects/:projectId`。
@@ -148,6 +150,7 @@ Schedule 写操作仅限 Owner。新建 Session 模式的任务,`modelId` 与
| POST | /steer | 运行中插话:`{text}` 为运行中的 Task 排队一条消息(作为独立的 `[user_steering]` 用户消息随下一轮送达)→ 202;无 Task 运行返回 409 `not_running` |
| POST | /approvals/:toolCallId | 审批决定:`{decision}` 取 `allow` 或 `deny` → 204 |
| POST | /abort | 中断当前 Task:已触发返回 202,无任务返回 204 |
| POST | /retry-now | 重连倒计时上的「立即重试」:跳过进行中的退避等待、立刻发起下一次重试(重试计数不变)→ 200 `{skipped}`——`skipped:false` 表示当前没有等待可跳过(良性空操作,非错误) |
| POST | /compact | 触发上下文压缩:202;无可压缩内容返回 409 `nothing_to_compact` |
| GET | /files?path= | 浏览 Workspace 目录 |
| GET | /files/content?path=&download=&preview= | 读取 Workspace 文件(`download=1` 时作为附件下载,`preview=1` 以沙箱方式预览 —— 见下) |
@@ -229,6 +232,7 @@ export type ServerEvent =
| { type: "task_state"; state: "idle" | "running" | "compacting" }
| { type: "session_title"; sessionId: string; title: string }
| { type: "resync_required" }
| { type: "credentials_updated" }
| { type: "hello" }
| { type: "session_created"; projectId: string; agentId: string; sessionId: string; source: SessionSource }
| { type: "schedule_fired"; projectId: string; agentId: string; name: string; sessionId: string }
@@ -241,6 +245,7 @@ export type ServerEvent =
| task_state | Session 运行状态翻转(idle / running / compacting) |
| session_title | 首轮后模型生成的标题已持久化 |
| resync_required | Last-Event-ID 已被缓冲区淘汰,客户端须重新拉取历史 |
| credentials_updated | Project 模型凭据已变更(`PUT /models`):缓存运行时已失效,客户端应清除鉴权失败的输入框禁用态 |
| hello | 用户通道连接握手 |
| session_created | 新 Session 注册(如子 Agent 会话) |
| schedule_fired | 定时任务已触发并发送 |
+3 -1
View File
@@ -50,7 +50,9 @@ There are four approval modes: `allow-all`, `deny-all`, `read-only` (only read-o
- Typing `/` opens the slash menu: trigger context compaction (`/compact`) or toggle installed Skills — chosen Skills are sent along with the message in a `[use_skills]` block;
- While a Task is running the input stays live and the toolbar keeps a single action button: an empty composer shows **Stop**, and typing turns it into **Send**, whose behavior follows the **mid-run send mode** from the toolbar's More-settings popover (a compact extensible settings panel, also available in draft state; the choice is remembered): **Steer** (default) delivers the text mid-run as a `[user_steering]` user message with the next turn, **Queue** holds the whole message server-side as a follow-up and auto-sends it as an ordinary new message when the run finishes (an "N queued" hint shows near the input until then; the queue survives page reloads);
- Typing `@` mentions another Agent to hand the conversation over to it;
- When human approval is required, tool calls show inline allow/deny buttons in the message stream; the approval mode can be changed mid-Session.
- When human approval is required, tool calls show inline allow/deny buttons in the message stream; the approval mode can be changed mid-Session;
- While the engine waits out a reconnect backoff (≥2s), the retry line shows a live countdown to the next attempt with inline **Retry now** (skips the remaining wait) and **Give up** (the ordinary abort) controls;
- When the model API rejects the Session's credentials (an authentication failure), the composer grays out and disables — recoverably: the Session pins only the model reference, and credentials come from the current Project config. The notice's primary button opens the Models page; saving a new API key there unlocks the composer by itself (open tabs unlock live via a `credentials_updated` event, and after a reload the composer stays unlocked because the credential update is newer than the recorded auth failure). A "Retry" button clears the state manually for another attempt (it re-arms if the key is still bad), a completed request always clears it, and "New Session" remains as the escape to a fresh draft. The disabled composer keeps its draft selectable, so a long message that failed to send can still be copied out.
### Files Panel
+3 -1
View File
@@ -50,7 +50,9 @@ penguin web
- 输入 `/` 打开快捷菜单:触发上下文压缩(`/compact`),或勾选已安装的 Skill——所选 Skill 会以 `[use_skills]` 块随消息发送;
- Task 运行期间输入框保持可用,工具条只保留一个操作按钮:输入框为空时是**停止**,一旦输入内容即变为**发送**,其行为遵循工具条「更多设置」弹出分组中的**运行中发送方式**(一个可扩展的设置面板,草稿态同样可设,选择会被记忆):**插话**(默认)把文字以 `[user_steering]` 用户消息随下一轮送达运行中的 Agent;**排队** 把整条消息暂存在服务端,本轮结束后自动作为普通新消息发出(期间在输入框附近显示「N 条已排队」提示;队列存放在服务端,刷新页面不丢失);
- 输入 `@` 提及其他 Agent,将会话交接给它;
- 需要人工审批时,工具调用在消息流中内联显示“允许 / 拒绝”按钮;审批模式在会话中途可随时调整。
- 需要人工审批时,工具调用在消息流中内联显示“允许 / 拒绝”按钮;审批模式在会话中途可随时调整;
- 引擎在重连退避等待(≥2 秒)期间,重试提示行会实时倒计时到下一次尝试,并内联提供**立即重试**(跳过剩余等待)与**放弃**(普通中断)两个按钮;
- 模型 API 拒绝该 Session 的凭据(鉴权失败)时,输入框会置灰禁用——但可恢复:Session 锁定的只是模型引用,凭据取自当前 Project 配置。提示条的主按钮跳转到模型配置页;在那里保存新的 API key 后输入框会自动解锁(已打开的标签页经 `credentials_updated` 事件即时解锁;刷新后也保持解锁,因为凭据更新时间晚于记录的鉴权失败时间)。「重试」按钮可手动清除该状态再试一次(key 仍无效时会重新变灰),一次成功的请求总会清除该状态,「新建会话」仍作为跳到全新草稿的出口。禁用态的输入框保留草稿且可选中——发送失败的长消息仍能复制出来。
### 文件面板