sayo

A Silent Agent, a Token Quota, and the Changes I Refused

I sent a message to my Feishu agent on a Thursday evening. I wanted it to organize four articles into a wiki page.

I waited. Nothing arrived.

Five minutes later, one line arrived in the chat:

Custom endpoint rejected your API key, so the model can't be reached.
Provider said: HTTP 403: total_tokens quota exceeded: 10054233/10000000

That message points at the wrong problem. My API key was fine. The quota was full.

The gateway log held the real cause:

2026-09-18 22:15:39,925 ERROR agent.chat_completion_helpers:
  Streaming failed before delivery: Error code: 403 -
  {'error': {'code': 'quota_exceeded',
             'message': 'total_tokens quota exceeded: 10054233/10000000',
             'type': 'quota_exceeded_error'}}

My first thought was not about the model. It was about the meter.

If you only want the result, read "The Result". The rest of this post explains why I removed some changes before I added others.

What the thing is#

I wanted an assistant I could task from my phone. I did not want to sit at a terminal.

The chain is short:

Feishu app  ->  Hermes Agent gateway  ->  AxonHub proxy  ->  model provider

The versions matter for anyone who wants to repeat this work:

PartVersion
Hermes Agent0.21.3, commit c62bd9f2078a946108f1c9d9b24bf118963277ef
Python3.11.12
AxonHubv1.0.0-beta10, image looplj/axonhub:v1.0.0-beta10
AxonHub databasepostgres:16-alpine, in Docker
Chat platformFeishu (Lark), websocket mode
Modeldeepseek/deepseek-v4.1-flash
HostmacOS

AxonHub is an OpenAI-compatible proxy. It listens on http://127.0.0.1:8090/v1. The gateway holds one configuration block for it:

model:
  default: deepseek/deepseek-v4.1-flash
  provider: axonhub
  context_length: 1000000        # I will come back to this line
providers:
  axonhub:
    api: http://127.0.0.1:8090/v1
    api_key: ${env:AXONHUB_API_KEY}

The proxy gives each API key a daily token quota. That quota was the only guardrail on cost.

I treated ten million tokens a day as generous. That judgement was wrong. The next sections show why.

My first move#

I did not read the logs myself. I gave the agent three tasks. Find why the reply stopped. Find where the tokens went. Compare our numbers with common practice.

I gave the agent one constraint. I do not accept a change that only looks correct.

The agent answered in one minute. The proxy was over quota.

That answer is a symptom, not a cause. A quota can only fill if something spends it. I wanted the cause.

This part can expand: I can explain why I asked for attribution before repair.

Where the tokens went#

The agent read the proxy database and the gateway logs. I checked each number against its source.

The first number is the strange one. The day used 10,054,233 tokens. Of that, 94.4% were cached prompt tokens.

A cached token costs almost nothing. The proxy charged it in full.

The second number explains the first. A turn is one request and the agent's full answer. An iteration is one model call inside a turn.

Here is the gateway log for one turn. This turn cost 1,702,841 tokens:

API call #1:  in=104495 out=636  total=105131  cache=104192/104495 (100%)
API call #2:  in=105575 out=497  total=106072  cache=105088/105575 (100%)
API call #3:  in=106230 out=472  total=106702  cache=105984/106230 (100%)
API call #4:  in=107335 out=290  total=107625  cache=106624/107335 (99%)
API call #5:  in=112334 out=152  total=112486  cache=107520/112334 (96%)
API call #6:  in=119461 out=651  total=120112  cache=112384/119461 (94%)
   ... 15 iterations in total, ending at in=135202

Read the in= column. The context grows on every iteration. The agent sends the whole history again, every time.

The cache column shows the second problem. Almost every input token is a cache hit. The model reads it cheaply. The quota charges it in full.

The third number is the important one. The compression feature never ran. Not once.

Compression replaces old turns with a summary. The trigger is a fraction of the context window:

compression.threshold       = 0.5
model.context_length        = 1,000,000
trigger                     = 0.5 x 1,000,000 = 500,000 tokens
largest context we observed = 135,202 tokens

The trigger sat at 500,000. The session never reached 135,202. So the trigger never fired.

The window value was also wrong. The gateway log says so on every session start:

Could not detect context length for model 'deepseek/deepseek-v4.1-flash'
  at http://127.0.0.1:8090/v1 - defaulting to 256,000 tokens (probe-down).
  Set model.context_length in config.yaml to override.
Using hardcoded context length 1,000,000 for model 'deepseek/deepseek-v4.1-flash'
  (custom endpoint, catalog match on 'deepseek-v4.1-flash')

Auto-detection failed against the proxy. The code fell back to a hardcoded catalog value of 1,000,000. The official documentation tells you to leave context_length unset and let Hermes detect the value. That advice assumes detection works. On this host it does not.

So the session grew for four hours. Every iteration carried the whole history.

The three decisions#

Decision one: raise the quota, or change the meter?#

A raise hides the problem. It gives me time and keeps a bad meter.

A new meter costs more work. The proxy must count a different unit.

The AxonHub quota is a JSON object on the API key row. It accepts three units: requests, tokens, and cost. The row looked like this:

{
  "period": {"type": "calendar_duration", "calendarDuration": {"unit": "day"}},
  "totalTokens": 10000000
}

I changed it to this:

{
  "period": {"type": "calendar_duration", "calendarDuration": {"unit": "day"}},
  "requests": 2000
}

The day had used 230 requests at that point. A limit of 2,000 gives room.

I chose requests for one reason. A token meter punishes a long session twice. It charges for the resend, and it charges for the cache hit that the resend produces. A request meter counts the work and ignores the cache.

Decision two: change a setting, or change the agent?#

The agent offered two kinds of proposal. The first kind was a setting with a number in it. The second kind was an instruction that told the agent to use fewer tools.

I can measure the first kind. I cannot measure the second kind.

I took the settings. I refused the instruction.

An instruction to use fewer tools may also make the agent stop investigating too early. A cost cut and a lazy agent look the same in a token report. I had no test that could tell them apart.

Decision three: do I build a test first?#

I decided that every later change needs a baseline.

Without a baseline, I cannot separate a saving from a failure. That decision set every trade-off below.

What I refused#

The agent proposed a hard limit on iterations. It wanted to cut agent.max_turns from 500 to 40.

The official config example recommends a range of 20 to 30 for focused work. That range looked sensible, so I nearly took it.

I asked the agent to count the iterations in our own logs instead. The command was simple:

❯ grep -h "response ready" ~/.hermes/logs/gateway.log \
    | grep -oE "api_calls=[0-9]+" | cut -d= -f2 | sort -n

The logs held 14 turns. The largest turn used 16 iterations. No turn passed 20.

A limit of 40 would never fire. It would add a risk and no saving. I refused it.

The agent proposed a rule for SOUL.md. That file controls the agent's voice. The rule said to reach for a tool only when the answer is not in front of you.

The official guide warns against rules that "micro-manage every response shape". A rule about tool use also changes how the agent investigates. I refused it.

The agent edited the AxonHub database directly. I had not asked for that. I stopped the work. I told the agent to revert the change, then compare the row with the backup, byte for byte. The revert was clean.

Each refusal lowers the ceiling of this round of work. I accepted that cost. A change I cannot measure is not a change I can defend.

The Result#

The settings I did change#

model:
  default: deepseek/deepseek-v4.1-flash
  provider: axonhub
  # context_length deleted - let Hermes detect it

compression:
  enabled: true
  threshold: 0.5
  threshold_tokens: 60000          # new absolute cap; fires at the lower of the two
  target_ratio: 0.2
  protect_last_n: 20
  min_tail_user_messages: 2
  max_attempts: 6
  proactive_prune_tokens: 48000    # prune stale tool output without a model call
  proactive_prune_min_result_chars: 8000
  proactive_prune_min_reclaim_tokens: 4096
  protect_first_n: 0               # stop pinning the first turns of a long session
  idle_compact_after_seconds: 1800 # compress a session that returns after 30 minutes idle

platform_toolsets:
  feishu:                          # explicit list, replacing the 64-entry platform preset
    [web, terminal, file, skills, code_execution, clarify,
     todo, session_search, cronjob, feishu_doc, feishu_drive]

skills:
  external_dirs: [~/.agents/skills]  # mount a shared skill directory

Compression now fires on every message. The log proves it:

Preflight compression: ~135,813 tokens >= 60,000 threshold
  (model deepseek/deepseek-v4.1-flash, ctx 1,000,000)
context compression done: messages=156->68 rough_tokens=~49,467

The session went from 156 turns to 68, and the context fell to about 49,000 tokens.

The numbers#

MeasureBeforeAfterChange
Context per iteration~113,000 tokens~50,000 tokens-56%
One turn of the same kind1,702,841 tokens430,501 tokens-75%
Tool schema per iteration42,882 bytes, 25 tools24,425 bytes, 15 tools-43%
Fixed cost per iteration~15,200 tokens~10,300 tokens-32%
Compressionnever ranchecked on every message-

I measured the fixed cost with the built-in tool:

❯ hermes prompt-size --platform feishu
  System prompt total :   16,708 B
    skills index      :    5,742 B
  Tool schemas        :   42,882 B  (25 tools)   # before

❯ hermes prompt-size --platform feishu
  System prompt total :   21,630 B
    skills index      :   10,662 B
  Tool schemas        :   24,425 B  (15 tools)   # after

One number is not measured#

I also mounted a shared skill directory. That change added 29 skills to the index and raised the fixed cost by about 1,230 tokens per iteration.

I expect that mount to remove wasted tool calls. I found the reason to try it in the logs. Of 160 terminal calls, 63 calls (39%) did not do the task:

Kind of callCountShare
--help probes2516%
Feishu OAuth setup2516%
sleep and wait loops138%

The agent ran 25 --help probes because Hermes could not read the shared skill directory. The directory holds the command reference for the lark-cli tool. With no reference, the agent guessed commands and read the help text instead.

I wrote a small counter for this measure:

❯ python3 ~/.hermes/ops/terminal_waste.py --since "2026-09-18 23:30"

The counter prints one line per session, with a column for each kind of waste. I have not run a real task since the mount. I will not claim that saving yet.

What I learned#

A token quota is a bad guardrail. It puts a cheap resend and an expensive generation on the same meter. The cheap one wins by volume.

Four changes I would make at the start, not at the end:

  1. Install cost monitoring on day one. My first good data followed a failure.
  2. Reset the session at each task boundary. One session ran for four hours.
  3. Ask for a baseline before I approve a change. I had none until the quota filled.
  4. Define a quality measure next to the cost measure. A cost cut alone can mean the agent became lazy.

Who did what#

Four sources supplied the diagnosis. The official Hermes documentation. Six public issue reports. The source code of a second agent harness. A community directory.

The agent ran the commands, read the logs, changed the files, and wrote the measurement script.

I set the decisions, the refusals, and the test standard.

The most expensive part of this work was not a setting. It was the changes I refused.

Three parts of this post can expand. Ask for any of them. The remaining settings I reviewed, with the value I chose and the reason. How to design a test set that measures cost and quality together. The method to collect these numbers on your own machine. Is this the level of depth you are looking for?