Skip to content

Merge Agent runs runtime adapter contract - #5450

Closed
MerverliPy wants to merge 18 commits into
nesquena:masterfrom
MerverliPy:agent-runs-runtime-adapter-merge
Closed

MerverliPy wants to merge 18 commits into
nesquena:masterfrom
MerverliPy:agent-runs-runtime-adapter-merge

Conversation

@MerverliPy

Copy link
Copy Markdown

Adds the Agent runs runtime adapter, runtime routes, mobile pending-action support, deployment health reporting, live smoke harness, runtime contract documentation, and cross-repo integration support for Hermes Agent runtime runs.

Verification completed locally:

  • WebUI default focused tests: 77 passed
  • WebUI forced agent-runs mode: 69 passed, 8 known expected direct/journal failures
  • Cross-repo deterministic smoke: 11 passed, 0 failed
  • Pending-action smoke: PASSED
  • Agent deterministic runtime smoke: 7 passed, 0 failed
  • Agent focused runtime tests: 150 passed, 0 failed
  • Local merge rehearsal: PASSED
  • Actual local merge into master: PASSED

Related Agent PR:

Credential-gated checks not run:

  • Real DeepSeek cross-repo smoke requires DEEPSEEK_API_KEY
  • Telegram live adapter smoke requires TELEGRAM_BOT_TOKEN and safe private TELEGRAM_CHAT_ID

MerverliPy added 17 commits July 2, 2026 09:47
Phase 1: Stable RuntimeEvent and RuntimeStatus contract with:
- RuntimeEvent dataclass: event_id, seq, run_id, session_id, type,
  created_at, terminal, payload with secret redaction
- RuntimeStatus dataclass: full reconnect/mobile fields including
  controls, pending_approval_ids, pending_clarify_ids, error, result
- make_event() / make_status() factory helpers
- Event type and status validation helpers
- docs/rfcs/runtime-api-contract.md with Hermex/mobile usage pattern
- 16 contract tests covering serialization, validation, redaction

No live streaming or route changes. Dependency-light: no imports
from api/streaming.py or live runtime globals.
Update agent-runs adapter and runtime routes to handle new Agent
approval/clarify response shapes:

- respond_approval/respond_clarify map not_found, conflict, not_supported
- unified _control_result_response helper maps status to HTTP codes
- new TestApprovalClarifyErrorMapping covering all error states
- no secrets leaked in any error response path
Phase 15 confirms WebUI agent-runs adapter correctly proxies all
Agent runtime endpoints. No code changes required - existing test
suite comprehensively covers the contract.

Verification:
- Run status, events, cancel, approval, clarify all proxy correctly
- Error mapping: not_found->404, conflict->409, success->200
- Secret redaction preserved end-to-end
- Mobile pending actions resolve correctly in agent-runs mode
- 138 tests passed (default mode), 130 passed (agent-runs mode)
- 345 Agent runtime tests pass with the same contract
Add live HTTP smoke script and pytest tests for the WebUI agent-runs
adapter smoke harness.

New files:
- scripts/smoke_agent_runs_live.sh — live smoke script
- tests/test_agent_runs_live_http_smoke.py — 8 tests

Live smoke verified (cross-repo):
1. Runtime capabilities -> agent-runs mode
2. Proxied run status -> terminal state
3. Proxied events -> done event
4. Cancel/stop -> proxies correctly
5. Deployment health -> agent-runs adapter

Tests: 146 passed (default), 138 passed/8 expected (agent-runs env)
No architecture changes. agent-runs remains opt-in.
No code changes. Verifies:
- Deterministic cross-repo smoke (--fake): 11/11 PASSED
- Default tests: 146 passed, 0 failed
- Agent-runs env tests: 138 passed, 8 expected failures
- Real DeepSeek smoke: SKIPPED (no key)
- Agent-side approval/clarify deterministic trigger wired
- Messaging-adapter smoke plan documented in hermes-agent repo
@greptile-apps

greptile-apps Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces the Agent runs runtime adapter, wiring the WebUI to the Hermes Agent /v1/runs HTTP contract and adding runtime routes, mobile dashboard endpoints, deployment health reporting, workspace search, and a durable JSONL run journal.

  • api/runtime_adapters/agent_runs.py: New AgentRunsAdapter/AgentRunsClient that translates RuntimeAdapter protocol calls into JSON-over-HTTP against the agent runtime, with structured error mapping and an intentional no-redirect opener to protect the API key.
  • api/runtime_routes.py / api/routes.py: New stable REST endpoints for run status, event replay, cancel, approval, and clarify; wired into the existing GET/POST dispatch.
  • api/deployment_health.py / api/mobile_routes.py / api/workspace_search.py: New observability and mobile-client support surfaces (health diagnostics, run dashboard, pending-action resolution, workspace file search).

Confidence Score: 3/5

Two defects need fixing before merge: the deployment health probe can leak the agent API key on an HTTP redirect, and the approval/clarify route handlers forward empty IDs to the remote agent without the same guard that cancel already applies.

The agent API key credential-leak path via redirect in the health check is a real, reproducible issue — not theoretical. The missing input validation in approval/clarify handlers is consistent with the existing cancel guard and produces malformed outbound requests on empty IDs. Both defects are in new code introduced by this PR and affect the live request path.

api/deployment_health.py (redirect-unsafe urlopen with Authorization header) and api/runtime_routes.py (missing run_id/approval_id/clarify_id guards in handle_run_approval and handle_run_clarify).

Security Review

  • Authorization header leak via HTTP redirect (api/deployment_health.py line 185): The agent reachability probe uses urllib.request.urlopen directly, which follows HTTP redirects and forwards all original headers including Authorization: Bearer <api_key>. AgentRunsClient deliberately avoids this with a _NoRedirect opener; the health check endpoint bypasses that protection.

Important Files Changed

Filename Overview
api/deployment_health.py New health diagnostics endpoint; uses urllib.request.urlopen directly (follows redirects) for the agent reachability probe, unlike AgentRunsClient which uses a _NoRedirect opener — API key can leak on redirect.
api/runtime_routes.py New runtime route handlers; cancel validates run_id but approval and clarify handlers skip that validation, allowing empty IDs to propagate to the remote adapter.
api/runtime_adapters/agent_runs.py New HTTP adapter translating RuntimeAdapter protocol to Hermes Agent /v1/runs JSON API; solid error mapping and redirect protection in the main client, but the health check in deployment_health.py bypasses the same redirect guard.
api/runtime_journal.py Durable JSONL event journal with per-run and index file locking; append_event reads the index twice (once for validation, once for update), though both reads are inside _index_lock so concurrent writes are safely serialized.
api/runtime_adapters/init.py Singleton factory for the runtime adapter; uses double-checked locking correctly for agent-runs init, though the pre-lock None check is outside the lock (acceptable under Python's GIL).
api/mobile_routes.py New mobile-facing API layer for run dashboard, pending actions, and reconnect; per-run agent status refresh silently ignores errors which is appropriate for a best-effort dashboard.
api/routes.py Wires new runtime, mobile, workspace-search, and deployment health routes into the existing GET/POST dispatch; routing logic is clear and the path guards are correct.
api/workspace_search.py New workspace search endpoint; stays within workspace root via resolved paths, skips ignored dirs, caps content size, and redacts secrets in previews.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant WebUI_Routes as routes.py
    participant RuntimeRoutes as runtime_routes.py
    participant Adapter as runtime_adapters/__init__.py
    participant AgentClient as agent_runs.py
    participant AgentAPI as Hermes Agent /v1/runs
    participant Journal as runtime_journal.py

    Client->>WebUI_Routes: GET /api/runtime/capabilities
    WebUI_Routes->>RuntimeRoutes: handle_runtime_capabilities()
    RuntimeRoutes-->>Client: "{supports: {...}}"

    Client->>WebUI_Routes: "GET /api/runs/{id}/events"
    WebUI_Routes->>RuntimeRoutes: handle_run_events()
    alt agent-runs mode
        RuntimeRoutes->>Adapter: get_runtime_adapter()
        Adapter->>AgentClient: observe_events(run_id)
        AgentClient->>AgentAPI: "GET /v1/runs/{id}/events"
        AgentAPI-->>AgentClient: "{events: [...]}"
        AgentClient-->>RuntimeRoutes: RunEventStream
    else journal mode
        RuntimeRoutes->>Journal: read_events(run_id)
        Journal-->>RuntimeRoutes: [RuntimeEvent, ...]
    end
    RuntimeRoutes-->>Client: "{run_id, events}"

    Client->>WebUI_Routes: "POST /api/runs/{id}/approval"
    WebUI_Routes->>RuntimeRoutes: handle_run_approval()
    RuntimeRoutes->>Adapter: get_runtime_adapter()
    Adapter->>AgentClient: resolve_approval(run_id, approval_id, choice)
    AgentClient->>AgentAPI: "POST /v1/runs/{id}/approval"
    AgentAPI-->>AgentClient: "{ok, status}"
    AgentClient-->>RuntimeRoutes: ControlResult
    RuntimeRoutes-->>Client: "{ok, status, message}"

    Client->>WebUI_Routes: GET /api/deployment/health
    WebUI_Routes->>DeploymentHealth: handle_deployment_health()
    Note over DeploymentHealth: urllib.urlopen follows redirects ⚠️
    DeploymentHealth->>AgentAPI: GET /v1/health (with Authorization)
    AgentAPI-->>DeploymentHealth: "{version}"
    DeploymentHealth-->>Client: "{status, warnings, runtime, ...}"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant WebUI_Routes as routes.py
    participant RuntimeRoutes as runtime_routes.py
    participant Adapter as runtime_adapters/__init__.py
    participant AgentClient as agent_runs.py
    participant AgentAPI as Hermes Agent /v1/runs
    participant Journal as runtime_journal.py

    Client->>WebUI_Routes: GET /api/runtime/capabilities
    WebUI_Routes->>RuntimeRoutes: handle_runtime_capabilities()
    RuntimeRoutes-->>Client: "{supports: {...}}"

    Client->>WebUI_Routes: "GET /api/runs/{id}/events"
    WebUI_Routes->>RuntimeRoutes: handle_run_events()
    alt agent-runs mode
        RuntimeRoutes->>Adapter: get_runtime_adapter()
        Adapter->>AgentClient: observe_events(run_id)
        AgentClient->>AgentAPI: "GET /v1/runs/{id}/events"
        AgentAPI-->>AgentClient: "{events: [...]}"
        AgentClient-->>RuntimeRoutes: RunEventStream
    else journal mode
        RuntimeRoutes->>Journal: read_events(run_id)
        Journal-->>RuntimeRoutes: [RuntimeEvent, ...]
    end
    RuntimeRoutes-->>Client: "{run_id, events}"

    Client->>WebUI_Routes: "POST /api/runs/{id}/approval"
    WebUI_Routes->>RuntimeRoutes: handle_run_approval()
    RuntimeRoutes->>Adapter: get_runtime_adapter()
    Adapter->>AgentClient: resolve_approval(run_id, approval_id, choice)
    AgentClient->>AgentAPI: "POST /v1/runs/{id}/approval"
    AgentAPI-->>AgentClient: "{ok, status}"
    AgentClient-->>RuntimeRoutes: ControlResult
    RuntimeRoutes-->>Client: "{ok, status, message}"

    Client->>WebUI_Routes: GET /api/deployment/health
    WebUI_Routes->>DeploymentHealth: handle_deployment_health()
    Note over DeploymentHealth: urllib.urlopen follows redirects ⚠️
    DeploymentHealth->>AgentAPI: GET /v1/health (with Authorization)
    AgentAPI-->>DeploymentHealth: "{version}"
    DeploymentHealth-->>Client: "{status, warnings, runtime, ...}"
Loading

Reviews (2): Last reviewed commit: "Fix WebUI runtime review findings" | Re-trigger Greptile

Comment thread api/routes.py
Comment on lines +12326 to +12327
if parsed.path.startswith("/api/runs/") and not parsed.path.endswith("/events"):
return handle_run_status(handler, parsed)

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.

P2 The GET routing guard for run status (startswith("/api/runs/") and not endswith("/events")) also matches action sub-paths such as /api/runs/{id}/cancel, /api/runs/{id}/approval, and /api/runs/{id}/clarify when called via GET. The path then lands in handle_run_status, which returns 400 "invalid run_id" because of the "/" check — technically safe, but misleading. Adding an explicit check that the remainder contains no / makes the intent clear.

Suggested change
if parsed.path.startswith("/api/runs/") and not parsed.path.endswith("/events"):
return handle_run_status(handler, parsed)
if parsed.path.startswith("/api/runs/") and not parsed.path.endswith("/events") and "/" not in parsed.path[len("/api/runs/"):]:
return handle_run_status(handler, parsed)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@MerverliPy

Copy link
Copy Markdown
Author

Phase 31 review-response update:

Pushed repair commit d40c749d82571c480d09d674ec716fdd4501be5a to address the Greptile review findings.

Fixes included:

  • Moved RuntimeJournal index read-modify-write paths under _index_lock for create_run, append_event, and mark_terminal.
  • Re-read the journal index before each locked write so stale snapshots cannot clobber terminal state.
  • Removed the duplicate read_events() call in handle_run_events.
  • Added a lock around runtime adapter singleton initialization.
  • Tightened run-status subpath handling so action subpaths like /api/runs/{id}/cancel do not get treated as run IDs.
  • Added focused Phase 31 regression coverage.

Verification:

  • Focused runtime shard passed: 63 passed in 4.09s.

Current PR status:

  • Open
  • Non-draft
  • Mergeable
  • Head: d40c749d82571c480d09d674ec716fdd4501be5a

Ready for re-review.

Comment thread api/deployment_health.py
Comment on lines +185 to +203
try:
health_url = agent_base_url.rstrip("/") + "/v1/health"
req = urllib.request.Request(health_url, method="GET")
api_key = os.getenv("HERMES_WEBUI_AGENT_RUNS_API_KEY", "").strip()
if api_key:
req.add_header("Authorization", "Bearer " + api_key)
with urllib.request.urlopen(req, timeout=5) as resp:
agent_runtime_reachable = resp.status < 500
if agent_runtime_reachable and resp.status < 400:
import json

try:
body = json.loads(resp.read().decode("utf-8", errors="replace"))
agent_api_version = str(
body.get("version") or body.get("api_version") or ""
) or None
except Exception:
pass
except Exception:

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.

P1 security Authorization header forwarded on HTTP redirect

urllib.request.urlopen follows HTTP redirects by default and sends all original headers — including Authorization: Bearer <api_key> — to the redirect destination. AgentRunsClient deliberately prevents this with a custom _NoRedirect opener (HTTPRedirectHandler that returns None). That protection is absent here: a 301/302 from the configured agent health URL would silently forward the API key to a third-party host. If the agent base URL ever serves a cross-origin redirect (e.g., HTTP→HTTPS misconfiguration or a compromised DNS record), the bearer token is leaked.

Comment thread api/runtime_routes.py
Comment on lines +291 to +302
if runtime_adapter_agent_runs_enabled():
adapter = _adapter()
if adapter is None:
return json_response(
handler,
{"error": "agent_runtime_unreachable", "message": "agent-runs adapter is not configured."},
status=502,
)
run_id = str(body.get("run_id") or "").strip()
approval_id = str(body.get("approval_id") or "").strip()
choice = str(body.get("choice") or "accept").strip()
result = adapter.respond_approval(run_id, approval_id, choice)

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.

P1 Missing run_id validation in approval and clarify handlers

handle_run_cancel explicitly returns 400 when run_id is empty, but the parallel handle_run_approval and handle_run_clarify handlers forward an empty string straight to the remote adapter. A POST to /api/runs//approval sets run_id="" in the body (via the routing code), which then calls adapter.respond_approval("", ...) and fires an HTTP request to the agent at /v1/runs//approval, an invalid path. handle_run_cancel already has the correct guard — the same check is needed here.

Suggested change
if runtime_adapter_agent_runs_enabled():
adapter = _adapter()
if adapter is None:
return json_response(
handler,
{"error": "agent_runtime_unreachable", "message": "agent-runs adapter is not configured."},
status=502,
)
run_id = str(body.get("run_id") or "").strip()
approval_id = str(body.get("approval_id") or "").strip()
choice = str(body.get("choice") or "accept").strip()
result = adapter.respond_approval(run_id, approval_id, choice)
if runtime_adapter_agent_runs_enabled():
adapter = _adapter()
if adapter is None:
return json_response(
handler,
{"error": "agent_runtime_unreachable", "message": "agent-runs adapter is not configured."},
status=502,
)
run_id = str(body.get("run_id") or "").strip()
if not run_id:
return bad(handler, "run_id is required", 400)
approval_id = str(body.get("approval_id") or "").strip()
if not approval_id:
return bad(handler, "approval_id is required", 400)
choice = str(body.get("choice") or "accept").strip()
result = adapter.respond_approval(run_id, approval_id, choice)

@nesquena-hermes nesquena-hermes added the size:L Large PR (>10 files or >250 LOC) label Jul 3, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Cross-repo contract review — two agent-runs gaps + a CI note

Pulled this read-only and cross-referenced the WebUI adapter against the Hermes Agent /v1/runs server it targets (gateway/platforms/api_server.py). The seam design is sound — the adapter is a pure protocol translator, and the default stays legacy-direct (runtime_adapter.py:runtime_adapter_mode falls back to _RUNTIME_ADAPTER_DIRECT), so the live chat path is untouched until someone opts in with HERMES_WEBUI_RUNTIME_ADAPTER=agent-runs. That's the right posture. Two things will bite the moment that flag flips, plus one process note.

1. Agent side has no /v1/runs/{run_id}/clarify route

The adapter POSTs clarify responses (api/runtime_adapters/agent_runs.py:196):

return self._post(
    f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}/clarify",
    body,
)

and the RFC you added documents it as first-class (docs/rfcs/runtime-api-contract.md:192, "Respond to clarify request"). But the agent only registers five run routes (api_server.py:4780-4784):

self._app.router.add_post("/v1/runs", self._handle_runs)
self._app.router.add_get ("/v1/runs/{run_id}", self._handle_get_run)
self._app.router.add_get ("/v1/runs/{run_id}/events", self._handle_run_events)
self._app.router.add_post("/v1/runs/{run_id}/approval", self._handle_run_approval)
self._app.router.add_post("/v1/runs/{run_id}/stop", self._handle_stop_run)

No /clarify. A live respond_clarify gets a 404, which surfaces through _agent_runs_error_from_urllib as ControlResult(False, status="error", …) — not the clean status="not_supported" your code handles when the body carries {"error":"not_supported"}. So clarify degrades to a generic error rather than a graceful "unsupported." Either the agent PR (NousResearch/hermes-agent#57410) needs to add the route, or the adapter should treat a 404 on /clarify as not_supported. Cancel is fine — cancel_run correctly targets /stop (agent_runs.py:162), which matches the agent route.

2. Event-name drift between the agent stream and the WebUI contract vocab

_map_agent_event_to_dict (agent_runs.py:474) is a passthrough — it copies raw.get("type") verbatim with no rename. But the agent emits assistant.delta, message.started, assistant.completed, run.completed, tool.progress (api_server.py:1946-1993), while the WebUI contract vocabulary (runtime_contract.py:_EXPECTED_EVENT_TYPES) is token.delta, tool.started, tool.updated, tool.done, run.status, done, etc. The overlap is basically just run.started / error / done. _EXPECTED_EVENT_TYPES is only advisory today (is_known_event_type at :169 is never enforced), so nothing rejects the mismatched names — but any consumer keying on contract names won't see streamed tokens (assistant.delta ≠ token.delta). Recommend a small alias map in _map_agent_event_to_dict (assistant.delta→token.delta, tool.progress→tool.updated, assistant.completed/run.completed→done/run.status) and, ideally, feed mapped events through runtime_contract so drift is caught.

3. CI matrix didn't run on this PR

statusCheckRollup shows only Greptile Review — the test (3.11/3.12/3.13, 0-4), lint, and browser-smoke jobs that ran on every other open PR (e.g. #5454) are absent here. For a 9407-line PR adding 15+ test files, the "77 passed / 150 passed" figures in the body are self-reported and unverified by CI. Worth re-triggering the workflow before this is considered mergeable. Separately, AGENT_HANDOFF.md, IMPLEMENTATION_REPORT.md, and PR_DESCRIPTION.md (1,516 lines total) look like process artifacts landing in the repo root — probably belong in the PR description or docs/, not committed to master.

None of this blocks the seam design, which is careful. The gaps are all in the agent-runs path that ships default-off.


Cross-repo review, read-only. Verified agent routes at gateway/platforms/api_server.py:4780-4784 and event emissions at :1946-1993. Not a merge decision — surfacing contract drift for the author + Nathan.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Thanks for the substantial effort here, @MerverliPy — but I'm going to decline this in its current form.

The honest reasoning: this is ~9,400 lines adding a whole new remote-runtime + mobile-API architectural layer (runtime contract/journal/routes/adapter, a /v1/runs HTTP adapter, mobile APIs, deployment-health, workspace-search) plus committed report/RFC docs. Hermes WebUI is a single-user, agent-centric app, and this introduces a large new external-HTTP surface + subsystem that doesn't map to a direction the project has committed to — so the cost (maintenance burden, new attack surface, review load) is very high relative to a benefit we're not clear on.

I'm not dismissing the work — if there's a concrete use case I'm missing, I'd genuinely like to hear it. If you can make the case for the specific problem this solves for WebUI users (what can't be done today that this enables, and who needs it), please open an issue laying that out. If the direction lands, the right path would be decomposing this into small, individually-reviewable PRs (each subsystem on its own) with a full security review of the external HTTP contract, and dropping the committed AGENT_HANDOFF.md / IMPLEMENTATION_REPORT.md / PR_DESCRIPTION.md report files (those don't belong in the tree).

Closing for now — happy to reconsider a scoped, motivated version. Appreciate the ambition.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L Large PR (>10 files or >250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants