Skip to content

hermes: the first Python harness — and the streaming exchange orca was dropping - #33

Open
xizhuomengcontin wants to merge 4 commits into
mainfrom
hermes-adapter
Open

hermes: the first Python harness — and the streaming exchange orca was dropping#33
xizhuomengcontin wants to merge 4 commits into
mainfrom
hermes-adapter

Conversation

@xizhuomengcontin

@xizhuomengcontin xizhuomengcontin commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Orca-Code-Review — push 1

Severity Count
P0 0
P1 1
P2 0
P3 0

❌ 1 finding blocks merge

Four commits. The first is the one that matters beyond Hermes; the last two came out of running
the result rather than reading it.

The bug

An intercepted streaming response was recorded only if it reached end. A harness that stops
reading the moment it has its answer never gets there, so the run kept nothing — and then said:

warn capture.empty exchanges=0 cause="the agent never called the proxy — it may not read a base-URL variable"

which is the opposite of what happened. orca had decrypted the request, forwarded it, and seen the
response headers. Any harness that abandons an SSE stream early hits this; Hermes does it on every
turn.

Instrumenting the CONNECT handler is what settled it — three connections, all allowed=true, two
POST /zen/v1/chat/completions arriving at the intercepting server with no TLS error, and zero
events in the trace. Both forwarders now hoist what they have read out of the response handler and
record it once, from whichever path settles first. NetExchange.abandoned says which happened:
the agent stopped asking for bytes, as against responseTruncated, where orca stopped keeping
them. The h2 path had the same gap and is fixed the same way.

One follow-on in the same commit: server.ts stops promoting a response-less call to a model
exchange.
A POST abandoned before the origin answered satisfies the dialect check on its path
alone, and promoting it puts an entry in the replay set that can answer nothing and cannot be
forked. On a real Hermes run this is the difference between one clean model exchange plus one
recorded probe
and three model exchanges, two of them empty.

Worth a reviewer's eye: the client-gone signal is res.destroyed and deliberately not
req.destroyed. A request with no body destroys itself once read, so asking about req turns
every upstream failure — an origin certificate orca refuses to trust, most of all — into a silent
abandonment with no 502 for the client. The repo's existing test for that refusal caught the first
draft.

What running it turned up

The last two commits are things the first one broke or left unsaid, found by recording, replaying
and forking a real run rather than by re-reading the diff.

  • The timeline could not say what had happened. An abandoned exchange files as status 0, and
    orca show rendered exactly that — indistinguishable from a failure the run never had. The row
    now reads status 0 · client left, and stays normal tone: the agent leaving early is its own
    behaviour, and marking it attention would train the eye to ignore it on runs where every turn
    ends this way.
  • warnUnclaimed started giving advice that could not help. Forking a Hermes recording printed
    no wire dialect claims this path for /zen/v1/chat/completions — a path the openai dialect had
    claimed one event earlier in the same run. It fired because the abandoned probe now reaches
    onNetExchange by design. It now skips a status === 0 exchange, which is the same condition
    that made the promotion skip it; a response that did arrive on an unrecognised path still warns.

The adapter

Hermes is the first Python harness here, and the first with no base-URL variable to move at
all
. OPENAI_BASE_URL appears in one place in the shipped source — an advisory warning for when
it is set and the provider is not custom — and the origin lives in config.yaml under
model.base_url, which is what the official configuration
reference
describes. Verified
against a listener rather than inferred: point model.base_url at a dead port and
OPENAI_BASE_URL at a live one and the run dies on the dead port with the listener untouched,
--provider custom included. orca does not rewrite a user's global config, so the adapter
redirects nothing and --tls-intercept is the route.

No CA handling was needed, and an earlier revision of this branch wrongly added some. Hermes
sends the catalogue read through requests and the completion through httpx under the OpenAI SDK,
and httpx pins certifi — so SSL_CERT_FILE looks like it should not reach it. It does: Hermes
resolves that client's CA itself, and the chain in agent/ssl_verify.py reads HERMES_CA_BUNDLE,
then SSL_CERT_FILE, then REQUESTS_CA_BUNDLE, then CURL_CA_BUNDLE. I had added
HERMES_CA_BUNDLE to setupTlsCapture believing it was the missing piece; removing it again and
re-recording gave the same single model exchange, status 200, 10,615 input tokens. The variable is
gone from this branch and attach.ts is untouched. The missing completion had exactly one cause,
which is the bug above.

inference-api.nousresearch.com joins DEFAULT_TLS_HOSTS. portal.nousresearch.com deliberately
does not — billing, subscription management, and where the API key is issued.

Verification

Hermes Agent v0.21.0 (2026.8.31), installed from git, on the opencode-free tier that is
served anonymously so no credential enters the recording:

orca record hermes --tls-intercept --tls-hosts '+opencode.ai' -- \
  --provider opencode-free -m nemotron-3.5-lightning-free -z 'Compute 212000 plus 8 …'
model exchange 42,305 bytes · 19 tools · status 200 · streamed
system prompt 7,742 chars
usage 10,615 in / 88 out
orca replay reused 1/1, exit 0
orca replay --from 5 --model deepseek-v4-flash-free fork exit 0
abandoned probe recorded as net.* with abandoned=true, status=0

Regression-tested against harnesses that were already working, since this rewrites the forwarder
every transport capture goes through:

record kilo --tls-intercept 2 model exchanges, 8,037 in / 27 out; replay reused=2/2, unmatched=0, exit 0
record opencode --tls-intercept model exchange 8,144 in / 69 out; the h2 ECONNRESET warnings it prints are on main too
record generic-openai (plain HTTP, no interception) model exchange, status 200, streamed

opencode.ai is passed explicitly rather than defaulted in, being a website as much as a gateway.

The prompt lands in prompt/HERMES/, which puts one free model in the table twice — 9,655 chars
/ 11 tools from OpenCode against 7,742 / 19 from Hermes
. Same model, same anonymous endpoint; the
harness is the only variable.

Tests

Five new cases, each mutation-checked — reverting the fix it covers turns exactly that test red and
nothing else:

  • records a streaming exchange the client walks away from mid-flight
  • keeps a model call abandoned before the origin answered as network traffic
  • records an h2 exchange the client abandoned before the origin answered
  • says a response was abandoned rather than showing a bare status 0
  • stays quiet about a call the agent abandoned before the origin answered

Full suite compared per-test against main: 23 failures on each, the same 23 — every one a
pre-existing Windows failure (0o600 file modes, symlinks). No regressions and no differences.

🤖 Generated with Claude Code

@orcacode-review orcacode-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Outdated review — the PR head changed from 8399fb1 to 5483933 during the review; these findings describe a diff the PR no longer has.
These findings describe 8399fb18773e. Re-run the review to check the PR as it stands now.

🐳 OrcaCode Review

No findings — nothing to flag in this PR. Great work!

OrcaCode Review — Route Smarter. Ship Safer. Spend Less.
Engine-reported: 280 calls · 14.2M tokens · 98% cached

❤️ Share · Install OrcaCode Review

Free on GitHub — the review runs on your own OrcaRouter key. If it helped, a shout-out goes a long way.

Share: X · Reddit · LinkedIn
Follow: X · Discord · LinkedIn · OrcaRouter

@orcacode-review orcacode-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐳 OrcaCode Review

No findings — nothing to flag in this PR. Great work!

OrcaCode Review — Route Smarter. Ship Safer. Spend Less.
Engine-reported: 304 calls · 16.6M tokens · 98% cached

❤️ Share · Install OrcaCode Review

Free on GitHub — the review runs on your own OrcaRouter key. If it helped, a shout-out goes a long way.

Share: X · Reddit · LinkedIn
Follow: X · Discord · LinkedIn · OrcaRouter

xizhuomengcontin and others added 2 commits September 5, 2026 15:35
An intercepted streaming response was recorded only when it reached `end`. A harness that stops
reading the moment it has its answer never gets there, so the run kept nothing at all -- and then
reported `capture.empty` with "the agent never called the proxy", about a request orca had itself
decrypted and forwarded. That message is the opposite of what happened, which is the part that
made this expensive to find.

Hermes is the harness that showed it. Its answer arrives over SSE and its client leaves as soon as
the answer is complete; the proxy had the request body, the response headers and a prefix of the
body in hand, and dropped all three. Instrumenting the CONNECT handler is what settled it: three
connections, all `allowed=true`, two `POST /zen/v1/chat/completions` arriving at the intercepting
server with no TLS error, and zero events in the trace.

So both forwarders now hoist what they have read out of the response handler and record it once,
from whichever path settles first. `NetExchange` gains `abandoned`, which is a different fact from
`responseTruncated`: one says the agent stopped asking for bytes, the other says orca stopped
keeping them. The h2 path had the same gap -- its `abandon()` destroyed the upstream and resolved
without recording -- and is fixed the same way.

One follow-on change, in `server.ts`: a response-less call is no longer promoted to a model
exchange. A POST abandoned before the origin answered satisfies the dialect check on its path
alone, and promoting it puts an entry in the replay set that can answer nothing and cannot be
forked. It is kept as network traffic, which is what it is. In a real Hermes run this is the
difference between one clean model exchange plus one recorded probe, and three model exchanges of
which two are empty.

The client-gone signal is `res.destroyed` and deliberately not `req.destroyed`. A request with no
body destroys itself once read, so asking about `req` turned every upstream failure -- an origin
certificate orca refuses to trust, most of all -- into a silent abandonment with no 502 for the
client to see. The repo's own test for that refusal is what caught it.

The three new tests were mutation-checked: reverting the client-gone listener, the h2 `emit`, or
the `status !== 0` guard each turns exactly the matching test red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other adapter here fronts a Node harness, and that is most of what makes this one different.

Hermes has no base-URL variable to move. `OPENAI_BASE_URL` is read in exactly one place in the
shipped source -- an advisory warning that fires when it is set and the provider is not `custom` --
and the origin itself lives in `config.yaml` under `model.base_url`, which is what the official
configuration reference describes. That was established against a listener rather than inferred:
with `model.base_url` pointed at a dead port and `OPENAI_BASE_URL` at a live one, the run dies on
the dead port and the listener records nothing, `--provider custom` included. orca does not rewrite
a user's global config, so the adapter redirects nothing.

What works is the transport. Hermes honours `HTTPS_PROXY` all the way down -- through a proxy
logging CONNECT targets, one question sends two of them to the model origin, the catalogue read and
the completion -- so `--tls-intercept` reaches it.

The trust store needs nothing added, which is worth recording because it looks as though it would.
The catalogue goes out through `requests` and the completion through httpx under the OpenAI SDK,
and httpx pins certifi. But Hermes resolves that client's CA itself, and the chain in
`agent/ssl_verify.py` reads `HERMES_CA_BUNDLE`, then `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`,
then `CURL_CA_BUNDLE` -- orca already sets the second. An earlier draft of this branch added the
vendor variable to `setupTlsCapture` on the theory that it was needed; removing it again and
re-recording produced the same single model exchange, status 200, 10,615 input tokens, so it is
not here.

`inference-api.nousresearch.com` joins `DEFAULT_TLS_HOSTS` so Hermes on Nous' own models needs no
extra `--tls-hosts`. Deliberately not joining it: `portal.nousresearch.com`, which is billing and
subscription management and where the API key is issued -- the same line this list draws at every
other vendor's console.

Verified against Hermes Agent v0.21.0 (2026.8.31) by running it, on the `opencode-free` tier that
is served anonymously so no credential enters the recording:

    orca record hermes --tls-intercept --tls-hosts '+opencode.ai' -- \
      --provider opencode-free -m nemotron-3.5-lightning-free -z 'Compute 212000 plus 8 ...'

One model exchange: 42,305 bytes, 19 tools, a 7,742-character system prompt, answered streamed
with status 200 for 10,615 input and 88 output tokens -- and `orca replay` reused 3/3. `opencode.ai`
is named explicitly rather than defaulted in, being a website as much as a gateway.

The prompt is filed under `prompt/HERMES/`, which puts the same free model in the table twice: 9,655
characters and 11 tools from OpenCode against 7,742 and 19 from Hermes. Same model, same anonymous
endpoint -- the harness is the only variable, and that is the pair that isolates it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
xizhuomengcontin and others added 2 commits September 5, 2026 15:55
The previous commit records an exchange the agent walked away from, and files it with `status 0`
because no response header ever arrived. On the timeline that renders as `status 0` and nothing
else, which reads like a failure the run never had -- the agent left on purpose, having got what
it asked for.

So the row says `status 0 · client left`. Tone stays normal: leaving early is the agent's own
behaviour, not something orca did wrong, and marking it attention would train the eye to ignore
the marker on runs where every turn ends this way. A Hermes run has two of these per question.

Mutation-checked: dropping the marker turns exactly this test red.

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

Follow-on to the abandoned-exchange fix, and found by forking a real recording rather than by
reading the code. `warnUnclaimed` fires for any decrypted POST with a JSON body that reaches
`onNetExchange`, and since that commit a call the agent abandoned before the origin answered lands
there too — deliberately, because an exchange with no response cannot be a model exchange whatever
dialect exists.

The warning said the opposite:

    warn tls.unclaimed_path path=/zen/v1/chat/completions
      detail="decrypted and recorded, but no wire dialect claims this path"
      next="docs/plugins.md to add a dialect for it"

That path is claimed. The openai dialect had already recognised the same path in the same run, one
event earlier. Writing a plugin would change nothing, because there is no response to parse — and
Hermes produces two of these per question, so the advice would print on every turn of every run.

So the warning skips a `status === 0` exchange, which is exactly the condition that made the
promotion skip it. A response that did arrive on an unrecognised path still warns; the existing
test for that is unchanged and still passes.

Testing it needed two small things in the fixtures: an origin that never answers, so the state can
be reached without racing a fast one, and a `leave` mode in the shared proxy caller for a client
that stops after sending its request. Mutation-checked: removing the guard turns the new test red
and nothing else.

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

@orcacode-review orcacode-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Outdated review — the PR head changed from cd35c7e to 2bb5ef1 during the review; these findings describe a diff the PR no longer has.
These findings describe cd35c7e4abc2. Re-run the review to check the PR as it stands now.

🐳 OrcaCode Review

No findings — nothing to flag in this PR. Great work!

OrcaCode Review — Route Smarter. Ship Safer. Spend Less.
Engine-reported: 314 calls · 18.9M tokens · 98% cached

❤️ Share · Install OrcaCode Review

Free on GitHub — the review runs on your own OrcaRouter key. If it helped, a shout-out goes a long way.

Share: X · Reddit · LinkedIn
Follow: X · Discord · LinkedIn · OrcaRouter

@orcacode-review orcacode-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐳 OrcaCode Review

Found 1 issue in this PR: 🟠 1 P1.

OrcaCode Review — Route Smarter. Ship Safer. Spend Less.
Engine-reported: 330 calls · 18.3M tokens · 98% cached

❤️ Share · Install OrcaCode Review

Free on GitHub — the review runs on your own OrcaRouter key. If it helped, a shout-out goes a long way.

Share: X · Reddit · LinkedIn
Follow: X · Discord · LinkedIn · OrcaRouter

}
});
originRes.on('error', reject);
originRes.on('aborted', abandon);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 P1 HTTP/1.1: do not treat an origin-side mid-response abort as client abandonment

New in this commit (the old code had no 'aborted' listener and rejected on originRes 'error', so the server's catch wrote a 502). When the ORIGIN connection dies after response headers but before the body completes (RST or premature FIN — reproduced on Node 22: the proxy's originRes emits 'aborted' first, then 'error' ECONNRESET), this fires abandon(), which records the exchange and calls resolve() — but the downstream client is still connected and waiting. abandon() never writes to res; the later originRes 'error' handler's reject(err) is a no-op because the promise already resolved, so the server-level catch that would have sent a 502 never runs. Verified with a plain-HTTP simulation of this exact wiring: after "originRes aborted", no 502 is ever sent and the client waits indefinitely (in the simulation it was still waiting after 8 s; real agents' SSE reads typically have no or very long timeouts). Before this commit the same origin death produced a prompt 502. The net effect: any origin crash/network break mid-stream on the HTTP/1.1 interception path silently hangs the agent's in-flight call, and files a trace record claiming "abandoned: client left" (the client never left). This is precisely the "no 502 for the client to see" failure the new upstream-error handler comments warn against, re-introduced on the response object. Fix: on 'aborted' only abandon when the downstream client is actually gone, otherwise reject so the caller's 502 path runs: originRes.on('aborted', () => { if (res.destroyed) abandon(); else reject(new Error('origin aborted mid-response')); });.

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