Skip to content

feat(agent): P1.3 — structured tool-error signal to the model (is_error) - #42

Merged
monatis merged 2 commits into
altaidevorg:mainfrom
efecnc:feat/p1-tool-is-error
Jun 4, 2026
Merged

feat(agent): P1.3 — structured tool-error signal to the model (is_error)#42
monatis merged 2 commits into
altaidevorg:mainfrom
efecnc:feat/p1-tool-is-error

Conversation

@efecnc

@efecnc efecnc commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Context

Follow-on to the P0/P1 harness work. The review flagged a P1 ACI gap: when a tool call fails, the model only sees an "Error:" text prefix — it has to infer failure from prose. The is_error boolean is computed at dispatch (agent/mod.rs) and sent to telemetry/UI, but dropped before the provider request; even the Anthropic path never set the API's own is_error. Surfacing a structured failure signal is the single highest-leverage fix for self-correction, which is the heart of loop robustness.

What changed, and why

  • ChatMessage gains an internal-only is_error: Option<bool> with #[serde(default, skip_serializing)]. Why internal-only: the OpenAI-compatible request serializes ChatMessage directly, and strict endpoints reject unknown message fields — so this field must never hit that wire. skip_serializing guarantees the OpenAI-compat wire is byte-identical; default keeps older persisted messages deserializing.
  • Anthropic convert_messages now sets the native is_error: true on the tool_result content block when the tool failed (omitted on success — Anthropic treats absence as success). This is read from the Rust field, not serde, so it's fully controlled.
  • ChatMessage::tool_with_error(...) records the flag; both the parallel and sequential tool-dispatch paths in agent/mod.rs now use it. The existing "Error:" text prefix is preserved as the signal for OpenAI-compatible providers (which have no native equivalent).

Test plan

  • anthropic_tool_result_sets_is_error_only_on_failure — failed tool → is_error: true; success/legacy(None) → no key.
  • is_error_is_never_serialized_to_openai_wire — serializing a failed-tool ChatMessage produces no is_error field.
  • cargo test --lib green (302 passed); cargo check --all-targets green; cargo clippy adds no new warnings.
  • Reviewer: confirm OpenAI-compat behavior is intentionally unchanged (text prefix only); decide whether the resume-injection (mod.rs:1778) and repair-placeholder (mod.rs:225) tool messages should ever be marked is_error (currently None).

Tool failures previously reached the model only as an "Error:" text prefix; the
computed is_error boolean was used for telemetry but dropped before the provider.
Now a failed tool call carries the failure through to the API:

- ChatMessage gains an internal-only `is_error: Option<bool>` (#[serde(default,
  skip_serializing)]), so the OpenAI-compatible wire is byte-identical and strict
  endpoints can't reject an unknown message field.
- The Anthropic message builder (convert_messages) sets the native `is_error: true`
  on the tool_result content block on failure (absent on success).
- ChatMessage::tool_with_error sets it; both the parallel and sequential
  tool-dispatch paths now use it. The "Error:" text prefix is preserved as the
  OpenAI-compatible signal.

Self-correction (the heart of loop robustness): Anthropic models get a first-class
failure signal instead of inferring it from text.

Tests: native is_error only on failure (success/legacy omit it); is_error never on
the OpenAI wire. cargo test --lib green (302); clippy adds no new warnings.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces an internal is_error field to ChatMessage to support Anthropic's native failure flag on tool results, providing a structured error signal instead of relying solely on text prefixes. This field is skipped during serialization to avoid breaking OpenAI-compatible endpoints. The reviewer points out that because is_error is not persisted in the SQLite database, this structured flag is lost when a session is reloaded from history, and suggests adding an is_error column to the database to preserve this signal across restarts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/memory.rs
Comment on lines +889 to +891
// Not persisted (skip_serializing internal field); a reloaded
// message keeps only the "Error:" text already in `content`.
is_error: None,

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.

medium

Losing the is_error flag on session reload/resume is a potential issue for loop robustness. When a session is resumed from the database, the model will only see the "Error:" text prefix for past tool failures, losing the structured is_error: true signal. Consider adding an is_error column to the messages table (e.g., as an integer or boolean) to persist this flag across restarts, ensuring consistent self-correction behavior even after a session is reloaded.

…clarify doc

Addresses the independent review of altaidevorg#42:
- Wrap the two new test asserts to rustfmt's form (provider.rs). Left the pre-existing
  line-441 fmt drift untouched (CI does not gate fmt).
- Strengthen `is_error_is_never_serialized_to_openai_wire` to also assert against the real
  `{"messages":[...]}` request-body shape, not just the bare struct.
- Tighten the `is_error` field doc: it tracks execution/transport failure (dispatch
  Result::Err); in-band `Ok("Error: ...")` tool errors still travel as text only (widening
  the structured flag to the textual heuristic is noted as a follow-up).
@efecnc

efecnc commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

🔍 Code review — independent reviewer pass (+ resolution)

Reviewed by independent rust-reviewer + code-reviewer agents against the full diff + surrounding code (Anthropic + OpenAI-compat paths, persistence, the agent loop). Both APPROVE — no CRITICAL/HIGH.

Verified clean by review

  • OpenAI-compat wire is byte-identicalLLMClient::chat serializes ChatMessage directly; #[serde(skip_serializing)] drops is_error unconditionally (proven by test, now also asserted against the real {"messages":[...]} body).
  • Anthropic shape correctis_error: true as a sibling in the tool_result block; omitting on success is correct (API treats absence as success); API version supports it.
  • No control-flow change — the only read of msg.is_error is the Anthropic builder; no retry/loop decision keys off it. Text + structured flag derive from the same is_err() source, so they can't disagree.
  • merge_consecutive_roles preserves per-block is_error — and the test genuinely exercises the merge path (3 consecutive tool results → one user message, 3 blocks).
  • No missing literals / no panic on reloadcargo check --all-targets green; #[serde(default)] covers older persisted rows (existing round-trip test passes).

Addressed

  • MEDIUM (fmt): wrapped the two new test asserts to rustfmt's form (left the pre-existing line-441 drift; CI gates check/clippy --release/test, not fmt). Fix 5feb0c3.
  • MEDIUM (in-band Ok("Error:...") gap): five tools report recoverable failures as Ok("Error: ..."), which is_error (from Result::is_err()) doesn't flag. Took the reviewers' lower-risk option for this PR: tightened the field doc to state is_error tracks execution/transport failure, and noted that those in-band errors still reach the model as text (widening the structured flag to the UI's tool_result_looks_like_failure heuristic is a clean follow-up). Fix 5feb0c3.
  • LOW: strengthened the wire test to assert the real request-body shape.

Deferred (follow-up)

  • Widen is_error to also cover in-band Ok("Error:...") tool errors by reusing the existing tool_result_looks_like_failure predicate (would also align telemetry/UI). Out of scope for this surgical PR.
  • is_error is not durable across a process restart (not persisted — by design); reloaded failures keep only the "Error:" text.

Verdict: APPROVE — correct, surgical, non-behavioral; nits resolved.

@monatis
monatis merged commit e99e8b3 into altaidevorg:main Jun 4, 2026
1 check passed
efecnc added a commit to efecnc/isanagent that referenced this pull request Jun 4, 2026
…"Error:"))

A tool message's `is_error` was derived solely from the dispatch `Result`, so
tools that report recoverable failures *inside* `Ok(...)` were recorded as
successes. The model then saw a failed edit or a non-zero command as a success,
which also blinded the doom-loop detector (it reasons about tool outcomes).

Conventions in this codebase:
- `exec` / `python_run` append a trailing `Exit code: <N>` line on non-zero exit.
- `edit_file` / `list_dir` / `glob_files` / `search_text` return `Ok("Error: ...")`.

Changes:
- utils: add `tool_output_signals_failure(tool_name, raw_output)` — a tool-scoped,
  anchored heuristic — and `tool_call_is_error(tool_name, &Result)`, the single
  source of truth used at both tool-result sites in the reasoning loop. Detection
  runs on the RAW `Ok` payload (the exit marker sits at the tail and
  `finalize_tool_output` would truncate it away). Scope:
  - exec/python_run: only the trailing `Exit code: <non-zero>` marker.
  - edit_file/list_dir/glob_files/search_text: an `Error:` prefix (their whole
    payload is a control/listing message, never raw file content).
  read_file and other content tools are intentionally excluded to avoid false
  positives on file content that merely begins with "Error:".
- builtin (`exec`): append the `Exit code:` marker LAST — after the grep advisory
  and after the internal 10 KB truncation — so it always survives as the final
  line. Previously the advisory and the size-truncation trailed it, so a genuine
  non-zero exit on grep-like or large output was silently recorded as a success.
- agent: both tool-result sites now call `tool_call_is_error`.

The Anthropic message builder already maps `is_error` onto the native
`tool_result.is_error`; the OpenAI wire format is unchanged (field is
`skip_serializing`). Follow-up to the internal `is_error` field added in altaidevorg#42.

Known, accepted residual: a *successful* command whose own output ends with a
line literally `Exit code: <non-zero>` is a false positive (the runner only
appends the marker on real non-zero exit). Low frequency; the only consequence
is a spurious retry, never a masked real failure — documented and pinned by test.

Tests: utils heuristic + wrapper (exit cases, anchoring, scoped Error-prefix,
file-content/unrelated-tool guards, documented residual); plus exec contract
tests proving the marker stays the final line under 10 KB truncation and the
grep advisory, and that successful commands carry no marker.
monatis pushed a commit that referenced this pull request Jun 4, 2026
…r decay

Post-merge follow-ups on #42 (is_error) and #44 (doom-loop):
- is_error now also flags tools that report a *recoverable* failure in-band as Ok("Error: ...")
  (e.g. "old_text not found"), not just a dispatch Err — so the model's structured failure
  signal matches what the result text already conveys. Extracted shared
  `utils::tool_output_looks_like_failure` and reused it in the terminal UI failure phase (was a
  duplicated heuristic) and both tool-dispatch sites.
- Doom-loop escalation: on a detected-but-not-tail-active turn, decay the consecutive-detection
  counter instead of hard-resetting, so an intermittently-varying loop (mostly repeating with the
  occasional one-off) still escalates over time rather than being fully forgiven by one turn.

cargo test --lib green (324 passed); clippy adds no new warnings in changed files.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants