Skip to content

fix: token estimation accuracy, context length logging, batch integrity - #6629

Closed
aaronlab wants to merge 2 commits into
NousResearch:mainfrom
aaronlab:fix/token-estimation-batch-integrity
Closed

fix: token estimation accuracy, context length logging, batch integrity#6629
aaronlab wants to merge 2 commits into
NousResearch:mainfrom
aaronlab:fix/token-estimation-batch-integrity

Conversation

@aaronlab

@aaronlab aaronlab commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR addresses token estimation accuracy, debugging observability, and batch data integrity issues found during iteration #6 of a deep code audit:

  • Fix token estimation undercount (agent/model_metadata.py): estimate_tokens_rough() used floor division (len // 4), causing short texts to estimate as 0 tokens (e.g., "yes" → 0). This makes the compressor and pre-flight checks systematically undercount, especially with many short tool results. Switched to ceiling division ((len + 3) // 4).

  • Add warning log for silent 128K fallback (agent/model_metadata.py): get_model_context_length() silently returned 128K when all 10 detection methods failed, with zero logging. Users with small-context models (8K, 32K) would get 128K silently, causing API failures impossible to debug. Added a warning with model name and config hint.

  • Add fsync to batch trajectory writes (batch_runner.py): Trajectory entries were written without flush()/fsync(), but the checkpoint immediately marked them as completed. A crash between write and disk sync would leave the checkpoint claiming completion with no trajectory data on disk.

  • Add KeyboardInterrupt handling in batch pool (batch_runner.py): Ctrl+C during pool.imap_unordered() relied on context manager cleanup which can hang 180+ seconds. Added explicit pool.terminate() + join(timeout=10) for responsive shutdown.

Files Changed

File Change
agent/model_metadata.py Ceiling division for token estimation + warning log for 128K fallback
batch_runner.py fsync for trajectory writes + KeyboardInterrupt pool cleanup

Test plan

  • estimate_tokens_rough("yes") should return 1, not 0
  • estimate_tokens_rough("") should return 0
  • estimate_tokens_rough("hello world") should return 3 (11 chars → ceil(11/4) = 3)
  • Verify warning log appears when model context length can't be determined
  • Run batch with --batch_size 2 and Ctrl+C → verify responsive shutdown
  • Run existing test suite: pytest tests/

🤖 Generated with Claude Code

aaronlab and others added 2 commits April 9, 2026 20:54
…agent loop reliability

## Summary
Found 5 critical bugs in async error handling, context compression, and cron scheduling:

**CRITICAL (2):**
1. Role violation after context compression (context_compressor.py:694-728)
   - Tool message validation missing when merging summary
   - Causes API crash and data loss after compression

2. Double-execution race condition in cron scheduler (scheduler.py:843-892)
   - File lock released before job execution completes
   - Allows duplicate jobs to be executed (DoS, duplicate messages)

**HIGH (1):**
3. Unhandled context compression exceptions in main loop (run_agent.py:8204,8262,8338)
   - Silent crash when summarizer fails during API loop
   - No graceful degradation

**MEDIUM (2):**
4. Error swallowing in auxiliary_client (auxiliary_client.py:2074-2106)
   - Original error overwritten on retry failure
   - Lost error context, unreachable fallback logic

5. Session ID change without exception recovery (run_agent.py:6041-6071)
   - Session state corruption on DB failures
   - Broken session lineage

## Details
Full analysis with code snippets, scenarios, and fixes in:
- AUDIT_ITERATION_2.md (400 lines, detailed technical analysis)
- AUDIT_ITERATION_2_SUMMARY.txt (visual summary, testing recommendations)

## Recommended Priority
1. Bug NousResearch#1 (Role Violation) - FIX IMMEDIATELY
2. Bug NousResearch#2 (Double Execution) - FIX IMMEDIATELY
3. Bug NousResearch#3 (Unhandled Exceptions) - FIX SOON
4. Bug NousResearch#4 & NousResearch#5 - FIX AFTER critical bugs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tegrity

- Fix token estimation undercount (agent/model_metadata.py):
  estimate_tokens_rough() used floor division (len // 4), causing short
  texts to estimate as 0 tokens ("yes" → 0). This makes the compressor
  and pre-flight checks systematically undercount token usage, especially
  when many short tool results are present. Switched to ceiling division
  ((len + 3) // 4) so every non-empty text estimates at least 1 token.

- Add warning log for silent 128K context length fallback (agent/model_metadata.py):
  get_model_context_length() silently returned the 128K default when all
  10 detection methods failed, with zero logging. Users with small-context
  models (8K, 32K) would silently get 128K, causing API failures that were
  impossible to debug. Added a warning log with the model name and a hint
  to set context_length in config.yaml.

- Add fsync to batch trajectory writes (batch_runner.py):
  Trajectory entries were written without flush/fsync, while the checkpoint
  immediately marked them as completed. A crash between write and disk sync
  would leave the checkpoint claiming completion with no trajectory data.
  Added flush() + fsync() to ensure durability before checkpoint update.

- Add KeyboardInterrupt handling in batch pool execution (batch_runner.py):
  Ctrl+C during pool.imap_unordered() relied on context manager cleanup
  which can hang for 180+ seconds waiting for workers to join. Added
  explicit pool.terminate() + join(timeout=10) for both KeyboardInterrupt
  and Exception cases for responsive shutdown.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@SHL0MS SHL0MS mentioned this pull request Apr 11, 2026
2 tasks
teknium1 added a commit that referenced this pull request Apr 11, 2026
…rmula

Switch estimate_tokens_rough(), estimate_messages_tokens_rough(), and
estimate_request_tokens_rough() from floor division (len // 4) to
ceiling division ((len + 3) // 4). Short texts (1-3 chars) previously
estimated as 0 tokens, causing the compressor and pre-flight checks to
systematically undercount when many short tool results are present.

Also replaced the inline duplicate formula in run_conversation()
(total_chars // 4) with a call to the shared
estimate_messages_tokens_rough() function.

Updated 4 tests that hardcoded floor-division expected values.

Related: issue #6217, PR #6629
Tommyeds pushed a commit to Tommyeds/hermes-agent that referenced this pull request Apr 12, 2026
…rmula

Switch estimate_tokens_rough(), estimate_messages_tokens_rough(), and
estimate_request_tokens_rough() from floor division (len // 4) to
ceiling division ((len + 3) // 4). Short texts (1-3 chars) previously
estimated as 0 tokens, causing the compressor and pre-flight checks to
systematically undercount when many short tool results are present.

Also replaced the inline duplicate formula in run_conversation()
(total_chars // 4) with a call to the shared
estimate_messages_tokens_rough() function.

Updated 4 tests that hardcoded floor-division expected values.

Related: issue NousResearch#6217, PR NousResearch#6629
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint labels Apr 29, 2026
aj-nt pushed a commit to aj-nt/hermes-agent that referenced this pull request May 1, 2026
…rmula

Switch estimate_tokens_rough(), estimate_messages_tokens_rough(), and
estimate_request_tokens_rough() from floor division (len // 4) to
ceiling division ((len + 3) // 4). Short texts (1-3 chars) previously
estimated as 0 tokens, causing the compressor and pre-flight checks to
systematically undercount when many short tool results are present.

Also replaced the inline duplicate formula in run_conversation()
(total_chars // 4) with a call to the shared
estimate_messages_tokens_rough() function.

Updated 4 tests that hardcoded floor-division expected values.

Related: issue NousResearch#6217, PR NousResearch#6629
02356abc pushed a commit to 02356abc/hermes-agent that referenced this pull request May 14, 2026
…rmula

Switch estimate_tokens_rough(), estimate_messages_tokens_rough(), and
estimate_request_tokens_rough() from floor division (len // 4) to
ceiling division ((len + 3) // 4). Short texts (1-3 chars) previously
estimated as 0 tokens, causing the compressor and pre-flight checks to
systematically undercount when many short tool results are present.

Also replaced the inline duplicate formula in run_conversation()
(total_chars // 4) with a call to the shared
estimate_messages_tokens_rough() function.

Updated 4 tests that hardcoded floor-division expected values.

Related: issue NousResearch#6217, PR NousResearch#6629
gweeteve pushed a commit to gweeteve/hermes-agent that referenced this pull request Jun 2, 2026
…rmula

Switch estimate_tokens_rough(), estimate_messages_tokens_rough(), and
estimate_request_tokens_rough() from floor division (len // 4) to
ceiling division ((len + 3) // 4). Short texts (1-3 chars) previously
estimated as 0 tokens, causing the compressor and pre-flight checks to
systematically undercount when many short tool results are present.

Also replaced the inline duplicate formula in run_conversation()
(total_chars // 4) with a call to the shared
estimate_messages_tokens_rough() function.

Updated 4 tests that hardcoded floor-division expected values.

Related: issue NousResearch#6217, PR NousResearch#6629

@teknium1 teknium1 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.

Thanks for the durability and observability investigation. The token-estimation fix has already landed in current main as 5c2ecde (agent/model_metadata.py:2467, with coverage at tests/agent/test_model_metadata.py:51-53), while the final fallback and batch durability concerns still merit focused work.

Problems

  • batch_runner.py:948 and :953 call pool.join(timeout=10), but CPython's multiprocessing.pool.Pool.join accepts only self; both proposed exception paths would raise TypeError.
  • agent/model_metadata.py:971 says to set context_length, whereas current resolver guidance uses model.context_length (agent/model_metadata.py:2232-2236).
  • AUDIT_ITERATION_2.md and AUDIT_ITERATION_2_SUMMARY.txt add unrelated audit claims without implementing their listed gateway/compression fixes.
  • No tests cover the new fsync, fallback-log, or interrupt-cleanup paths.

Suggested changes

  • Isolate the still-relevant fallback diagnostic and trajectory durability work; remove the landed token patch and audit documents.
  • Use a valid Pool cleanup strategy and add regression coverage for interruption and checkpoint/output ordering.

Automated hermes-sweeper review.

Comment thread batch_runner.py
except KeyboardInterrupt:
print("\n⚠️ Interrupted — terminating batch workers...")
pool.terminate()
pool.join(timeout=10)

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.

multiprocessing.pool.Pool.join() has no timeout parameter (CPython defines join(self)), so this KeyboardInterrupt cleanup path raises TypeError after termination instead of preserving the interrupt.

Comment thread agent/model_metadata.py
# 10. Default fallback — 128K
logger.warning(
"Could not determine context length for model %s (base_url=%s) "
"— falling back to %d tokens. Set context_length in config.yaml "

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.

The configuration hint should name the actual key: model.context_length in config.yaml, matching the current resolver guidance.

@@ -0,0 +1,224 @@
================================================================================

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.

This audit adds unrelated gateway security findings, but this PR does not implement those gateway changes. Please split or remove it so the patch remains scoped to the token/context/batch work.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 12, 2026
@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Thanks for the investigation @aaronlab — the three concerns you identified are legitimate. Addressing the review feedback:

  1. Token estimation ceiling division — already landed on main as 5c2ecdec with additional CJK handling. Not duplicated.

  2. Pool.join(timeout=10) is invalid — confirmed. CPython's multiprocessing.pool.Pool.join signature is (self), no timeout parameter. Both exception paths would raise TypeError.

  3. Config guidance — the PR says context_length but the resolver uses model.context_length (agent/model_metadata.py:2232-2236).

  4. AUDIT_ITERATION_2.md / SUMMARY.txt — 624 lines of unrelated audit documents that shouldn't be in the repo.

I've salvaged the two still-relevant concerns (fallback diagnostic + batch durability) with the review issues fixed in #76027:

  • Warning log at step 9 fallback with correct model.context_length guidance and 256K default (not 128K — the default was raised)
  • f.flush() + os.fsync() for trajectory writes
  • pool.terminate() + pool.join() (no timeout) for KeyboardInterrupt/Exception paths
  • 7 regression tests covering all three changes

Your co-authorship is preserved in the commit. Closing this in favor of #76027.

Regarding #6622 — it does not supersede this PR. They touch completely different files and concerns:

Both PRs share the same audit documents but their code changes are independent.

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Closing in favor of #76027 (salvage with review issues fixed). Credit to @aaronlab for the original investigation.

kshitijk4poor added a commit to kshitijk4poor/hermes-agent that referenced this pull request Aug 1, 2026
…ol cleanup

Salvage of NousResearch#6629 by aaronlab (kshitijk4poor reworked against current main).

Three concerns from the original PR, reworked to address review feedback:

1. Context-length fallback diagnostic (agent/model_metadata.py):
   get_model_context_length() silently returned 256K when all 9 detection
   methods failed. Users with small-context models (8K, 32K) would get 256K
   silently, causing hard-to-debug API context-length errors. Added a
   warning log at the step 9 fallback with model name, base_url, and the
   correct config override hint (model.context_length, not context_length).
   The token-estimation ceiling-division fix from the original PR already
   landed on main (5c2ecde) with CJK handling — not duplicated here.

2. Fsync for batch trajectory writes (batch_runner.py):
   Trajectory entries were written without flush/fsync, but the checkpoint
   immediately marked them as completed. A crash between write and disk
   sync would leave the checkpoint claiming completion with no trajectory
   data on disk. Added flush() + os.fsync() before checkpoint update.

3. Pool cleanup on interruption (batch_runner.py):
   Ctrl+C during pool.imap_unordered() relied on context manager cleanup
   which can hang. Added explicit pool.terminate() + pool.join() for both
   KeyboardInterrupt and Exception paths. The original PR used
   pool.join(timeout=10) which is invalid — CPython's Pool.join() takes
   no timeout parameter. Fixed to use pool.join() without arguments.

Tests:
  - test_warning_emitted_on_fallback: verifies warning fires at step 9
  - test_no_warning_when_cached: verifies no false warning when cache hits
  - test_trajectory_entry_is_synced_to_disk: verifies os.fsync is called
  - test_pool_terminate_called_on_exception: verifies cleanup on RuntimeError
  - test_pool_terminate_called_on_keyboard_interrupt: verifies cleanup on Ctrl+C
  - test_pool_join_called_without_timeout: verifies no timeout arg to join()
  - test_real_pool_join_accepts_no_timeout: integration check on CPython API

Co-authored-by: Aaron Lab <aaronlab@users.noreply.github.com>
kshitijk4poor added a commit that referenced this pull request Aug 1, 2026
…ol cleanup

Salvage of #6629 by aaronlab (kshitijk4poor reworked against current main).

Three concerns from the original PR, reworked to address review feedback:

1. Context-length fallback diagnostic (agent/model_metadata.py):
   get_model_context_length() silently returned 256K when all 9 detection
   methods failed. Users with small-context models (8K, 32K) would get 256K
   silently, causing hard-to-debug API context-length errors. Added a
   warning log at the step 9 fallback with model name, base_url, and the
   correct config override hint (model.context_length, not context_length).
   The token-estimation ceiling-division fix from the original PR already
   landed on main (5c2ecde) with CJK handling — not duplicated here.

2. Fsync for batch trajectory writes (batch_runner.py):
   Trajectory entries were written without flush/fsync, but the checkpoint
   immediately marked them as completed. A crash between write and disk
   sync would leave the checkpoint claiming completion with no trajectory
   data on disk. Added flush() + os.fsync() before checkpoint update.

3. Pool cleanup on interruption (batch_runner.py):
   Ctrl+C during pool.imap_unordered() relied on context manager cleanup
   which can hang. Added explicit pool.terminate() + pool.join() for both
   KeyboardInterrupt and Exception paths. The original PR used
   pool.join(timeout=10) which is invalid — CPython's Pool.join() takes
   no timeout parameter. Fixed to use pool.join() without arguments.

Tests:
  - test_warning_emitted_on_fallback: verifies warning fires at step 9
  - test_no_warning_when_cached: verifies no false warning when cache hits
  - test_trajectory_entry_is_synced_to_disk: verifies os.fsync is called
  - test_pool_terminate_called_on_exception: verifies cleanup on RuntimeError
  - test_pool_terminate_called_on_keyboard_interrupt: verifies cleanup on Ctrl+C
  - test_pool_join_called_without_timeout: verifies no timeout arg to join()
  - test_real_pool_join_accepts_no_timeout: integration check on CPython API

Co-authored-by: Aaron Lab <aaronlab@users.noreply.github.com>
@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Merged via #76027 (rebase) — the still-relevant parts of this PR are now on main:

  • Context-length fallback warning (both the step-9 default and the sibling custom-endpoint probe-down path, deduped per model+endpoint)
  • fsync durability for batch trajectory writes
  • Pool terminate/join cleanup on interruption (with the corrected join() signature)

Your investigation and authorship are preserved — commit a1ff62a on main carries your Co-authored-by credit. Thanks @aaronlab!

randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…ol cleanup

Salvage of NousResearch#6629 by aaronlab (kshitijk4poor reworked against current main).

Three concerns from the original PR, reworked to address review feedback:

1. Context-length fallback diagnostic (agent/model_metadata.py):
   get_model_context_length() silently returned 256K when all 9 detection
   methods failed. Users with small-context models (8K, 32K) would get 256K
   silently, causing hard-to-debug API context-length errors. Added a
   warning log at the step 9 fallback with model name, base_url, and the
   correct config override hint (model.context_length, not context_length).
   The token-estimation ceiling-division fix from the original PR already
   landed on main (938c90a) with CJK handling — not duplicated here.

2. Fsync for batch trajectory writes (batch_runner.py):
   Trajectory entries were written without flush/fsync, but the checkpoint
   immediately marked them as completed. A crash between write and disk
   sync would leave the checkpoint claiming completion with no trajectory
   data on disk. Added flush() + os.fsync() before checkpoint update.

3. Pool cleanup on interruption (batch_runner.py):
   Ctrl+C during pool.imap_unordered() relied on context manager cleanup
   which can hang. Added explicit pool.terminate() + pool.join() for both
   KeyboardInterrupt and Exception paths. The original PR used
   pool.join(timeout=10) which is invalid — CPython's Pool.join() takes
   no timeout parameter. Fixed to use pool.join() without arguments.

Tests:
  - test_warning_emitted_on_fallback: verifies warning fires at step 9
  - test_no_warning_when_cached: verifies no false warning when cache hits
  - test_trajectory_entry_is_synced_to_disk: verifies os.fsync is called
  - test_pool_terminate_called_on_exception: verifies cleanup on RuntimeError
  - test_pool_terminate_called_on_keyboard_interrupt: verifies cleanup on Ctrl+C
  - test_pool_join_called_without_timeout: verifies no timeout arg to join()
  - test_real_pool_join_accepts_no_timeout: integration check on CPython API

Co-authored-by: Aaron Lab <aaronlab@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants