Skip to content

feat: automated Upstreamer port pipeline for @openrouter/agent - #19

Merged
LukasParke merged 7 commits into
mainfrom
upstreamer/port-automation
Jul 28, 2026
Merged

feat: automated Upstreamer port pipeline for @openrouter/agent#19
LukasParke merged 7 commits into
mainfrom
upstreamer/port-automation

Conversation

@LukasParke

Copy link
Copy Markdown
Contributor

Wires up automated porting of @openrouter/agent into this repo using Upstreamer (MIT) — the same tool that produced the existing upstreamer-changelog.md, but running on a schedule with real gates instead of by hand.

Why

This repo has one commit (the manual 0.7.2 port), no CI, and no drift signal. Upstream is now at 0.8.0, so the port is a minor version behind and missing:

  • HooksManager lifecycle system (upstream feat: concurrent tool execution via anyio.create_task_group #7, #67) — the 0.8.0 headline feature
  • Versioned state serialization: serialize_conversation_state / deserialize_conversation_state / CONVERSATION_STATE_VERSION (#66)
  • awaiting_client_tools manual-tool persistence (#64), empty-final tolerance (#63), string-input resume (#61), contextSchema inference (#65), default final-answer directive (#68)

⚠️ Before merging — two things needed from you

1. Fill in credentials. Two values, same names locally and in CI:

Name CI Local
OPENROUTER_API_KEY repo secret .upstreamer/port.env
OPENCODE_MODEL repo variable .upstreamer/port.env
cp .upstreamer/port.env.example .upstreamer/port.env   # gitignored

.upstreamer/port.env.example documents both. The wrapper writes the key into ~/.local/share/opencode/auth.json, so headless runs work without the interactive opencode /connect flow. OPENCODE_MODEL overrides the contract's model: field, so you can switch models without a code change — recommend a strong coding model, this is a load-bearing SDK port behind a strict eval.

2. Add HOP C to typescript-agent. This workflow listens for repository_dispatch type openrouter-agent-published, which nothing sends yet. It needs a dispatch step in typescript-agent's publish.yaml, right next to the existing HOP B dispatch to openrouter-web. Until then the weekly cron and manual dispatch cover it.

What's here

Path What
.upstreamer/upstreamer.md The rewrite contract — the durable artifact
.upstreamer/scripts/verify.sh Mechanical gate: ruff, mypy, pytest, required API, version consistency
.upstreamer/eval.md Parity gate — fresh-context behavioral review vs the TS reference
.upstreamer/state.yaml Seeded at 0.7.2 (adc7939)
scripts/upstream Wrapper, adapted for in-repo downstream
.github/workflows/upstreamer-port.yaml Dispatch on publish + weekly cron → opens a PR
.github/workflows/ci.yaml New — this repo had no CI
PORTING.md Operator docs

Design decisions worth reviewing

This repo is the downstream. Upstreamer normally writes generated output into codebases/<name>/downstream/ inside the upstreamer repo. Putting the machinery here instead means output lands in place and there's no publish/rsync leg that could clobber CI or release config. The converter skill has an explicit do-not-touch list for repo-owned files.

Ports track releases, not commits. Trigger is npm publish, not every push to upstream main. Porting mid-flight commits creates churn and reviewer fatigue; at a publish event the upstream HEAD is also the release SHA, so change detection lands on a clean boundary.

Seeded state. Without seeding, run #1 regenerates the whole SDK — a huge unreviewable diff. Seeded at 0.7.2, run #1 is an incremental 0.7.2→0.8.0 delta.

Failure leaves state stale, on purpose. If either gate fails, state.yaml is not advanced, .upstreamer/eval-report.md explains why, and the PR is titled EVAL FAILED — do not merge with an eval-failed label. The next run retries the same delta rather than skipping the gap. This is the property that makes unattended runs safe; a false PASS is worse than no eval.

The contract is the product. When a port is wrong, fix .upstreamer/upstreamer.md, not just the generated code — a code-only fix gets re-broken next sync.

Verified

Ran the full pipeline locally against @openrouter/agent@0.7.2:

  • Wrapper clones upstream, resolves the ref, peels annotated tags to commits, detects no-change, runs the verifier
  • Existing code passes ruff, ruff-format, mypy, and pytest
  • Verifier correctly fails on the 0.8.0 gap:
    FAIL: missing public API symbols: serialize_conversation_state
          deserialize_conversation_state CONVERSATION_STATE_VERSION
          HooksManager HookName DEFAULT_FINAL_RESPONSE_DIRECTIVE
    
    That failure is the point — it's the drift this pipeline exists to close, and it proves the gate has teeth before any model runs.
  • Confirmed .upstreamer/port.env is gitignored while port.env.example stays tracked

Two bugs found and fixed while testing: the seed was an annotated tag SHA rather than a commit SHA (would have forced a full regeneration every run), and the prompt heredoc tripped a bash 3.2 parsing bug on macOS.

Expectations

The first few runs will be contract-tuning, not clean automation. Budget review time. Also note two publishing blockers this pipeline doesn't solve: PyPI openrouter-agent is currently owned by an unrelated third-party package, and this repo is private so it can't be installed as documented.

Opened as a draft — needs the credentials above before the workflow can do anything.

This repo is a port of @openrouter/agent but had no automation keeping it in
sync: one commit (the manual 0.7.2 port), no CI, no drift signal. Upstream is
now at 0.8.0, so the port is a minor version behind and missing HooksManager,
versioned state serialization, and the #61-#68 fixes.

Wires up Upstreamer (github.com/mountgram/upstreamer, MIT) so the port tracks
upstream automatically, with gates strong enough to run unattended.

- .upstreamer/upstreamer.md — the rewrite contract: scope (packages/agent only,
  MCP explicitly out), required public API as the parity floor, the TS->Python
  naming map, permanent idiomatic divergences (Pydantic/Zod, async-first,
  generator convention, looser typing), and the `openrouter` substrate pin.
- .upstreamer/scripts/verify.sh — mechanical gate: ruff/mypy/pytest, required
  API presence, version consistency vs the ported upstream release.
- .upstreamer/eval.md — parity gate, run in a fresh context against the TS
  reference. This is what catches a port that compiles but sits a version behind.
- scripts/upstream — wrapper adapted for in-repo downstream: this repo IS the
  output, so no publish leg is needed.
- .github/workflows/upstreamer-port.yaml — triggered by repository_dispatch from
  typescript-agent on npm publish (ports track releases, not every commit),
  weekly cron as backstop. Opens a PR; never pushes to main.
- .github/workflows/ci.yaml — this repo had no CI at all, so the verifier's test
  calls only ever ran inside the sync job.

State is seeded at 0.7.2 (adc7939) so the first run is an incremental
0.7.2 -> 0.8.0 delta rather than an unreviewable full regeneration.

Failed gates deliberately leave state.yaml stale: the next run retries the same
delta instead of skipping the gap, and the PR is labelled eval-failed.

Credentials are OPENROUTER_API_KEY + OPENCODE_MODEL, read from
.upstreamer/port.env locally (gitignored) or repo secret/variable in CI.
See PORTING.md.
The required-API check fails by design until the port catches up to upstream
0.8.0. A permanently-red required check on every unrelated PR just teaches
people to ignore CI, so report it in the job summary instead.

It stays blocking where it matters: inside scripts/upstream, where it gates
whether .upstreamer/state.yaml advances.

Also make the verifier's version check say when it skipped for lack of an
upstream checkout, rather than passing silently.

@perry-the-pr-reviewer perry-the-pr-reviewer 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.

Perry's Review

Verdict: 💬 Comments / questions

Well-structured automation infrastructure with a sound design. The two-gate model (mechanical verify + fresh-context parity eval) with stale-state-on-failure is the correct safety property — a false PASS that advances state past a gap is the exact failure mode that silently drops a port a version behind, and this pipeline makes that hard. CI is green, the existing SDK code is untouched, and e2e tests skip cleanly without credentials.

Findings

Suggestion — full clone of upstream on every CI run (scripts/upstream:112)

git clone "$upstream_url" "$upstream_dir" does a full clone with all branches and tags. Since the upstream checkout is ephemeral in CI (fresh checkout each run), every sync does a full clone of typescript-agent. Adding --single-branch (or --depth=N if you only need recent history for diff resolution) would cut fetch time without affecting correctness. Not a blocker — just a CI cost consideration.

Nit — upstream clone assumes typescript-agent stays public

The clone passes no credentials. This works today because OpenRouterTeam/typescript-agent is public (confirmed), but if it's ever made private the pipeline breaks with a git auth error rather than a clear message. Worth a one-line note in PORTING.md under prerequisites.

Notes

  • The required-API presence check via Python heredoc (importlib.import_module + hasattr) is a clean approach — it verifies actual importability rather than grepping source.
  • The perl -e 'alarm shift; exec @ARGV' timeout correctly propagates SIGALRM to the exec'd opencode process. pipefail + set -e ensures the exit code propagates through tee.
  • upstreamer-changelog.md is referenced by the contract and SKILL.md but already exists from the manual port — correctly not recreated here.
  • The pyproject.toml version grep (grep -m1 '^version') assumes double-quoted version format, which is standard for pyproject.toml. Fine for now.

Comment thread scripts/upstream
else
target_commit="$(git -C "$upstream_dir" rev-parse origin/HEAD 2>/dev/null || git -C "$upstream_dir" rev-parse origin/main)"
fi
git -C "$upstream_dir" checkout -q --detach "$target_commit"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

▶ Prompt for agents

Suggestion: git clone without --single-branch fetches the entire ref namespace (all branches + tags) on every CI run, since tmp/upstreamer/ is ephemeral. Adding --single-branch would halve fetch time without affecting correctness — the script only ever checks out origin/HEAD or a specific --ref.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in follow-up PR #20 (this PR merged before the fix landed): --single-branch added, plus a fetch fallback so a --ref outside the default branch still resolves.

LukasParke and others added 3 commits July 25, 2026 10:21
…ncode auth

The wrapper only seeded auth.json when no openrouter credential existed. Anyone
who had previously run 'opencode /connect' would have their port.env key
silently ignored and the run billed to the old key — close to undebuggable.

An explicitly provided key now always wins. Other providers in auth.json are
preserved rather than clobbered, and a corrupt auth.json recovers instead of
failing the run.
- Model id: openrouter/anthropic/claude-sonnet-latest does not exist on
  OpenRouter; opencode resolves the alias as ~anthropic/claude-sonnet-latest.
  Fixed in the contract frontmatter, port.env.example, PORTING.md, and the
  workflow comment. Set the OPENCODE_MODEL repo variable with the ~ form.

- Committed opencode.json denying external_directory: without it, the model
  writing any scratch file outside the workspace (observed: /tmp/hookspec)
  trips an interactive permission ask that a headless `opencode run` can
  never answer — the run wedges silently until the job timeout. Deny makes
  opencode refuse the write so the agent course-corrects.

- Timeouts: job timeout 300→150 min and inner wrapper alarm 14400→7200 s,
  so a wedged run dies with logs intact (inner alarm first) and stops
  blocking the upstreamer-port concurrency group for 5 hours. The full
  0.7.2→0.8.0 jumbo delta completed locally in ~110 min; routine
  single-release deltas are far smaller.

Verified locally: with these fixes the 0.7.2→0.8.0 python port ran end to
end, flipped the verifier to PASS, and passed the parity eval on its second
attempt (PASS WITH WARNINGS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generated by the Upstreamer pipeline in this PR, run locally end to end
(scripts/upstream --ref @openrouter/agent@0.8.0). Both gates passed:
mechanical verifier === PASS: 0 failures ===, parity eval PASS WITH
WARNINGS on the second pass (.upstreamer/eval-report.md).

Ports the 0.7.2 -> 0.8.0 delta: lifecycle hooks system (HooksManager +
nine built-in hooks), versioned conversation-state serialization,
awaiting_client_tools for unresolved manual tool calls, default-on
allow_final_response with DEFAULT_FINAL_RESPONSE_DIRECTIVE,
strict_final_response / empty-final-retry tolerance, and MCP tool-result
source discrimination. state.yaml advances to 680bceb, so the first CI
run after merge is a no-op until the next upstream release.

First-pass eval FAILed on three real findings (Stop-hook force_resume
was not a zero-cost retry, MCP branding surface missing, thin hooks test
coverage); all three fixed and re-verified in the second pass — see the
eval report for the traced evidence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasParke

Copy link
Copy Markdown
Contributor Author

Two commits added on top of the machinery:

  • 66b5c32 — three CI-breaking fixes found by running this pipeline locally end to end: the documented model id doesn't exist on OpenRouter (correct alias is openrouter/~anthropic/claude-sonnet-latest — the OPENCODE_MODEL repo variable is now set with that value), a committed opencode.json denying external_directory (without it a headless run wedges forever on an interactive permission ask — observed on the first attempt), and tightened timeouts (job 150 min, inner alarm 120 min) so a wedged run dies with logs intact instead of blocking the concurrency group for 5 h.
  • 8467f1b — the actual 0.7.2 → 0.8.0 port output, generated by this pipeline. Verifier PASS: 0 failures; parity eval PASS WITH WARNINGS on the second pass after the first pass legitimately FAILed on three real findings (all fixed and re-verified — see .upstreamer/eval-report.md). state.yaml advances to 680bceb, so the first CI run after merge is a no-op until the next upstream release.

Review the two commits separately: the first is machinery, the second should be read as a port (behavioral parity against the TS reference, especially mixed approval/HITL turn ordering and hooks semantics).

Remaining before this goes live: OPENROUTER_API_KEY repo secret, and contents:write on the typescript-agent GH_TOKEN PAT for the HOP C dispatch (OpenRouterTeam/typescript-agent#84).

🤖 Generated with Claude Code

OPENCODE_MODEL repo variable updated to openrouter/~anthropic/claude-opus-latest
to match; contract frontmatter, port.env.example, PORTING.md, and the workflow
comment all now reference the same alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasParke
LukasParke marked this pull request as ready for review July 28, 2026 21:27
Two changes so the pipeline runs entirely on the native GITHUB_TOKEN:

- Cron and blank manual dispatches now resolve the latest published
  @openrouter/agent version from the public npm registry and port that
  release tag, instead of porting upstream main HEAD. This makes the
  weekly cron fully equivalent to the repository_dispatch fast path
  (same tag either way), so the cross-repo PAT dispatch in
  typescript-agent becomes a latency optimization, not a requirement —
  and it also fixes cron runs violating the 'ports track releases'
  principle by syncing to mid-flight upstream commits.

- PRs opened with the native GITHUB_TOKEN don't trigger other workflows
  (GitHub's recursion guard), which was the standard argument for a PAT
  here. workflow_dispatch is exempt from that guard, so the port job now
  explicitly dispatches ci.yaml at the upstreamer/sync branch after
  opening the PR. Requires actions: write, added to the permissions
  block.

No PAT or external secret is referenced anywhere in this repo now; the
only secrets are OPENROUTER_API_KEY (inference) and the built-in token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasParke
LukasParke merged commit 83a0443 into main Jul 28, 2026
2 checks passed
@LukasParke
LukasParke deleted the upstreamer/port-automation branch July 28, 2026 22:20
LukasParke added a commit that referenced this pull request Jul 29, 2026
* perf(upstream): clone upstream --single-branch, with a fetch fallback for out-of-branch refs

Addresses the review suggestion on #19: the CI clone is ephemeral, so
fetching the full ref namespace every run roughly doubles clone time for
nothing — the script targets origin/HEAD or a release tag, both reachable
from the default branch.

Goes one step beyond the suggestion: with --single-branch, a --ref that
is NOT reachable from the default branch (e.g. a manual run against a
feature branch) would no longer resolve, so rev-parse now falls back to
an explicit fetch of that ref before failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(upstream): guard ref arguments against option injection

Same hardening as go-agent#2 review nit: '--' terminator on the fallback
fetch and --end-of-options on rev-parse so a manual --ref value starting
with '-' cannot be parsed as an option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
LukasParke added a commit to OpenRouterTeam/typescript-agent that referenced this pull request Jul 30, 2026
* ci(release): dispatch the Python and Go ports on publish (HOP C)

The Python and Go ports of @openrouter/agent track this repo as their reference
spec, but nothing told them when a version shipped. Both were generated by hand
against 0.7.2 and have sat a minor version behind since — missing HooksManager,
versioned state serialization, and the #61-#68 fixes.

Adds a HOP C dispatch beside the existing HOP B monorepo dispatch. On a real
publish, python-agent and go-agent each receive openrouter-agent-published and
open a PR porting the delta. Their pipelines gate the result on a mechanical
verifier plus a behavioral parity eval before advancing sync state, so a bad
port cannot land silently.

Details worth noting:

- Sends the release TAG (@openrouter/agent@X.Y.Z), not a branch, so a port
  reproduces the exact published tree rather than whatever main drifted to.
- continue-on-error: the packages are already on npm when this runs, so a failed
  dispatch must not turn a successful release red. It warns instead, and names
  the manual recovery path.
- !cancelled() so a failed HOP B dispatch doesn't also skip the ports.
- Partial failure is tolerated: one port failing still dispatches the other.

Reuses the same GH_TOKEN PAT as HOP B, which additionally needs contents:write
on OpenRouterTeam/python-agent and OpenRouterTeam/go-agent.

Also documents the full publish fan-out in the changeset-versioning skill, since
a breaking callModel change now produces port PRs in two other repos.

Companion PRs: OpenRouterTeam/python-agent#19, OpenRouterTeam/go-agent#1

* fix(release): address review findings on HOP C dispatch

- Use -f (--raw-field) for all dispatch payload fields: -F treats
  values starting with @ (the release tag) as filenames, which made
  every port dispatch fail before the request was sent
- Push release tags on the manual mode=publish path so the dispatched
  ref actually exists on the remote
- Restore set -e; the gh api call sits in an if-condition and is
  already -e-exempt, so the rest of the script stays strict
- Pass source_run_url via env like VERSION instead of inline ${{ }}

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(release): fall back to commit SHA when the release tag is not on origin

Address review findings from cortex and Devin on the HOP C dispatch:

- Make the manual-publish tag push non-fatal. It runs after packages are
  already on npm, so a rejected push (tag protection, or a re-run where the
  tag exists at a different commit) turned a successful publish red and,
  because the HOP B/C steps carry no status guard, skipped both dispatches.

- Verify the tag is on origin before dispatching it, falling back to the
  run's commit SHA. Previously the manual path could dispatch a ref the port
  repos cannot resolve, making them fail on checkout instead of degrading.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: LukasParke <luke@openrouter.ai>
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