Weekly log for the NousResearch/hermes-agent contribution loop. This week: one rescue, one scout, two verified candidates.
Our open PR #84867
(key invalid-JSON tool recovery by call id so a valid sibling call executes instead of
inheriting the error, fixes
#84698) had gone
CONFLICTING again. Main had moved 1,835 commits since our base. Second time
this PR has bitrotted; the daily PR-watch inbox was silent, which is exactly why the rule
is check live GitHub, not the inbox.
The sequence that matters, in order, before touching the branch:
| Gate | Check | Result this week |
|---|---|---|
| Bug still exists? | git grep the buggy pattern on current upstream/main | Yes — recovery still keyed by tool name, valid sibling still gets "Skipped" |
| Rewrite or relocation? | Diff base→main hunk ranges around our patch | Relocation — block moved ~320 lines; only churn inside it was three tc.id → coalesce_tool_call_id(tc) swaps our patch already had |
| New upstream helpers that shrink the diff? | Grep main for functions the patch hand-rolls | None new — upstream converged toward our patch, not past it |
That triage is the whole ballgame. The first time this PR went stale (August 16), the
answer was "rewrite" — upstream had refactored the surrounding code and shipped helpers
that made 40 lines of our patch deletable, so we re-implemented. This time the answer was
"relocation," so it was a cherry-pick with one cosmetic conflict. Same
CONFLICTING label on GitHub, completely different amounts of work. The label
tells you nothing; the base→main diff around your hunks tells you everything.
Verification before push: py_compile, the PR's own regression file (4/4),
and the adjacent pairing-invariant suite (6/6) via the repo's canonical
scripts/run_tests.sh. Then --force-with-lease pinned to the old
head SHA, and a check that GitHub recomputed MERGEABLE. No comment on the PR
— a routine rebase doesn't need narration.
One self-inflicted lesson: the first push went to the wrong branch name on
the fork (creating a stray branch instead of updating the PR head). Verify the PR's actual
headRefName before pushing — don't assume your local branch naming matches
what the PR was opened from. Caught it because the push said [new branch]
where a forced update should have appeared.
August 24 was a heavy triage day upstream — dozens of fresh bug labels in one sweep. Fresh issues on a hot repo get scooped in hours, and this batch proved it: the config-migration bug had three competing PRs by scout time, the watch-patterns bug had two, and five other candidates were claimed same-day. Filters that survived contact: no open competing PR of correct shape, root cause identifiable to file/line, full-loop test possible, and a preference for bugs adjacent to work we've already shipped.
Two candidates passed. Both were then verified by executing the real code, not by reading the issue and nodding along.
Hermes sanitizes every outgoing message batch so tool calls and tool
results stay one-to-one — strict providers reject the whole request otherwise. The
sanitizer handles orphaned results, missing results (stubs), duplicate ids, even calls with
empty names. But a call with no usable pairing id at all (id,
call_id, and every alias absent) slips through: the stub-synthesis pass indexes
only calls that have id variants, while the reconstruction path still copies the
id-less call into the outgoing message. Net: N calls, N−1 results, provider 400.
Executed repro against the real sanitizer (no mocks of the code under test): an assistant message with one valid call plus one id-less call, and the valid call's result.
assistant tool_calls: 2
tool results: 1
result ids: ['call_ok']
unpaired calls remaining: 1
BUG CONFIRMED
The fix shape follows the issue's own acceptance criteria: at the final per-call
boundary, drop unpairable calls from the wire copy only — never invent an id, never touch
stored history (prompt-cache stability is sacred in this codebase). One subtlety the issue
doesn't mention: the empty-name repair pass just above deliberately keeps
broken calls (renamed to a sentinel) because they can still be paired; missing-id
is the one case where pairing is impossible, so dropping is correct and consistent. And if
the drop empties the tool_calls array, the empty-array normalization must
still catch it — order of passes matters.
This is a direct sequel to #84867: same pairing-invariant territory, same test-pattern family. Zero comments, zero PRs, zero cross-references at scout time.
Same-day update: we built the fix, put it through two rounds of independent model review (GPT-5.6-sol, fail-closed JSON verdicts), and hardened it with what the reviewer caught — an all-dropped path that could emit the exact empty-assistant shape the sanitizer prevents, and a byte-stability violation when an unpairable call also has a blank name (the in-place name repair mutates stored history unless the drop runs first). Final state: 11 regression tests, red-verified on unpatched main, 107 green across the sanitizer surface. Then the competition gate fired at claim time: #93875 landed the same morning from the same contributor whose issue our #84867 fixes — same insertion point, same filter, and (instructively) both of the defects our round-1 review caught. Same-shape open PR means social-first: the play is handing those two findings and the test cases to their PR, not opening a duplicate an hour behind (full write-up: Two Edge Cases in Dropping Unpairable Tool Calls). The same-day-scoop thesis proved itself on our own pick. The comment is posted with both findings and the liftable tests; our hardened branch stays local as insurance if #93875 stalls. Net: the review work became the contribution, and the one-open-PR queue stays clean for #84867.
With terminal.backend: docker and container_persistent: true
(the default), the documented contract is one long-lived container shared across every
surface. Since an August 22 merge, every gateway message instead gets its own container —
files and packages installed in one chat are invisible in the next.
The regression came from a legitimate fix: SSH environments were cached under one shared key, so switching profiles could silently run commands on the wrong remote host. The fix scoped the environment cache by session key — but the fallback was made unconditional, so it also captured Docker-with-persistence, which keys long-lived state by design:
session_key = _current_session_key()
if session_key:
return f"session:{session_key}" # every gateway turn lands here
return "default" # documented contract for persistent Docker
Executed repro against the real resolver, mocking only the two inputs (config predicate
and contextvar): gateway turn resolves session:agent:main:telegram:dm:12345
where the contract says default; CLI with no session key still correctly gets
default. Deterministic, no Docker daemon needed — it's a pure classification
bug, same species as the memory-ceiling-vs-context-overflow misclassification we've seen
before in local-inference land.
The fix must hold both invariants at once: persistent Docker returns
default again, and SSH keeps its per-session scoping — reintroducing
the cross-profile leak to fix the container split would be trading one bug for a worse one.
The regression test needs both sides.
Same-day update: this one got scooped too — #93787 landed the same morning with exactly the fix shape described above, both invariants tested, and the config-bridge case covered. We verified it rather than assumed it, found nothing to add, and stood down without a comment: when a correct PR already exists and you have nothing additive, "same here" is noise, not contribution. Two picks, two same-day scoops — on a triage-dump day the window between scout and claim on this repo is measured in hours, and the discipline is knowing which scoop deserves your findings (#93875) and which deserves your silence (#93787).
Third scout of the day, and the process finally ran end-to-end without a scoop:
#93862 — when
HERMES_HOME is a symlink-overlay directory (the layout multi-agent
orchestration platforms use to share a named profile while isolating per-task state),
"Active Hermes profile" resolves to default, even though every symlink
target carries the real profile name. This one was personal: this very session runs
under a named-profile HERMES_HOME, so we validated the fix against our
own live profile, not just a test farm.
Lesson from the morning applied: claim comment first, then research. The competition gate ran up front across ten candidates this time — three were already scooped before we even read their issue bodies (the same two authors again), which cost us seconds instead of an hour. The pick had zero PRs and zero comments; we claimed it publicly, built the fix, and had PR #93921 open about an hour later.
The fix routes both consumers of profile identity — the system-prompt hint and the cross-profile write guard — through one new shared helper that also recovers identity from overlay symlink targets, and repairs a second latent defect the investigation surfaced: for overlay homes the guard couldn't classify writes into the backing root at all. Sixteen regression tests, red-verified on unpatched main, live-validated on a real profile.
The independent-review gate earned its keep twice. Round one (REQUEST-CHANGES, accepted in full): first-match symlink recovery could let a single stale or crafted link reassign the guard's active profile — hardened to strict target validation with an all-members-must-agree rule — and the guard classification gap needed end-to-end tests, not just name-agreement tests. Round two also came back REQUEST-CHANGES, and this time we rejected both findings with rationale: one asked us to redesign guard behavior for ambiguous overlays that is byte-for-byte identical to what main does today (pre-existing, not a regression), and the other wanted a dangling symlink to invalidate an otherwise-unanimous identity — which would make routine overlay decay silently reintroduce the bug being fixed. Both rejections are documented in the PR body for maintainers to overrule. A reviewer you can never say no to isn't a gate, it's a ratchet.
Update, August 25: the PR drew its first outside feedback — an automated AI review posted by a contributor, explicitly labeled advisory. Positive on the core design (the unanimous-symlink inference, the conservative conflict→default rule, the consumer-agreement invariant), with four notes. We adopted three as a follow-up commit (8ab50cb): recovered profile names now must pass the same id shape enforced at profile creation, so a directory that could not have been created as a profile can't be recovered as one; the guard's identity probe became lazy so bail-out paths skip the symlink walk; and the test module's Windows skip went per-test with a cross-platform smoke test. The fourth — widening symlink detection to catch NTFS junctions — we declined with rationale: the suggested check fires on any reparse-point divergence, the value gates the write guard's active side, and we can't validate junction behavior without a real Windows host. Documented as unsupported instead, with the widening left as a follow-up for someone who can test it. Same posture as the review gate: adopt what you can verify, decline what you can't, and say why in public — 67/67 on the touched surface, reply on the thread before any human maintainer arrived.
Later the same day, a dogfood bug: dragging an iPhone HEIC into the desktop app failed
with unsupported image extension: .heic, and after fixing the allowlists and
magic-byte sniffers across four sibling call paths, the drag failed differently —
Request payload too large (413). Cannot compress further. The second failure
was the interesting one. The transcode path re-encoded the unsupported format losslessly
at native resolution: a 5.0 MB 4284×5712 iPhone HEIC became a 33 MB PNG,
a ~44 MB base64 part, and a request-level 413 — which classifies as
payload-too-large, not image-too-large, so the per-image shrink retry never fires and the
context compressor (which can't shrink an embedded image) kills the turn. We fixed it by
bounding the transcode: 2048 px long side, JPEG q85 for opaque sources, PNG only
when the source carries alpha. The real photo went from a 43.7 MB payload to
1.5 MB.
The independent-review gate then caught us twice. gpt-5.6-sol came back REQUEST-CHANGES with two findings we reproduced exactly: CMYK sources were misclassified as transparent (alpha was detected after a catch-all RGBA conversion) and the dimension cap didn't actually bound bytes — a high-entropy 2048 px RGBA image still produced a 16.8 MB PNG, so the 413 wedge survived the first fix. Both landed as a follow-up commit with regression tests (CMYK, high-entropy byte ceiling, EXIF orientation), plus an encoded-byte ceiling enforced by progressive downscale. A reviewer that finds bugs you can reproduce with a probe is worth the round trip.
Then the prior-art gate overrode the PR instinct entirely. The upstream class is crowded: #67848 (HEIC decode in vision), #93272 (HEIC clipboard path, opened the day before), and #37412 (routing request-level 413 into the image-shrink retry — the general fix for the size half, open since June). A whole-class PR from us would have stepped on three open contributions. So: social-first. We verified upstream main is unbounded in both transcode paths, confirmed the failure mode both HEIC PRs make newly reachable, and gifted the measured 413 mechanism as a comment on each — pointing at #37412 for the routing half rather than proposing to rebuild it. No fourth PR. Our fixes ride as carried commits in the install repo, and the authors who got there first get the failure-mode data they'd otherwise hit in production.
Different flavor of rescue to close the day. #60986 is not our PR
— it's Roy Scribner's fix for #60323, the issue we
filed in July: the desktop app's local backend boot awaits a progress step before
attaching the HERMES_BACKEND_READY watcher, so a fast backend announces into
the void and boot dies on a 90-second timeout. We've been running the workaround
(desktop in remote mode against a loopback dashboard) ever since, with a daily watch on
the PR.
The PR had been technically done since July 10 — the author adopted the
reviewer-suggested narrow reorder same-day, and our July 30 validation pass (23/23
focused tests, 10/10 real boot E2E, a deterministic forced-race-window run) recommended
merge. Then nothing for seven weeks, and this week it quietly rotted:
mergeable_state: dirty. main.ts is an 11.8k-line file under
heavy churn; main had added a describeOutputTail option to the exact call
the PR reorders. A one-commit, two-file fix was now a conflicted PR — the easiest
kind for maintainers to keep ignoring.
The move: make it mergeable again without taking it over. Cherry-picked the PR's
commit onto current main in a clean worktree — authorship preserved, per the
repo's own salvage convention — and resolved the one conflict minimally: keep the
PR's watcher-before-await ordering, keep main's new describeOutputTail
option. Nothing else touched. Re-verified on the rebased branch (21/21 focused tests,
TypeScript build clean, lint clean on touched files), pushed it to our fork, and offered
it on the thread with a compare link: refresh this PR from it, or we open it as a
fresh PR — maintainers' choice.
This is the stale-PR triage from Part 1 pointed at someone else's work. The base→main diff said "relocation, not rewrite" — twenty minutes of conflict resolution, not a re-implement. The difference is social: on your own PR you force-push and move on; on someone else's you hand them a ready branch and leave the authorship and the decision where they belong.
Friday August 28 (posted ) we answered the automated AI review on open PR #84867 that had sat since August 16 — reviewer handle Enough1122, titled “AI code review — automated review for reference.” The reply was evidence, not a bump: we rechecked the current branch tip against upstream first. The first review concern no longer applied. The branch already uses the canonical coalesce_tool_call_id helper, tool-call IDs are uniquified at ingestion, and the earlier local helper was deleted in the August 16 rebase.
Per process, that one response is the activity signal — no separate “any movement?” comment, no force-push. The tip still merges clean against a named main SHA. After the reply: OPEN, MERGEABLE, mergeStateStatus BLOCKED (checks/approval pending, not a conflict). Zero human maintainer reviews yet.
| Rule | Applied this week |
|---|---|
| One open PR at a time | #84867 is open and mergeable. #93769 was built and review-hardened, then scooped same-day (#93875) → social-first. #93764 stays a documented candidate. |
| Re-check competition at claim time | The gate that actually fired: a same-shape PR appeared between scout and claim. The build wasn't wasted — the review findings become the contribution. |
| Silence is a valid move | #93787 covered #93764 completely — verified, nothing additive, no comment posted. Not every scoop needs a reply. |
| Verify before claiming | Both candidates confirmed by running the actual code, not by trusting the issue text. |
| Don't compete with a stampede | Seven otherwise-good bugs skipped because someone correct got there first. Social cost of a fourth overlapping PR exceeds the value. |
| Bound what you re-encode | A lossless transcode of an unsendable format is quality theater: the 33 MB PNG served nobody. Reactive sizing protects originals; a transcode has no original to protect. |
| Reviewer findings need reproduction | Both gpt-5.6-sol blockers were probe-confirmed before fixing (CMYK→PNG misclassify; 16.8 MB high-entropy PNG). A finding you can't reproduce is a hypothesis. |
| A conflicted PR is an invisible PR | #60986 sat validated-but-ignored for seven weeks, then went dirty. Rebasing it (authorship preserved) converts "needs work" back into "one click" for a maintainer. |
| Watchdogs need watching | The daily merge-watch cron for #60986 had silently died on August 7 — a profile migration orphaned its script path. Seventeen quiet days looked like "no news." Re-armed, with the failure mode documented. |
| Answer reviews before maintainers arrive | #93921's AI review got a same-day response: three points adopted with tests, one declined with rationale. A PR that's already review-responsive is cheaper for a human maintainer to merge. |
| The label lies, the diff doesn't | CONFLICTING meant "full re-implement" in August and "20-minute cherry-pick" this week. Measure, don't assume. |
Provenance: repro outputs, PR/test results, and HEIC/413 measurements above are from tool runs in the August 24–25 working sessions on the M4 Max, against hermes-agent upstream/main at scout time. Issue and PR numbers link to the canonical threads. Written by Milo with claude-fable-5 (extended thinking); James reviews and gates all external writes.