Skip to content

fix(agents): detect empty provider responses as failures, improve Telegram error routing#60830

Open
garnetlyx wants to merge 2 commits intoopenclaw:mainfrom
garnetlyx:fix/empty-response-silent-failures
Open

fix(agents): detect empty provider responses as failures, improve Telegram error routing#60830
garnetlyx wants to merge 2 commits intoopenclaw:mainfrom
garnetlyx:fix/empty-response-silent-failures

Conversation

@garnetlyx
Copy link
Copy Markdown
Contributor

Summary

  • Adds EmptyResponseError typed class in model-fallback.ts; replaces the fragile string-matched new Error("Empty response content received from provider")
  • In runFallbackAttempt: treats a raw provider response with content: [] and no meta field as a failure, triggering fallback to the next candidate (benefits all channels)
  • In Telegram dispatch: tracks lastError via onError and an outer try/catch; routes the user-visible fallback message by error type (rate limit / empty response / general); re-throws aborts and timeouts so they don't produce spurious fallback replies; re-throws other dispatcher crashes after cleanup so callers still see the failure for logging/alerting
  • Adds tests for the empty-content detection (triggers fallback) and the meta field guard (accepted as valid)

Related

Follow-up work tracked in:

Test plan

  • pnpm test src/agents/model-fallback.test.ts — all tests pass including 3 new ones
  • bunx oxlint src/agents/model-fallback.ts src/telegram/bot-message-dispatch.ts — 0 errors
  • Telegram: provider returns empty content → user sees "I wasn't able to generate a response. Please try rephrasing your question."
  • Telegram: rate-limited provider → user sees rate limit message
  • Telegram: /stop or timeout → no spurious fallback reply sent

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: M labels Apr 4, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb3b7e10a6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +828 to +830
// Re-throw dispatcher crashes after fallback/cleanup so callers can log/alert
if (caughtDispatchError) {
throw caughtDispatchError;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-throw dispatch crashes before early no-response return

When dispatchReplyWithBufferedBlockDispatcher throws and caughtDispatchError is set, the function can still exit via the !hasFinalResponse early return (for example if fallback delivery also fails or only partial/non-final output was sent), so the new re-throw block is never reached. This silently swallows dispatcher crashes that previously propagated, which can hide production failures from upstream logging/alerting and make incident triage harder.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 4, 2026

Greptile Summary

This PR introduces two improvements: a typed EmptyResponseError class replacing string-matched error detection in the model fallback pipeline, and improved Telegram error routing that sends typed user-visible fallback messages (rephrasing prompt for empty responses, rate-limit message, or generic error) while correctly suppressing spurious fallback replies on abort/timeout.

Key changes:

  • EmptyResponseError class (model-fallback.ts): Clean replacement for new Error(\"Empty response content received from provider\"); exported so downstream consumers (Telegram dispatch) can use instanceof checks for typed routing.
  • runFallbackAttempt empty-content guard: A runtime check treats { content: [], ...no meta } results as failures, triggering fallback to the next candidate. The meta field is used as a heuristic to distinguish raw provider responses from EmbeddedPiRunResult wrappers — functional but not type-enforced.
  • Telegram dispatch error tracking (bot-message-dispatch.ts): Adds lastError/caughtDispatchError tracking. Abort and timeout errors are re-thrown early (before the finally block) to prevent spurious fallback replies on /stop or timeout. Other dispatch failures result in a typed fallback message, then the original error is re-thrown for callers to log/alert.
  • Tests: Three new unit tests cover the primary scenarios. One edge case (single candidate returns empty content → EmptyResponseError thrown directly) is not tested.

Confidence Score: 4/5

Safe to merge with minor observability and test-coverage gaps; no runtime correctness issues found.

The core logic is sound: EmptyResponseError propagates correctly through the fallback loop and into the Telegram dispatch routing in all scenarios. Abort/timeout re-throw prevents spurious fallback replies. The only concerns are: (1) the meta-field heuristic in runFallbackAttempt is not type-enforced and could silently break if either EmbeddedPiRunResult or raw provider response shapes change; (2) EmptyResponseError from the last/only candidate bypasses attempts.push and params.onError, reducing observability; (3) the single-candidate empty-content path is not covered by tests. None of these are correctness bugs in the current code.

src/agents/model-fallback.ts (meta-field guard and last-attempt observability); src/agents/model-fallback.test.ts (single-candidate empty-content test coverage)

Comments Outside Diff (1)

  1. src/agents/model-fallback.ts, line 550-552 (link)

    P2 Last-attempt EmptyResponseError skips attempts push and onError callback

    When EmptyResponseError is the error returned from the last (or only) candidate, isKnownFailover is false, so throw err is hit before attempts.push(...) and params.onError?.() are reached. This means:

    • The final attempt is absent from the attempts array, so throwFallbackFailureSummary is also bypassed — callers only receive the raw EmptyResponseError, not the structured "All models failed" summary that multi-attempt chains normally produce.
    • Any monitoring wired via params.onError never sees the terminal failure.

    This is the same behavior as for any other unrecognized error at the last position, but EmptyResponseError is now a deliberately generated, typed error (not an accidental exception), so missing it in the onError callback is more surprising. If observability for this case matters, consider recording the attempt before re-throwing, or registering EmptyResponseError as a known failover reason.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agents/model-fallback.ts
    Line: 550-552
    
    Comment:
    **Last-attempt `EmptyResponseError` skips `attempts` push and `onError` callback**
    
    When `EmptyResponseError` is the error returned from the last (or only) candidate, `isKnownFailover` is `false`, so `throw err` is hit before `attempts.push(...)` and `params.onError?.()` are reached. This means:
    
    - The final attempt is absent from the `attempts` array, so `throwFallbackFailureSummary` is also bypassed — callers only receive the raw `EmptyResponseError`, not the structured "All models failed" summary that multi-attempt chains normally produce.
    - Any monitoring wired via `params.onError` never sees the terminal failure.
    
    This is the same behavior as for any other unrecognized error at the last position, but `EmptyResponseError` is now a deliberately generated, typed error (not an accidental exception), so missing it in the `onError` callback is more surprising. If observability for this case matters, consider recording the attempt before re-throwing, or registering `EmptyResponseError` as a known failover reason.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/model-fallback.ts
Line: 163-175

Comment:
**Fragile `meta`-field discriminant for EmbeddedPiRunResult**

The `!("meta" in result)` guard is load-bearing for correctness: if a raw provider ever adds a `meta` field to its empty-content response (or if `EmbeddedPiRunResult` ever drops its `meta` field), the detection will silently misfire in the wrong direction. Since this heuristic is also completely invisible in the type system, future callers of `runWithModelFallback` have no way to know their result type needs to satisfy the guard.

A more robust approach would be to either:
1. Pass an explicit `isRawProviderResult` type predicate into `runFallbackAttempt`, or
2. Require callers to mark which result types should trigger the empty-content check (e.g. a wrapper type or a config flag)

The current approach is an improvement over the prior string-matched check, but it introduces a new latent coupling between `runFallbackAttempt`'s heuristic and the shape of `EmbeddedPiRunResult` that isn't enforced anywhere in the type system.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/model-fallback.ts
Line: 550-552

Comment:
**Last-attempt `EmptyResponseError` skips `attempts` push and `onError` callback**

When `EmptyResponseError` is the error returned from the last (or only) candidate, `isKnownFailover` is `false`, so `throw err` is hit before `attempts.push(...)` and `params.onError?.()` are reached. This means:

- The final attempt is absent from the `attempts` array, so `throwFallbackFailureSummary` is also bypassed — callers only receive the raw `EmptyResponseError`, not the structured "All models failed" summary that multi-attempt chains normally produce.
- Any monitoring wired via `params.onError` never sees the terminal failure.

This is the same behavior as for any other unrecognized error at the last position, but `EmptyResponseError` is now a deliberately generated, typed error (not an accidental exception), so missing it in the `onError` callback is more surprising. If observability for this case matters, consider recording the attempt before re-throwing, or registering `EmptyResponseError` as a known failover reason.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/model-fallback.test.ts
Line: 1210-1230

Comment:
**Missing test for single-candidate empty-content scenario**

The new test only covers the two-candidate case (primary returns `{ content: [] }`, fallback returns `"ok"`). The single-candidate path is meaningfully different: `isKnownFailover` is false and `i === candidates.length - 1`, so `EmptyResponseError` is thrown directly (before `attempts.push` and `onError`), bypassing `throwFallbackFailureSummary`.

It would be good to add a test asserting that when the only candidate returns `{ content: [] }`, the call rejects with an `EmptyResponseError` (not a generic "All models failed" wrapper):

```ts
it("throws EmptyResponseError when the only candidate returns empty content", async () => {
  const cfg = makeCfg({
    agents: { defaults: { model: { primary: "openai/gpt-4.1-mini" } } },
  });
  const run = vi.fn().mockResolvedValueOnce({ content: [] });
  await expect(
    runWithModelFallback({ cfg, provider: "openai", model: "gpt-4.1-mini", run }),
  ).rejects.toBeInstanceOf(EmptyResponseError);
  expect(run).toHaveBeenCalledTimes(1);
});
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(agents): detect empty provider respo..." | Re-trigger Greptile

Comment thread src/agents/model-fallback.ts Outdated
Comment on lines +163 to +175
// Check for empty raw provider response content array (treat as failure)
// Do NOT check EmbeddedPiRunResult payloads here - that should be handled upstream
// to avoid breaking valid use cases like memory flush which intentionally return empty
const result = runResult.result;
if (
result &&
typeof result === "object" &&
"content" in result &&
Array.isArray(result.content) &&
result.content.length === 0 &&
// Only apply to raw provider responses (not wrapped in EmbeddedPiRunResult)
!("meta" in result)
) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fragile meta-field discriminant for EmbeddedPiRunResult

The !("meta" in result) guard is load-bearing for correctness: if a raw provider ever adds a meta field to its empty-content response (or if EmbeddedPiRunResult ever drops its meta field), the detection will silently misfire in the wrong direction. Since this heuristic is also completely invisible in the type system, future callers of runWithModelFallback have no way to know their result type needs to satisfy the guard.

A more robust approach would be to either:

  1. Pass an explicit isRawProviderResult type predicate into runFallbackAttempt, or
  2. Require callers to mark which result types should trigger the empty-content check (e.g. a wrapper type or a config flag)

The current approach is an improvement over the prior string-matched check, but it introduces a new latent coupling between runFallbackAttempt's heuristic and the shape of EmbeddedPiRunResult that isn't enforced anywhere in the type system.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/model-fallback.ts
Line: 163-175

Comment:
**Fragile `meta`-field discriminant for EmbeddedPiRunResult**

The `!("meta" in result)` guard is load-bearing for correctness: if a raw provider ever adds a `meta` field to its empty-content response (or if `EmbeddedPiRunResult` ever drops its `meta` field), the detection will silently misfire in the wrong direction. Since this heuristic is also completely invisible in the type system, future callers of `runWithModelFallback` have no way to know their result type needs to satisfy the guard.

A more robust approach would be to either:
1. Pass an explicit `isRawProviderResult` type predicate into `runFallbackAttempt`, or
2. Require callers to mark which result types should trigger the empty-content check (e.g. a wrapper type or a config flag)

The current approach is an improvement over the prior string-matched check, but it introduces a new latent coupling between `runFallbackAttempt`'s heuristic and the shape of `EmbeddedPiRunResult` that isn't enforced anywhere in the type system.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +1210 to +1230
it("treats empty content array (no meta) as failure and falls back to next candidate", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: { primary: "openai/gpt-4.1-mini", fallbacks: ["fallback/ok-model"] },
},
},
});
const run = vi
.fn()
.mockResolvedValueOnce({ content: [] }) // primary: empty, no meta → EmptyResponseError
.mockResolvedValueOnce("ok"); // fallback succeeds
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test for single-candidate empty-content scenario

The new test only covers the two-candidate case (primary returns { content: [] }, fallback returns "ok"). The single-candidate path is meaningfully different: isKnownFailover is false and i === candidates.length - 1, so EmptyResponseError is thrown directly (before attempts.push and onError), bypassing throwFallbackFailureSummary.

It would be good to add a test asserting that when the only candidate returns { content: [] }, the call rejects with an EmptyResponseError (not a generic "All models failed" wrapper):

it("throws EmptyResponseError when the only candidate returns empty content", async () => {
  const cfg = makeCfg({
    agents: { defaults: { model: { primary: "openai/gpt-4.1-mini" } } },
  });
  const run = vi.fn().mockResolvedValueOnce({ content: [] });
  await expect(
    runWithModelFallback({ cfg, provider: "openai", model: "gpt-4.1-mini", run }),
  ).rejects.toBeInstanceOf(EmptyResponseError);
  expect(run).toHaveBeenCalledTimes(1);
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/model-fallback.test.ts
Line: 1210-1230

Comment:
**Missing test for single-candidate empty-content scenario**

The new test only covers the two-candidate case (primary returns `{ content: [] }`, fallback returns `"ok"`). The single-candidate path is meaningfully different: `isKnownFailover` is false and `i === candidates.length - 1`, so `EmptyResponseError` is thrown directly (before `attempts.push` and `onError`), bypassing `throwFallbackFailureSummary`.

It would be good to add a test asserting that when the only candidate returns `{ content: [] }`, the call rejects with an `EmptyResponseError` (not a generic "All models failed" wrapper):

```ts
it("throws EmptyResponseError when the only candidate returns empty content", async () => {
  const cfg = makeCfg({
    agents: { defaults: { model: { primary: "openai/gpt-4.1-mini" } } },
  });
  const run = vi.fn().mockResolvedValueOnce({ content: [] });
  await expect(
    runWithModelFallback({ cfg, provider: "openai", model: "gpt-4.1-mini", run }),
  ).rejects.toBeInstanceOf(EmptyResponseError);
  expect(run).toHaveBeenCalledTimes(1);
});
```

How can I resolve this? If you propose a fix, please make it concise.

…mpty-result predicate

- Re-throw caughtDispatchError in Telegram early-return path (P1)
- Treat EmptyResponseError as known failover for observability (P2)
- Replace fragile meta-field heuristic with isRawProviderResult option (P2)
- Add single-candidate empty-content test (P2)
@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 30, 2026

Codex review: needs changes before merge.

Summary
The PR adds typed empty-response fallback handling, Telegram fallback-message routing by error type, model-fallback tests, and two local ignore patterns.

Reproducibility: yes. The blocking PR issue is reproducible by source inspection: current main has no tracked src/telegram dispatcher, and the active Telegram plugin still sends generic fallback text.

Next step before merge
A focused automated repair can update the stale PR by moving the Telegram change to the active plugin path and adding the missing changelog entry.

Security
Cleared: No concrete security or supply-chain regression was found in the diff.

Review findings

  • [P2] Move Telegram error routing to the plugin dispatcher — src/telegram/bot-message-dispatch.ts:44-49
  • [P3] Add the required changelog entry — src/agents/model-fallback.ts:153-175
Review details

Best possible solution:

Adapt the contribution onto current main by using the existing classifier/failover seam for empty results, moving Telegram message selection into extensions/telegram, and keeping broader channel expansion separate.

Do we have a high-confidence way to reproduce the issue?

Yes. The blocking PR issue is reproducible by source inspection: current main has no tracked src/telegram dispatcher, and the active Telegram plugin still sends generic fallback text.

Is this the best way to solve the issue?

No. The intended behavior is useful, but the submitted patch is not the best current implementation because it targets an obsolete Telegram path and bypasses the classifier-based fallback contract already present on main.

Full review comments:

  • [P2] Move Telegram error routing to the plugin dispatcher — src/telegram/bot-message-dispatch.ts:44-49
    Current main's Telegram runtime lives under extensions/telegram/src, but this patch changes src/telegram/bot-message-dispatch.ts. After rebase, the typed rate-limit and empty-response fallback messages would not reach the shipped Telegram plugin, so move this logic and its tests to the active plugin path.
    Confidence: 0.9
  • [P3] Add the required changelog entry — src/agents/model-fallback.ts:153-175
    This PR changes user-visible fallback behavior and model fallback semantics, so repo policy requires a single-line CHANGELOG.md fix entry for the adapted implementation.
    Confidence: 0.82

Overall correctness: patch is incorrect
Overall confidence: 0.88

Acceptance criteria:

  • pnpm test src/agents/model-fallback.test.ts src/agents/outcome-fallback-runtime-contract.test.ts extensions/telegram/src/bot-message-dispatch.test.ts extensions/telegram/src/bot-message.test.ts
  • pnpm exec oxfmt --check --threads=1 src/agents/model-fallback.ts src/agents/model-fallback.test.ts src/agents/outcome-fallback-runtime-contract.test.ts extensions/telegram/src/bot-message-dispatch.ts extensions/telegram/src/bot-message-dispatch.test.ts extensions/telegram/src/bot-message.test.ts CHANGELOG.md
  • Run pnpm check:changed in Testbox before handoff if runtime code changes.

What I checked:

Likely related people:

  • steipete: Current-main commits and available blame in this checkout point to recent maintainer work around the active agent runtime and plugin tree that now contain the relevant fallback and Telegram dispatch paths. (role: recent maintainer; confidence: medium; commits: 4407c317f38e, c35ed548bf0c; files: src/agents/model-fallback.ts, src/agents/pi-embedded-runner/result-fallback-classifier.ts, extensions/telegram/src/bot-message-dispatch.ts)
  • 100yenadmin: Merged PR [codex] Harden GPT-5.4 runtime paths #70743 is linked in this PR's timeline and explicitly hardened the GPT-5.4 empty/reasoning/planning terminal fallback path that now overlaps the model-fallback half of this contribution. (role: adjacent merged fallback owner; confidence: medium; commits: 600cdedee473; files: src/agents/model-fallback.ts, src/agents/pi-embedded-runner/result-fallback-classifier.ts, src/auto-reply/reply/agent-runner-execution.ts)

Remaining risk / open question:

Codex review notes: model gpt-5.5, reasoning high; reviewed against 4407c317f38e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant