Skip to content

fix(harness): recover truncated/unterminated JSON tool requests - #1798

Open
Zenetusken wants to merge 10 commits into
agent0ai:mainfrom
Zenetusken:fix/deepseek-harness-reliability
Open

fix(harness): recover truncated/unterminated JSON tool requests#1798
Zenetusken wants to merge 10 commits into
agent0ai:mainfrom
Zenetusken:fix/deepseek-harness-reliability

Conversation

@Zenetusken

@Zenetusken Zenetusken commented Aug 2, 2026

Copy link
Copy Markdown

What this PR does

Hardens the Agent Zero harness against protocol failures observed empirically with the DeepSeek V4 Flash API (saved-chat evidence), without relaxing the strict single-JSON-envelope tool protocol for well-behaved models.

Symptom evidence (from live chat logs)

  • Planning prose instead of the JSON tool envelope → generic Message misformat, no valid tool request found. with no diagnostics.
  • Tool envelopes cut off mid-object (e.g. ending at "tool_args":{"code":"echo) → logged as generic misformat; provider finish_reason=length was never surfaced, so truncation was indistinguishable from malformed output.
  • _51_memorize_solutions failing inside LiteLLM with the provider status code swallowed, racing _50_memorize_fragments on the same background loop.

Changes

Parser diagnostics & recovery (helpers/extract_tools.py, agent.py)

  • finish_reason is plumbed end-to-end (transport → LLMResultunified_turnprocess_tools) and surfaced in the misformat log reason.
  • explain_tool_request_failure() classifies failures (truncated vs. prose vs. non-tool JSON vs. empty) with sanitized output (class + length only, never response content).
  • recover_embedded_tool_request() recovers exactly one strictly-valid embedded envelope; quoted/fenced/inline-code examples are masked and never executed.
  • is_truncated_tool_request() detects unterminated envelopes via a structural prefix gate ({"thoughts"|"headline"|"tool_name"|"tool_args"|"type") and routes them to a targeted re-prompt (prompts/fw.msg_truncated_request.md) instead of the generic misformat warning; the unusable-response loop guard tracks the new warning so repair loops still terminate.

Provider reliability (models.py)

  • Retries empty completions and truncated 200 bodies for direct deepseek/ providers only (stream, chat API, non-third-party hosts); utility-model calls opt out via a0_allow_empty_completion (an empty memorize result is a benign outcome — see _50_memorize_fragments.py).

Memory post-processing (plugins/_memory/)

  • _50/_51 are serialized via a cancellation-safe async lock (memorize_lock.py) and surface the provider status code in error messages.

Adversarial-review hardening (second pass)

  • response-tool recovery is refused when the envelope sits in substantial deliberating prose (>40 chars outside the root, or hedging words like "could"/"option"/"but") — a task can no longer be "completed" by a possibility under discussion. Successful recoveries now add a corrective history note (prompts/fw.msg_recovered_request.md) teaching the model to emit bare JSON.
  • Empty-completion retry is gated to main chat turns; utility calls no longer pay 3× cost while holding the memorize lock.
  • The truncation gate no longer substring-matches ordinary words ("actions", "function") — verified false positive {here are the actions I plan to take now classifies correctly and is not swallowed in responses mode.

Verification

  • Full suite in the agent0ai/agent-zero:v2.6 image with agent_zero_usr mounted read-only: 1250 passed, 1 skipped (skip = Playwright not installed).
  • All new tests fail against the pre-fix code for the right reasons (verified against base worktrees).
  • ~50 adversarial inputs run through the real extraction/detection functions (prose+JSON, two objects, trailing commas, truncated mid-string, extra braces, quote/escape desync attempts, false-positive probes).

Cross-PR note

This branch also contains connector-reconnect, timezone-persistence and test-infrastructure commits that overlap #1799 and #1800. Recommended merge order: #1799 and #1800 first (small, independent), then this PR rebased; or this PR first and the other two shrink correspondingly.

DeepSeek V4 Flash intermittently breaks Agent Zero's tool protocol in
three distinct ways, all reproduced against the live API and covered by
new regression tests (tests/test_deepseek_harness_reliability.py).

1. Planning prose around the JSON tool envelope. With thinking enabled,
   the model occasionally prepends prose to an otherwise valid tool
   request. extract_tool_request() stays strict; process_tools() now
   falls back to recover_embedded_tool_request(), which accepts exactly
   one valid embedded envelope and rejects zero/ambiguous/multiple.

2. Provider-dropped streams. DeepSeek closes streaming connections
   mid-response; LiteLLM ends the iterator without an error, so partial
   unterminated JSON reached the parser as "Message misformat". The
   transport now tracks last_finish_reason; a DeepSeek chat-completions
   stream ending without one is discarded and the turn retried (bounded
   by a0_retry_attempts, early-stop and responses API exempt).

3. Truncated non-streaming bodies classified fatal. LiteLLM reports
   "Unable to get json response" with the original 200 status code;
   _is_transient_litellm_error now treats it as transient so utility
   calls retry instead of surfacing immediately.

Also:
- Propagate finish_reason through chat-completions parse, stream parser,
  LLMResult (dict/metadata roundtrip) and the unified_turn fallback, so
  diagnostics can distinguish length-truncation from dropped streams.
- Misformat warnings now carry a sanitized reason (truncation, prose,
  missing envelope, empty) plus output length, never content.
- Serialize the _50/_51 memory post-processing jobs on a shared async
  lock (concurrent long-lived utility requests were being truncated
  provider-side) and include the provider status code in their errors.
Two more V4 Flash failure modes, both root-caused from saved chat
metadata and covered by regression tests.

1. Empty completions (production seq 1082): thinking/JSON mode
   occasionally finishes with finish_reason=stop after a full reasoning
   stream but whitespace-only content. The turn was accepted as valid
   and reported as a misformat. New _should_retry_empty_completion()
   discards whitespace-only DeepSeek completions and retries the turn
   (same bounds and exemptions as the dropped-stream retry).

2. Truncated non-streaming bodies: LiteLLM reports "Unable to get json
   response" with the original HTTP 200 status, which
   _is_transient_litellm_error trusted and never retried (the
   _51_memorize_solutions failure). It is now classified transient
   regardless of status code.
Root cause: after an Agent Zero restart the in-memory /ws registry is
wiped, and the a0 CLI's bounded reconnect (5 attempts over ~38s) often
gave up before the server was ready, leaving remote execution dead until
manual intervention. Meanwhile code_execution_remote returned a soft
tool result, so the model kept retrying with fresh session numbers
instead of surfacing the infrastructure problem to the user.

- code_execution_remote: when no CLI is connected, wait up to 60s
  (2s poll) for the CLI's automatic reconnect before failing; on
  failure end the turn (break_loop) with actionable reconnect guidance
  instead of inviting endless retries. Same clean handoff when a CLI is
  connected but exec/write access is disabled.
- helpers: add __init__.py so the package is no longer imported as a
  namespace package. Test cleanup helpers that purge sys.modules treated
  the namespace package as a stub and deleted every loaded helpers.*
  module, splitting extension-registry state between stale and fresh
  module copies depending on import order (e.g. Agent instances losing
  extension-initialized loop_data). Full suite goes from 39-71 flaky
  failures to a deterministic 7 pre-existing environmental ones
  (1203 passed).
- tests: regression coverage for the reconnect grace window, the
  break-loop handoff, and the exec-disabled/write-blocked fast paths.
Full suite goes from 39-71 order-dependent failures plus 6 collection
errors to a deterministic 1218 passed / 0 failed.

- conftest: bootstrap the Telegram plugin's lazy aiogram install via its
  own ensure_dependencies() before collection, falling back to
  collect_ignore_glob for offline environments; exclude the two legacy
  manual scripts (email_parser_test.py imports a commented-out module,
  rate_limiter_test.py performs a live LLM call at import).
- test_http_auth_csrf: register the serve_index endpoint the auth
  fallback redirects through (was 500 via werkzeug BuildError instead
  of 302); assert the login redirect's next parameter semantically
  instead of pinning Werkzeug's old query encoding.
- test_docker_release_plan: track the current branch-promotion contract
  (testing/ready/main, TARGET_TAG re-resolution) after the workflow
  refactor left the assertions stale.
- test_browser_agent_regressions: install sys.modules stubs only when
  the real module is unimportable - unconditional collection-time stubs
  poisoned every later-collected module depending on import order
  (collateral included test_responses_architecture and the file's own
  viewer tests); shadow emit_to on the handler instance in the viewer
  command tests so they behave identically in both regimes.
Findings from three adversarial code reviews of the DeepSeek harness,
CLI disconnect, and test-suite repair commits:

Arc 1 (DeepSeek harness):
- extract_tools: tighten embedded-JSON recovery — mask quoted, fenced,
  and inline-code regions before scanning, and require strict JSON for
  candidate objects, so prose examples can no longer be recovered as
  live tool requests (M1)
- models: classify transient failures status-first so a mis-mapped
  exception cannot trigger retries of non-transient errors (M3)
- models: gate empty-completion retries to direct DeepSeek transports
  only, avoiding double-billing through proxies (M4)
- memorize_lock: rebind the loop reference so the lock stays valid if
  the background event loop is recreated (M5)

Arc 2 (CLI disconnect survival):
- code_execution_remote: exit the grace wait early when the CLI
  reconnects, emit progress while waiting, and drop a stale hint (server)
- connection.py (host CLI, deployed separately): surface disconnect
  errors instead of looping silently, close the shutdown race, and
  guard base_url derivation

Arc 3 (test suite):
- conftest: narrow the aiogram-install except clause and warn loudly
  on failure instead of masking import errors
- test_http_auth_csrf: assert on origin netloc rather than substring
- test_docker_release_plan: expect TARGET_TAG twice and validate
  ALLOWED_BRANCHES
- test_model_config_project_presets: tear down the preset cache so
  the test cannot leak state

Verification: full suite in-container 1230 passed, 0 failed,
deterministic across two runs after deploy + container restart.
… usr data

Root-caused why a full-suite run wiped the user's DeepSeek model
presets, reset every scoped model selection to "Default", and clobbered
.env timezone defaults to UTC/0:

- tests/test_time_travel.py intentionally used PROJECT_ROOT/usr as
  scratch space; inside the deployed container that path IS the live
  persistent volume. Redirect helpers.files._base_dir to tmp_path in
  the workspace fixture and the symlink-alias test so /a0/usr display
  paths resolve into tmp. Verified: suite passes with /a0/usr mounted
  read-only.
- tests/conftest.py: add an autouse guard that fails any test writing
  under the real <repo>/usr via helpers.files write/delete functions
  or the dotenv/localization save_dotenv_value path. The guard
  immediately caught test_parallel_tool persisting a poll timezone.
- helpers/state_snapshot.py: the /poll snapshot builder applied the
  request timezone via localization.set_timezone(), persisting a
  browser-reported (or spoofed, e.g. Firefox RFP) timezone into
  usr/.env as DEFAULT_USER_TIMEZONE. A read poll must never write
  .env; now persist=False.
- helpers/localization.py + helpers/settings.py: set_timezone gains a
  persist flag; AUTO timezone resolution no longer overwrites the
  user's saved default.
- tests/test_timezone_regressions.py: regression test asserting AUTO
  mode does not persist the browser timezone.
- tests/test_defer_lifecycle.py: drop the environment-dependent
  "owner collected" assertion after a suppressed CancelledError -
  asyncio anchors the exception traceback island based on process-wide
  import state (importing helpers.settings makes it survive pumping
  and even gc.collect()), so the assertion tested CPython internals,
  not the DeferredTask contract. The meaningful guarantees (stored
  call cleared, running arguments untouched, restart rejected) remain
  asserted.

Verification: full suite 1230 passed with /a0/usr mounted read-only;
deployed to the live container, 1231 passed, and diff of /a0/usr
before/after the run is completely clean.
- Add is_truncated_tool_request() and _json_root_object_balanced()
- Route truncated requests to fw.msg_truncated_request.md and loop guard
- Extend DeepSeek reliability/connector regression coverage
- recover_embedded_tool_request: refuse 'response'-tool recovery when the
  envelope sits in substantial deliberating prose (>40 non-ws chars outside
  the JSON root, or hedging like could/but/option), so hedged deliberation
  can no longer silently complete a task; operational tools keep being
  recovered (helpers/extract_tools.py)
- process_tools: on successful embedded-envelope recovery add the new
  fw.msg_recovered_request.md corrective note so the model learns to emit
  bare JSON; deliberately not tracked by the unusable-response-loop guard
  (agent.py, prompts/fw.msg_recovered_request.md)
- is_truncated_tool_request: replace the substring keyword gate with a
  structural prefix match (thoughts/headline/tool_name/tool_args/type) so
  prose mentioning 'actions' after a stray '{' is not swallowed into a
  repair loop in responses mode; truncated function_call payloads classify
  as truncated so they take the repair-prompt path (helpers/extract_tools.py)
- unified_call/unified_turn: utility callers opt out of the main-turn
  empty-completion retry via a0_allow_empty_completion; call_utility_model
  passes it because an empty utility reply (e.g. 'nothing to memorize') is
  benign (models.py, agent.py)
- dedupe the brace-depth scan shared by _json_root_object_balanced and
  is_truncated_tool_request; classify a complete root followed by an
  unterminated fragment as trailing truncation in
  explain_tool_request_failure; document the '['-prefix recovery blind spot
  (helpers/extract_tools.py)

Regression tests cover the review's adversarial inputs, both retry regimes,
and the call_utility_model wiring.
- git rm docs/harness-truncated-json-fix-note.md (internal note with local
  machine paths and stale status; durable content moves to the PR
  description)
- remove now-unused 'from helpers import errors' from both memorize
  extensions after the switch to format_utility_error
- code_execution_remote: restore the pre-branch guidance that
  `runtime=output` and `runtime=reset` remain available when writes are
  blocked
@Zenetusken
Zenetusken force-pushed the fix/deepseek-harness-reliability branch from f31ce23 to bec6ea5 Compare August 2, 2026 18:54
@Zenetusken

Copy link
Copy Markdown
Author

Rebased onto v2.8 (5ff106a2, current main): branch was originally cut from v2.7-era 87e1e591. All 9 commits replayed cleanly; no conflicts. New head bec6ea5f.

Validation on the rebased branch (v2.8 image runtime): 1306 passed, 3 failed — the 3 failures (test_speech_plugin_split, test_parallel_tool sidebar markers, test_welcome_composer_static) are pre-existing at upstream v2.8 itself (stale static-marker assertions vs. deliberate WebUI changes in 93d1131c/bebe6826); they are fixed separately in #1799 (commit 187faf84). This PR introduces no new failures.

Relationship to v2.8's own envelope repairs: complementary, not overlapping — v2.7/v2.8 repair concatenated envelopes in the native Responses transport, while this PR adds truncation detection (finish_reason) and unterminated-JSON recovery to the chat-completions JSON tool protocol, which v2.8 does not handle.

Note: this branch carries the first version of the tests/conftest.py usr-write guard; #1799 carries an improved superset of the same file. If both merge, keep #1799's version of tests/conftest.py.

…mpt guidance

Live evidence (DeepSeek V4 Flash, 2026-08-04): a fenced 21702-char tool
request cut mid-string logged "truncated or unterminated" via
explain_tool_request_failure() yet routed to the generic misformat
reprompt because is_truncated_tool_request() gated on the raw content
starting with "{". The generic nudge also never addressed payload size,
so the model's retry grew to 31446 chars and truncated again.

- Add classify_tool_request_failure() as the single source of truth for
  both the sanitized log reason and the reprompt routing in
  Agent.process_tools; explain_tool_request_failure() now derives from it
- Recognize truncation from the first envelope-shaped opening brace, so
  fenced, prose-prefixed, and trailing-root truncated payloads classify
  correctly (structural envelope gate unchanged)
- fw.msg_truncated_request.md now tells the model to split oversized
  payloads into multiple smaller sequential tool calls
- Regression tests for all divergent shapes, category parity with the
  log reason, reprompt routing, and nudge content
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.

1 participant