Commit graph

9 commits

Author SHA1 Message Date
Hermes Agent
725c7ba534 refactor: single owner for empty-content wire repair (class fix)
The concept 'never send a turn that strict wire validation rejects as
empty' was forked across four sites, each with its own predicate and its
own blind spots:

1. build_assistant_message write-time ' ' pad — broke codex commentary
   turns (content:'' is a designed state), and a DB-side pad can't
   survive _rows_to_conversation's whitespace strip anyway. REMOVED.
2. conversation_loop send-time ' ' pad — main-loop only (summary path
   uncovered), ordering-fragile (had to run after whitespace
   normalization), assistant-only. REMOVED.
3. stream-stub '[response interrupted]' substitution — defeated the
   loop's empty-stub guard (the stub no longer looked empty, entered
   history, and the placeholder leaked into the stitched final
   response via truncated_response_parts). REMOVED.
4. repair_empty_non_final_messages in sanitize_api_messages — the
   unconditional pre-send chokepoint shared by the main loop AND the
   summary path, covers user and assistant turns, non-final only,
   copy-on-write. This is now the SINGLE OWNER.

The owner's payload predicate (_msg_has_payload) is extended to treat
codex_message_items / codex_reasoning_items as payload, so
designed-empty codex commentary turns are never rewritten on any
api_mode — the failure shape that broke site 1 in CI is encoded in the
owner, not special-cased at a call site.

Tests updated to pin the new contracts: builder stores textless turns
as-is; the empty stream stub stays recognizably empty for the loop
guard; poisoned resumed histories are repaired to the placeholder at
the send boundary; codex item carriers are never rewritten.
Sabotage-verified: unwiring the owner fails 3 regression tests.
2026-07-27 20:27:56 -07:00
Hermes Agent
25a8574592 test: adapt multimodal pad-safety test to main's assistant-content flattening
Current main flattens multimodal assistant list-content to a plain string
before the send boundary, so the original assertion (list survives to the
wire) can't hold on this base. The test now asserts the load-bearing
contract instead: no crash, and the assistant turn's text is neither
dropped nor replaced by the pad. Adds a direct unit-shape check that the
pad predicate skips list content (the exact AttributeError shape from the
original bug) and pads only textless string turns.
2026-07-27 19:23:34 -07:00
0xprincess
2a26be69c9 fix: send-time pad must skip multimodal (list) assistant content
The empty-content pad loop called .strip() on am.get("content") without
checking the type.  Multimodal assistant turns carry content as a list
of parts (text/image), so a session with any image-bearing turn crashed
during request assembly:

    AttributeError: 'list' object has no attribute 'strip'

Guard with isinstance(content, str) — a multimodal turn is never the
empty textless shape the pad repairs.  Adds a regression test driving a
multimodal history through the loop.
2026-07-27 19:23:34 -07:00
0xprincess
309f06b044 fix: prevent session poisoning from empty partial-stream-stub assistant turns
A mid-tool-call stream drop with no delivered text produces a
partial-stream stub carrying content:'' and tool_calls=None.  The
conversation loop's truncation path appended it to history as
{"role":"assistant","content":""} before the continuation nudge, and
strict providers (Moonshot/Kimi via OpenRouter) reject empty assistant
content with HTTP 400 ("the message at position N with role 'assistant'
must not be empty") on the next replay.  Because the message is
persisted, every subsequent turn re-failed — the session was
unrecoverable.

Three layers, smallest blast radius first:

1. conversation_loop (length path): an EMPTY partial-stream stub is no
   longer appended as an interim assistant message; only the
   continuation user-message is.  Stubs that delivered partial text are
   still persisted so continuation stitching is unchanged.

2. chat_completion_helpers.build_assistant_message: never serialize a
   textless assistant turn with content:'' — pad to a single space, the
   same trick as the reasoning_content pad (#15250, #17400).  Tool-call
   turns are exempt (content:'' alongside tool_calls is accepted
   everywhere).

3. conversation_loop send boundary: pad a textless assistant turn's
   empty content to a single space AFTER all content-mutating passes
   (surrogate sanitize, whitespace normalization, thinking-only drops),
   before token estimation.  This is the durable repair for sessions
   ALREADY poisoned by older builds: the persisted stub rows are rebuilt
   to '' on every reload (_rows_to_conversation strips whitespace, so a
   DB-side pad can't survive) and only a send-time pad repairs them.

Verified: 485 tests pass across the four affected files; live replay of
a real poisoned session's resumed history against Moonshot via
OpenRouter returns HTTP 200 (was HTTP 400).
2026-07-27 19:23:34 -07:00
ajzrva-sys
65bb16c8ce fix(streaming): detect text-only stream drops with no finish_reason (#32086)
When a streaming response ends cleanly (HTTP 200) with no finish_reason
after delivering text but no tool calls, the chunk collector silently
stamps finish_reason='stop' and the conversation loop presents truncated
text as a complete response.

Three stream-drop paths now exist after chunk collection:

1. Zero-chunk → EmptyStreamError, retried (existing)
2. Tool-call in progress, no finish_reason → partial-stream-stub (existing)
3. Text-only, no finish_reason → partial-stream-stub (NEW — this fix)

Path 3 routes through the same PARTIAL_STREAM_STUB_ID + FINISH_REASON_LENGTH
machinery as path 2. The conversation loop shows 'Stream interrupted —
requesting continuation' and injects a continue prompt, giving the model
a chance to resume where the stream dropped.

Observed with DeepSeek provider where CloudFront drops SSE streams
mid-response after delivering partial text.
2026-07-20 13:31:02 +05:30
teknium1
578e3989d4 fix(agent): route content-filter stream stalls to fallback chain (#32421)
When a provider's output-layer safety filter (MiniMax "output new_sensitive
(1027)", Azure content_filter, etc.) kills a streaming response after deltas
were already sent, interruptible_streaming_api_call swallows the raw error
into a finish_reason=length partial-stream stub. The conversation loop then
burned 3 continuation retries against the SAME primary — re-hitting the
content-deterministic filter every time — and gave up with "Response remained
truncated after 3 continuation attempts", never consulting fallback_providers.

Builds on @595650661's classifier change (cherry-picked) so error_classifier
recognizes the filter; then:
- chat_completion_helpers: run the swallowed error through error_classifier at
  the stub-creation point and stamp _content_filter_terminated on the stub
  (single source of truth — no parallel pattern list).
- conversation_loop: read the tag and activate the fallback chain BEFORE
  burning any continuation retries; roll partial content back to the last
  clean turn and re-issue against the new provider (restart_with_rebuilt_messages).
  Plain network stalls are unaffected (only content_policy_blocked is tagged).

Credits #32479 (@sweetcornna) and #33845 (@Tranquil-Flow) which fixed the
same issue via the stub-tag and loop-escalation approaches respectively.

Live E2E confirmed: before, _try_activate_fallback called 0x; after, fallback
fires on the first stub and the fallback provider completes the turn.
2026-06-28 01:15:21 -07:00
Teknium
c9094f5e5f
fix(stream): don't report dropped mid-tool-call streams as output truncation (#42314)
* fix(stream): don't report dropped mid-tool-call streams as output truncation

A streaming tool call whose SSE ends with no finish_reason (the upstream
delivers the tool name + opening '{' then closes the connection cleanly,
no terminator, no [DONE]) was stamped finish_reason='length' by the mock
builder. That routed it through the output-cap truncation path: 3 useless
max_tokens-boosted retries, then the misleading 'Response truncated due to
output length limit' error — even though the model never reported hitting
any cap.

Reproduced live on nvidia/nemotron-3-ultra:free via the Nous dedicated
endpoint, which stalls/drops during large tool-arg generation (50s-4m41s).

Now: when tool args are incomplete AND the provider sent no finish_reason,
tag the response as a partial-stream stub so the loop reports an honest
mid-tool-call drop and asks the model to chunk its output (existing
continuation machinery), instead of escalating output budget and lying.
A provider-reported finish_reason='length' still takes the real-truncation
path unchanged.

* test(stream): update truncated-tool-args test for drop-vs-cap split

test_truncated_tool_call_args_upgrade_finish_reason_to_length pinned the
old behaviour where ANY incomplete tool args → finish_reason='length' with
tool_calls preserved. That single-chunk-no-finish_reason scenario is exactly
the mid-tool-call stream drop now reclassified as a partial-stream stub.

Split into two tests matching the new contract:
- no finish_reason + incomplete args → PARTIAL_STREAM_STUB_ID, tool_calls=None,
  _dropped_tool_names set (the drop path)
- explicit finish_reason='length' + incomplete args → tool_calls preserved,
  'length' upgrade unchanged (the genuine output-cap path)
2026-06-08 11:56:10 -07:00
daimon-nous[bot]
ac5359a3f3
fix(streaming): route mid-tool-call partial-stream-stub through length continuation (#31998) (#32012)
* fix(streaming): route mid-tool-call partial-stream-stub through length continuation (#31998)

When a stream stalls mid-tool-call (e.g. a large write_file), the
partial-stream-stub recovery used finish_reason='stop' which caused the
conversation loop to treat the turn as complete, returning only the
warning text. When users said 'continue', the model retried the same
large tool call, hit the same stale timeout, and looped indefinitely.

Changes:
- chat_completion_helpers.py: change _stub_finish_reason from 'stop' to
  'length' for mid-tool-call partials. The stub still has tool_calls=None
  so no tool auto-executes — the model gets a fresh API call through the
  existing length-continuation machinery (bounded to 3 retries).
  Also attach _dropped_tool_names to the stub for downstream use.
- conversation_loop.py: add a third continuation prompt branch for
  partial-stream-stubs with dropped tool calls. Instead of the generic
  'continue where you left off' (which would retry the same large call),
  tell the model to break the output into smaller tool calls (~8K
  tokens each) to avoid stream timeouts.
- test_partial_stream_finish_reason.py: update existing test from
  finish_reason='stop' to 'length', add _dropped_tool_names assertion,
  add new test_dropped_tool_call_uses_chunking_prompt for the 3-way
  prompt branching.

Safety: tool_calls=None is preserved on the stub, so the conversation
loop enters the text-continuation branch (line 1513), NOT the tool-call
execution branch (line 3246). No tool auto-executes. The model simply
gets another API call with targeted guidance.

* refactor: extract constants and continuation prompt helper

- Move magic strings to hermes_constants.py (PARTIAL_STREAM_STUB_ID,
  FINISH_REASON_LENGTH)
- Extract _get_continuation_prompt() in conversation_loop.py — DRYs the
  3-way prompt branching and lets tests import the real function
- Trim verbose inline comments in chat_completion_helpers.py
- Tests import constants + helper instead of duplicating logic

---------

Co-authored-by: alt-glitch <balyan.sid@gmail.com>
2026-05-25 17:43:10 +05:30
xxxigm
6cafcf9c77 test(streaming): pin partial-stream-stub finish_reason + continuation contract
Three test classes lock in the #30963 fix:

1. TestPartialStreamStubFinishReason — drives _interruptible_streaming_api_call
   through the two recovery branches and asserts:
     - text-only partial → finish_reason="length" (the new behaviour),
     - mid-tool-call partial → finish_reason="stop" (unchanged on purpose).

2. TestLengthContinuationPromptBranching — pure-Python check on the branch
   that picks the continuation prompt by response.id. Locks the network
   error wording for partial-stream-stub vs. the output-length wording
   for everything else.

3. TestConversationLoopPartialStreamContinuation — feeds a stub +
   continuation pair into run_conversation, verifies the loop makes a
   second API call (instead of exiting with text_response(stop)),
   confirms the network-error continuation prompt actually reaches the
   model on call #2, and that final_response stitches both halves.

Refs: NousResearch/hermes-agent#30963
2026-05-24 04:35:15 -07:00