Skip to content

Runs that produce no output collapse distinct terminal reasons — surface provider content-filter / refusal / length-truncation as explicit outcomes #4453

Description

@MervinPraison

Summary

When an agent run yields no usable answer, PraisonAI collapses several distinct terminal causes — a provider content-filter block, a safety refusal, a finish_reason: "length" truncation, or a genuinely empty completion — into one indistinguishable falsy result (or a generic failed). For CLI-first and CI workflows this produces the classic "the session just went quiet" failure: the caller cannot tell why nothing came back, cannot branch on it, and cannot surface an actionable message to the user.

Core already models a rich set of terminal reasons and a per-run stop reason, but neither inspects the provider's finish_reason, so provider-side blocks/refusals/truncations are invisible.

Current behaviour

  • The public outcome taxonomy has no reason for a provider block/refusal/truncation:
    • src/praisonai-agents/praisonaiagents/agent/run_outcome.pyTerminalReason = Literal["completed", "hard_timeout", "cancelled", "aborted", "failed"].
  • The per-run stop reason only ever records completion or the step cap:
    • src/praisonai-agents/praisonaiagents/llm/llm.py sets self._last_stop_reason to "completed" or "max_steps" only (e.g. llm.py:486, :2708, :2941).
  • The provider finish_reason is never inspected for a filter/refusal/length signal: a search for content_filter / refusal / finish_reason-based branching in llm/llm.py returns no handling.
  • The public run/start surface then collapses distinct failures to a falsy value:
    • src/praisonai-code/praisonai_code/cli/commands/run.py _run_succeeded() documents that "The public Agent.start/run surface collapses failures (swallowed LLM/auth error, guardrail block, tool failure, max_iter without completion) to a falsy result … while a real answer is a non-empty string."

So a blocked/refused/truncated turn reaches the CLI either as completed with empty/partial text, or as generic failed, with no distinguishing reason.

Desired behaviour

  • Core inspects the provider finish_reason/refusal signal and records a distinct, additive terminal reason, e.g. content_filtered, refused, length_truncated (names TBD), on RunOutcome.reason and _last_stop_reason — alongside the existing max_steps precedent.
  • These reasons are actionable end-to-end:
    • praisonai run maps them to a clear, non-zero exit outcome and a human-readable message (not a silent empty success), and includes the reason in --output json.
    • return_outcome=True callers (Python) receive the specific reason.
  • Backward-compatible and additive: existing completed|failed|... semantics are unchanged; unknown/absent finish reasons behave exactly as today; zero overhead on the success path.

Layer placement

  • Primary layer: core (praisonaiagents)
  • Why not core → n/a (this is core). Detecting the provider's finish_reason/refusal and mapping it to a terminal reason must happen where the response is parsed (llm/llm.py) and where the outcome is modelled (agent/run_outcome.py).
  • Why not wrapper: the wrapper cannot recover a reason the core never captured; it can only render what core reports. Wrapper is a secondary touch (exit code + --output json field + message).
  • Why not tools: this is response interpretation and run lifecycle, not an agent-callable integration.
  • Why not plugins: although a plugin could observe an ON_ERROR-style hook, the canonical terminal-reason taxonomy is core runtime state that the public run/start contract and CLI exit codes depend on; it is not an optional lifecycle add-on.
  • Secondary touch (optional): wrapper (praisonai-code) — surface the reason in run exit handling and --output json.
  • 3-way surface (CLI + YAML + Python): yes (Python via RunOutcome.reason; CLI via exit/--output json; YAML runs inherit the same outcome semantics)

Proposed approach

  1. In llm/llm.py, read the provider finish_reason (and any explicit refusal field) on both the custom and OpenAI-native response paths; classify content-filter / refusal / length cutoffs.
  2. Record the classification on self._last_stop_reason (extending the existing completed/max_steps set) and thread it into RunOutcome via the existing _run_with_outcome/_astart_with_outcome path.
  3. Add the new reasons to run_outcome.py:TerminalReason with a defined precedence relative to failed (a specific block/refusal reason should win over generic failed, mirroring the existing sticky-precedence design in run_outcome.py).
  4. In the wrapper, map the new reasons to a clear exit outcome and message, and include reason in --output json.

Resolution sketch

  • agent/run_outcome.py: extend TerminalReason and the precedence map additively.
  • llm/llm.py: add a _classify_finish_reason(...) helper; set _last_stop_reason accordingly at the points that currently set "completed".
  • run.py: in _run_succeeded/outcome handling, distinguish the new reasons and emit a specific message + non-zero exit; add the reason to the JSON output shape.
  • Tests: a real agentic test that a prompt provoking a provider refusal/length cutoff yields the specific reason rather than an empty completed.

Severity

Medium — a correctness/observability gap that turns provider-side blocks into silent, un-actionable "empty" runs; particularly impactful for unattended/CI usage where exit codes and machine-readable reasons drive downstream behaviour.

Validation

  • run_outcome.py:TerminalReason lists only completed|hard_timeout|cancelled|aborted|failed — no block/refusal/truncation reason.
  • llm.py sets _last_stop_reason only to completed/max_steps, and performs no finish_reason-based content-filter/refusal handling.
  • run.py:_run_succeeded documents the collapse of distinct failures to a falsy result.
  • The existing max_steps stop reason and the RunOutcome precedence map are the precedents to extend.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaudeAuto-trigger Claude analysis

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions