Skip to content

fix(aux-routing): _read_main_provider string-form inference + credential transient-failure hardening - #6

Merged
deestax merged 4 commits into
mainfrom
fix/read-main-provider-string-inference
Apr 22, 2026
Merged

deestax merged 4 commits into
mainfrom
fix/read-main-provider-string-inference

Conversation

@deestax

@deestax deestax commented Apr 22, 2026

Copy link
Copy Markdown
Member

Summary

Four commits covering two root-cause fixes surfaced during Myah PR 1a E2E testing (T3-1001).

Changes

fix(auxiliary_client): _read_main_provider infers provider from string-form model:

_read_main_provider() at auxiliary_client.py:900-916 only handled dict-form model:. When config.yaml has model: "<id>" as a bare string (written by Myah's PATCH /api/v1/agent/config path via set_config_value), the function returned "". _resolve_auto Step 1 at :1290-1294 then fell through to the hardcoded OpenRouter fallback — so every aux call (title, follow-ups, compression, vision, etc.) routed to OpenRouter regardless of what provider the user configured.

Fix: extend _read_main_provider with a string-form branch (Myah marker block):

  1. Fast path — if model string is <provider>/<rest> and the prefix is a known key in _PROVIDER_MODELS, return that prefix directly (anthropic/claude-haiku-4-5-20251001 → anthropic)
  2. Fallback — delegate to detect_provider_for_model(model_str, current_provider=""), the same helper the interactive hermes model flow already uses when writing dict-form config

Read-side only. No config.yaml mutation. No prompt-cache impact (_read_main_provider is aux-routing only, outside conversation context). Mirrors _read_main_model's existing string-form handling at :889-890.

fix(myah_management): _validate_api_key transient-failure handling

_validate_api_key returned False on any exception (timeout, DNS flake, 429, 5xx), treating every network transient as an invalid key. Observed: 8 consecutive 400s for a valid OpenRouter key over 40s, then 200 for the same key 16s later.

Fix: distinguish genuine auth failures from transients:

  • HTTP 401/403 → (False, "auth denied by provider (HTTP N)") — reject
  • Timeout, 429, 5xx, network error → (True, "optimistic accept ...") with warning log — accept

Return type changed from bool to tuple[bool, str]. Call site in handle_connect_credential updated accordingly.

Tests

  • TestReadMainProvider (7 tests) — string-form anthropic/openrouter inference, dict-form regression, empty/missing/exception safety
  • TestReadMainProviderEdgeCases (5 tests) — vendor-prefix ground truth, dict-no-provider scope limit, swallows exception, known prefix fast-path, unknown prefix fallthrough
  • TestHonchoAuxIsolation (2 tests) — regression fence ensuring aux calls never reference Honcho or MemoryManager
  • _validate_api_key unit tests (8) + handler-level tests (2) in test_myah_providers_catalog.py

All tests pass via scripts/run_tests.sh. Pre-existing failures (74) are unrelated and reproduced on the base commit.

Refs T3-1001

deestax and others added 4 commits April 22, 2026 17:27
…g-form model:

Myah-marker patch. When config.yaml has model: as a bare string (e.g.
'anthropic/claude-haiku-4-5-20251001'), _read_main_provider previously
returned '' because it only accepted dict-form config. This broke
_resolve_auto Step 1 for every Myah frontend user, routing all aux
calls to the OpenRouter hardcoded fallback instead of the user's picked
provider.

New behavior (string-form path, inside Myah marker block):
1. Fast path: if the model string is '<provider>/<rest>' and the prefix
   is a known provider key in _PROVIDER_MODELS, return that prefix
   directly (e.g. 'anthropic/claude-haiku...' -> 'anthropic').
2. Fallback: delegate to detect_provider_for_model(model_str,
   current_provider='') — the same helper the interactive hermes model
   flow uses at hermes_cli/main.py:1997-2004 when writing dict-form
   config.  Return the inferred provider id, or '' if no confident
   match.

No mutation of config.yaml on disk. CLI users (who write dict-form via
'hermes model') are unaffected. No impact on prompt caching
(aux-routing-only path, outside conversation context).

Tests added: TestReadMainProvider (7 tests) covering string-form
anthropic/openrouter inference, dict-form regression, auto pass-through,
unknown/empty/missing key safety.

Upstream PR to be filed (see platform PR description). Marker to be
removed on next upstream merge that lands this fix.

Refs T3-1001
Co-Authored-By: Claude <noreply@anthropic.com>
…m inference

Covers: vendor-prefix ground truth (resilient to catalog churn),
dict-with-no-provider-key scope limit, load_config exception safety,
known-prefix fast path, unknown-prefix fallthrough.

Refs T3-1001
Co-Authored-By: Claude <noreply@anthropic.com>
…ts in _validate_api_key

Myah-marker block. Previously _validate_api_key returned False on any
exception (DNS flake, timeout, 429, 5xx), treating transient infra
issues as invalid keys. Evidence: 8 consecutive 400s for a valid
OpenRouter key over 40s (backend log 16:48:14-16:48:54), then 200 for
the same key 16 seconds later — same user, same key, same endpoint.

New contract: only explicit HTTP 401/403 (provider auth denial) returns
(False, reason). Timeouts, 429, 5xx, and network errors return
(True, 'optimistic accept ...') with a warning log. This matches native
Hermes CLI behavior (accepts keys optimistically; first real API call
surfaces auth failures).

Updated return type from bool to tuple[bool, str]; call site in
handle_connect_credential updated accordingly. Existing test updated
to use tuple mock return value.

New tests: 8 _validate_api_key unit tests + 2 handler-level tests
covering 401/403 rejection, 429/5xx/timeout/network optimistic accept,
full handler 400 on auth denial, full handler 200 on optimistic accept.

Refs T3-1001
Co-Authored-By: Claude <noreply@anthropic.com>
Regression fence: auxiliary_client.py and the Myah /myah/v1/aux
endpoint must never reference Honcho, MemoryManager, or peer_card.
Current code is clean (verified 2026-04-22). These tests fail
immediately if a future refactor accidentally re-couples the aux
call path to user memory.

Refs T3-1001
Co-Authored-By: Claude <noreply@anthropic.com>
@deestax
deestax merged commit ead051e into main Apr 22, 2026
3 of 5 checks passed
deestax added a commit that referenced this pull request May 7, 2026
…ner + Sentry/admin moves (#24)

* chore(hermes): drop unused _get_adapter_for_platform() helper

Verified zero callers via repo-wide grep. Function was a leftover
from earlier dispatch refactoring.

Drop candidate #2 from spec 2026-05-06 §2.3 (Tier 2A Task 2A.1.1).

* chore(hermes): drop os.environ CHAT_ID writer (concurrency bug)

Per-request mutation of process-wide environment, racing with
concurrent requests. The TODO comment confirmed this was a known
defect awaiting migration. set_session_vars() in session_context.py
is the contextvar-safe replacement.

Drop candidate #5 from spec 2026-05-06 §2.3 (Tier 2A Task 2A.1.2).

* chore(hermes): catch up MAX_REQUEST_BYTES to upstream (10MB)

Verified via git blame: the 1MB value at line 88 was authored by
Teknium upstream at commit 80cc27e (2026-03-24) and later bumped
to 10MB upstream. Fork has the older value due to drift, not a Myah
security decision.

Drop candidate #6 from spec 2026-05-06 §2.3 (Tier 2A Task 2A.1.3).

* chore(hermes): drop /health Myah credential-validation extension

The MYAH_ADAPTER_ENABLED-gated credential check was specific to the
fork-bundled adapter and goes away with the plugin-based OSS
architecture. /health/detailed (line 832 below) provides rich
runtime status; 'hermes doctor' covers credential validation.

Drop candidate #4 from spec 2026-05-06 §2.3 (Tier 2A Task 2A.1.4).

* chore(hermes): drop cronjob_tools origin-unresolved WARNING

Pure debug logging — the function returns None either way; the
WARNING was added for diagnostic purposes during a 2025-era cron
delivery bug. Function behavior unchanged.

Drop candidate #7 from spec 2026-05-06 §2.3 (Tier 2A Task 2A.1.5).

* feat(plugin): move myah_overrides.py from hermes_cli/ into plugin

Provider catalog augmentation (230 LOC) was Myah-only data living in an
upstream-counterpart directory. Moves it into the pip-installed plugin
at myah_hermes_plugin/myah_admin/myah_overrides.py and updates the two
known importers (plugins/myah-admin/dashboard/_providers.py and the
plugin tests).

After this commit, hermes_cli/ contains zero Myah-only files — one
fewer cardinal-counterpart file to keep marker hygiene on.

Implements Tier 2A Task 2A.6 from spec 2026-05-06 §3.

* feat(plugin): move Sentry init from upstream into plugin register() (Task 2A.7 + drop #3)

Previously gateway/platforms/api_server.py:76-82 had an inline
'try: from logging_setup import setup_sentry; setup_sentry(); except ImportError: pass'
Myah marker block. logging_setup.py was Myah-specific (agent-container
Sentry init + SentryHook adapter for agent.telemetry.TelemetryHook
protocol) but lived in an upstream-counterpart top-level path.

This commit:
- Renames logging_setup.py -> myah_hermes_plugin/sentry_init.py.
- Calls sentry_init.setup_sentry() from the plugin's register() so
  hosted-Myah agent containers still get Sentry on gateway boot.
- Drops the inline init Myah-marker block from api_server.py.

Idempotent: setup_sentry() returns silently when SENTRY_DSN_AGENT is
unset (the OSS-user case).

Closes drop candidate #3 from spec 2026-05-06 §2.3 (deferred from
Tier 2A Task 2A.1 to 2A.7 because it depended on having a plugin-side
sentry_init.py to call into).

Implements Tier 2A Task 2A.7 from spec 2026-05-06 §3.

* feat(plugin): add pre_gateway_dispatch hook (Tier 2A Task 2A.4)

Replaces the skip_user_authorization semantics that PR #20 removed.
Adds myah_hermes_plugin/myah_platform/pre_dispatch_hook.py with a
single hook callback that returns {'action': 'allow'} for Myah-platform
messages and None (passthrough) for everything else.

Architectural note: returning 'allow' does NOT bypass the gateway-level
user-allowlist check at gateway/run.py:3655. Auth bypass for Myah's
single-tenant deployment is handled by allow_all_env=MYAH_ALLOW_ALL_USERS
on the platform registration. The hook exists as a documented choke
point for future Myah-specific routing logic (rate limiting, silent
ingest, content rewrite) that must NOT live in upstream gateway/run.py.

Wired via ctx.register_hook('pre_gateway_dispatch', ...) in the plugin's
register() — guarded with hasattr(ctx, 'register_hook') so older
PluginContext versions don't fail.

Implements Tier 2A Task 2A.4 from spec 2026-05-06 §3.

* feat(plugin): vendor _dispatch_approval_notify + register_gateway_notify

Plugin owns its own copy of the notify-dispatch chain. Without it, the
plugin's vendored request_action_confirmation has no transport to the
platform adapter and the agent silently auto-approves.

Variadic dispatch (1/2/3-arity callbacks) matches upstream's
gateway/run.py:_dispatch_approval_notify behavior at lines 334-460.
Uses threading.Lock for the registry to match upstream's thread-safe
semantics in tools/approval.py.

Implements docs/superpowers/plans/2026-05-06-myah-oss-completion-2a-plugin-stock-upstream.md
Task 2A.2.0.

* feat(plugin): vendor request_action_confirmation + _action_queues registry

Plugin owns its own action confirmation primitives mirroring upstream's
tools/approval.py:request_action_confirmation (sync, threading.Event-based).

The sync contract is required because cronjob_tools._execute() runs in
a tool-runner threadpool and blocks on event.wait(timeout) — an asyncio
version would force rewriting the cron tool.

Two of the six tests intentionally fail until Task 2A.2.2 lands the
plugin's cron_tool.py shadow.

Implements docs/superpowers/plans/2026-05-06-myah-oss-completion-2a-plugin-stock-upstream.md
Task 2A.2.1.

* feat(plugin): shadow upstream cronjob_tools.py with plugin-owned cron tool

Verbatim copy of upstream's tools/cronjob_tools.py, with the single
change of importing request_action_confirmation from the
plugin-vendored myah_hermes_plugin.cron_approval. Tool registration
uses the same 'cronjob' name as upstream so last-writer-wins on import
order shadows upstream's handler with the plugin's.

All 6 tests in test_cron_approval.py now pass (the two cron_tool
sanity checks added in 2A.2.1 had been failing pending this commit).

Implements docs/superpowers/plans/2026-05-06-myah-oss-completion-2a-plugin-stock-upstream.md
Task 2A.2.2.

* refactor(plugin): adapter.py uses plugin-vendored approval primitives

Move 4 import sites in adapter.py from upstream's tools.approval to the
plugin's vendored modules:

- resolve_action_confirmation         → myah_hermes_plugin.cron_approval
- resolve_action_confirmation_by_session → myah_hermes_plugin.cron_approval
- unregister_gateway_notify (×2)      → myah_hermes_plugin.dispatcher

Update mock targets in test_myah_confirm_dispatch.py to follow the new
import paths. Comment block in send_action_confirmation updated to
reference the plugin's cron_approval module.

resolve_gateway_approval (legacy terminal-command approval) stays on
tools.approval — it's outside Tier 2A's cron-approval scope.

Implements docs/superpowers/plans/2026-05-06-myah-oss-completion-2a-plugin-stock-upstream.md
Task 2A.2.3.

* feat(plugin): adapter standalone aiohttp runner on MYAH_GATEWAY_PORT

Tier 2A Task 2A.3 — collapse the previous hosted/standalone split. The
plugin's MyahAdapter now ALWAYS owns its own aiohttp AppRunner +
TCPSite via the new MyahStandaloneRunner helper, removing the
dependency on upstream's gateway/platforms/api_server.py
register_pre_setup_hook + get_shared_app.

This is a one-way door for hosted Myah: once shipped, hosted Myah uses
the standalone runner forever. See spec
docs/superpowers/specs/2026-05-06-myah-oss-completion-design.md §3
Task 2A.3 for the rationale.

Default port is now 8643 via MYAH_GATEWAY_PORT (was 8642 hard-coded).
Port resolution still respects config.extra.port and MYAH_ADAPTER_PORT
overrides for backward compat with existing yaml.

Tests cover ephemeral binding (port=0 → OS-assigned), live HTTP route
dispatch, and env-var resolution fallback. Zero new failures in the
gateway/tools test directories vs baseline (55 pre-existing failures
unchanged).

* test: fix Tier 2A regressions surfaced by full CI suite

The PR's local verification only ran tests/gateway/ + tests/tools/ —
three tests in tests/cron/, tests/plugins/, and tests/tools/test_resolve_path.py
broke under the full CI run.

1. tests/cron/test_origin_capture_log.py — DELETED.
   Task 2A.1.5 dropped the WARNING that this entire 'Bug E' file was
   designed to verify. The file's purpose is gone with the warning;
   keeping the happy-path companion test would just duplicate coverage
   that lives elsewhere.

2. tests/plugins/test_myah_admin_providers.py::test_list_providers_against_real_catalog —
   import switched to pytest.importorskip(). Task 2A.6 moved
   myah_overrides.py from hermes_cli/ into the plugin distribution,
   but the hermes Tests workflow only runs 'uv pip install -e ".[all,dev]"'
   against the core package — it never installs the plugin. Local dev
   runs (where the plugin is editable-installed) still exercise the
   real-catalog assertion; CI now skips it cleanly. The plugin's own
   pytest suite covers the catalog path independently.

3. tests/tools/test_resolve_path.py — added isolated_resolve_state
   fixture that clears tools.file_tools._file_ops_cache and
   tools.terminal_tool._active_environments before the two tests that
   rely on falling through to TERMINAL_CWD. Adding the new plugin
   modules to the suite shifted xdist worker assignment in a way that
   exposed pre-existing pollution from other tests leaving a 'default'
   task_id entry in those module-level dicts. The fixture is per-test
   (monkeypatch.setattr) so it doesn't disrupt other tests.

* test: gentler test_resolve_path isolation (don't rebind module attrs)

Previous fix used monkeypatch.setattr to swap _file_ops_cache and
_active_environments with empty dicts. This unintentionally caused
test_file_state_registry tests to fail on Linux CI under xdist —
rebinding the module attribute discarded entries other tests had
written under non-'default' keys, and the cross-worker timing
exposed the leak.

The replacement fixture mutates the existing dicts in place — only
pops the 'default' key (which is what _resolve_path reads when no
explicit task_id is passed) and restores the prior value after the
test. No module-attribute rebinding, no impact on entries other
tests created.

* ci: trigger re-run to confirm test flakiness

Recent runs showed 3 new tests intermittently failing on Linux CI
that pass locally and pass in isolation:
- test_concurrent_inserts_settle_at_cap (30s timeout)
- test_modal_sandbox_fixes::TestToolResolution::test_terminal_tool_present
- test_modal_sandbox_fixes::TestToolResolution::test_terminal_and_file_toolsets_resolve_all_tools

This empty commit re-runs CI to confirm whether they're deterministic
regressions or pre-existing test-ordering flakes exposed by the changed
test count from this PR.
deestax added a commit that referenced this pull request May 11, 2026
Closes Issue #6 from the OSS follow-up spec without requiring an
upstream PR or core modification. The watcher polls the stable
~/.hermes/cron/output/{job_id}/*.md convention every 2s, reads job
metadata via vanilla cron.jobs.load_jobs(), and POSTs to the
platform's existing /api/v1/processes/webhook/run-complete.

Trade-off: tool_calls_log is None because the on-disk .md doesn't
carry it. Cron AG-UI render_* artifacts deliver as plain Markdown
(accepted degradation per spec).

Started lazily from pre_gateway_dispatch hook to avoid Python 3.12+
asyncio.get_event_loop() deprecation. Silent no-op when
MYAH_PLATFORM_BASE_URL is unset. Filtered to origin.platform == 'myah'
so telegram/discord cron jobs are unaffected.

Includes a CI guard that catches upstream API drift on
cron.jobs.OUTPUT_DIR and load_jobs.

Refs: docs/superpowers/specs/2026-05-10-myah-oss-followup-issues.md
Phase E.
deestax added a commit that referenced this pull request May 11, 2026
…nect (#27)

* feat(plugin): MYAH_USER_ID bootstrap via platform /whoami in OSS mode

Hosted Myah injects MYAH_USER_ID per-container at spawn time. OSS
self-hosted users have no spawner — without this bootstrap they would
have to manually copy their user_id from the platform UI to
~/.hermes/.env or cron deliveries would fail with HTTP 400.

The plugin's register(ctx) now calls _bootstrap_user_id() which:
- Idempotent: no-op if MYAH_USER_ID already set (hosted case).
- Skips silently if MYAH_PLATFORM_BASE_URL or bearer is missing.
- Calls GET /api/v1/myah/whoami with the platform bearer token.
- On success: populates MYAH_USER_ID in os.environ.
- On any error: logs at WARNING level and continues (chat still
  works without cron-webhook delivery).

Bearer alias fallback: MYAH_PLATFORM_BEARER, MYAH_AGENT_BEARER_TOKEN,
or MYAH_AGENT_TOKEN — any one is acceptable, matching the env-var
naming in agent/Dockerfile.stock and ~/.hermes/.env templates.

Test: 9 tests in test_user_id_bootstrap.py cover idempotency, missing
config, HTTP 4xx/network errors, empty response, alias env vars,
trailing-slash URL handling.

Refs: Phase 8.2 of docs/superpowers/plans/2026-05-09-myah-no-fork-vendoring.md

* feat(plugin): F4 secret-capture global callback registration (Phase 5.1)

Vanilla upstream's tools/skills_tool exposes set_secret_capture_callback(fn)
— a single global slot for the secret-prompt callback, invoked when
a tool needs the user to provide a secret value. The fork carries
hosted-Myah-specific session-keyed wiring inside _run_agent's closure;
on stock vanilla that wiring doesn't exist, so without this commit
the plugin's _secret_capture_callback bound method never fires and
secret prompts silently auto-skip.

This commit registers a global wrapper at plugin register-time:

1. tools.skills_tool.set_secret_capture_callback(_global_secret_callback)
   takes the vanilla-shaped (name, prompt, metadata) call.
2. _global_secret_callback consults adapter._LATEST_ADAPTER (a
   late-bound module attr set by MyahAdapter.__init__) plus the
   session-key contextvar (from tools.approval) to resolve the right
   stream_id.
3. Delegates to adapter._secret_capture_callback(name, prompt,
   metadata, stream_id) — which already does the SSE event push and
   threading.Event blocking dance. No adapter-side changes needed.

Single-adapter / single-process assumption (single-tenant OSS or
per-user agent container in hosted Myah). Multi-tenant in-process
would need a contextvar-keyed lookup; out of scope for v1.

Failure modes (all silent + logged at WARNING):
- No active adapter → auto-skip with success=true, skipped=true.
- No active stream → adapter still called with empty stream_id;
  adapter handles via its existing 'No stream' branch.
- tools.skills_tool unavailable → ImportError swallowed, no-op.

Test: 6 tests in test_secret_capture_wiring.py cover all branches.
All 310 plugin tests still pass.

Refs: Phase 5.1 of docs/superpowers/plans/2026-05-09-myah-no-fork-vendoring.md

* feat(plugin): F7 per-server MCP disconnect via direct private-attribute access

Vanilla upstream's tools/mcp_tool exposes shutdown_mcp_servers() which
tears down ALL configured MCP servers at once. The Myah admin UI needs
per-server teardown so a user can remove one MCP connection (e.g. a
misbehaving server) while leaving the others alive.

Vanilla does not expose a public per-server disconnect API. The
implementation accesses upstream's private state directly:
- _servers: Dict[str, MCPServerTask]   (upstream/main:tools/mcp_tool.py:1607)
- _lock: threading.Lock                (line 1969 — sync, NOT asyncio)
- MCPServerTask.shutdown(self)         (line 1568 — async coro)
- _run_on_mcp_loop(coro, timeout=...)  (line 2042 — cross-loop bridge)

Per AGENTS.md interpretation (see runtime_extensions/__init__.py
docstring): direct attribute access is normal Python, NOT modifying
core files on disk. Same pattern adapter.py:get_session_override_direct
already uses for runner._session_model_overrides.

Behavior:
- Acquire the threading.Lock (sync — never use asyncio.acquire).
- Look up _servers[name]; return False if missing.
- Call MCPServerTask.shutdown() coro through _run_on_mcp_loop bridge
  with caller-supplied timeout (default 15s).
- Swallow shutdown exceptions — the user wants the server gone, don't
  block removal on a hung close.
- Pop from _servers and return True.

Robustness: every getattr falls back to None and degrades to a
no-op-with-False return on upstream API drift. Two CI guard tests
catch the rename at plugin-CI time:
- test_upstream_state_present: assert _servers / _lock /
  _run_on_mcp_loop attributes still exist.
- test_upstream_lock_is_threading_not_asyncio: assert lock works in
  a sync 'with' block (asyncio.Lock would error).

Test: 7 tests in test_mcp_disconnect.py cover happy path, missing
server, missing private state, exception during shutdown, custom
timeout propagation, and the two CI guards.

Refs: Phase 5.2 of docs/superpowers/plans/2026-05-09-myah-no-fork-vendoring.md

* docs(plugin): release v1.1.0 with OSS bootstrap, F4 secret wiring, F7 MCP disconnect

CHANGELOG.md gains:

- New 'Known limitations' section at the top documenting F5 BOOT.md
  unavailability on stock vanilla upstream + plugin, and the
  single-tenant assumption of /whoami.
- v1.1.0 entry documenting Phase 8.2 (user_id bootstrap), Phase 5.1
  (secret-capture global callback), Phase 5.2 (MCP disconnect helper).
- Test count update: 333 plugin tests (310 prior + 23 new).

pyproject.toml version bumped 1.0.0 → 1.1.0 (additive features, no
breaking changes per semver).

Refs: Phase 6 of docs/superpowers/plans/2026-05-09-myah-no-fork-vendoring.md

* docs(plugin): document F6 cron-on-vanilla limitation and resolution paths

Vanilla cron/scheduler.py:_deliver_result does NOT call
runtime_adapter.build_delivery_metadata(...) — that polymorphic hook
is fork-only (Tier 2B Task 2B.4). Result on stock vanilla + plugin:
cron jobs run and write to ~/.hermes/cron/output/, but do not appear
in chat history.

Two resolution paths documented (neither shipped in v1.1.0):
1. Upstream PR U-CRON adding the polymorphic call to vanilla.
2. Plugin-side cron output-dir watcher (~150 LOC, no upstream PR).

The hosted Myah deployment is unaffected — it uses the fork build
with Tier 2B's polymorphic hook, so cron deliveries reach chat
normally.

Refs: Phase 3 of docs/superpowers/plans/2026-05-09-myah-no-fork-vendoring.md
(documented as known limitation rather than shipping the contentious
runtime monkey-patch on cron.scheduler._deliver_result)

* chore(plugin): INFO log model switch flow for OSS Issue #2

Adds three logger.info calls in _handle_message_endpoint's model
override block: pre-switch (raw_input + current state), post-switch
(success/new_model/target_provider/error_message), and post-write
(confirmed override). Lets us reproduce why OSS model picks fail to
take effect and triage the right fix.

Refs: docs/superpowers/specs/2026-05-10-myah-oss-followup-issues.md
Phase B.1.

* fix(plugin): OSS graceful fallback when model name unresolvable

When MYAH_DEPLOYMENT_MODE=oss and the platform-supplied model is not
in any of the user's hermes credentials (e.g. user.default_model is
openai/gpt-4o-mini but hermes pool is openrouter+opencode-go),
fall through to the agent's existing default rather than 400-failing
the entire chat turn.

Hosted mode behaviour is unchanged (multi-tenant: a 400 surfaces an
auth/config bug to the user). OSS is single-tenant: the user wants
their message to go through with whatever model the agent has.

The session override is cleared either way, so subsequent turns
re-attempt resolution.

Closes Issue #2 from the OSS follow-up spec.

Refs: docs/superpowers/specs/2026-05-10-myah-oss-followup-issues.md
Phase B.4.

* feat(plugin): bootstrap user.default_model from hermes config

Extends _bootstrap_user_id() to read default_model from /whoami's OSS
response and POST it to the platform's /api/v1/users/user/default-model
endpoint. Idempotent and best-effort: logs and continues on any
failure, never blocking the bootstrap.

Closes Issue #5 plugin side from the OSS follow-up spec.

Refs: docs/superpowers/specs/2026-05-10-myah-oss-followup-issues.md
Phase C.4.

* feat(plugin): F6 cron->chat output watcher for stock vanilla

Closes Issue #6 from the OSS follow-up spec without requiring an
upstream PR or core modification. The watcher polls the stable
~/.hermes/cron/output/{job_id}/*.md convention every 2s, reads job
metadata via vanilla cron.jobs.load_jobs(), and POSTs to the
platform's existing /api/v1/processes/webhook/run-complete.

Trade-off: tool_calls_log is None because the on-disk .md doesn't
carry it. Cron AG-UI render_* artifacts deliver as plain Markdown
(accepted degradation per spec).

Started lazily from pre_gateway_dispatch hook to avoid Python 3.12+
asyncio.get_event_loop() deprecation. Silent no-op when
MYAH_PLATFORM_BASE_URL is unset. Filtered to origin.platform == 'myah'
so telegram/discord cron jobs are unaffected.

Includes a CI guard that catches upstream API drift on
cron.jobs.OUTPUT_DIR and load_jobs.

Refs: docs/superpowers/specs/2026-05-10-myah-oss-followup-issues.md
Phase E.

* chore(plugin): add _chat_id_session_keys + _native_streaming_used state

Prepares MyahAdapter for the Phase F streaming workaround. New fields
are wired up but have no consumer yet — adding them in a separate
commit lets the next commits be focused. Includes defensive cleanup
in the hosted-mode failure path so the map doesn't leak entries when
model-override fails.

Refs: docs/superpowers/specs/2026-05-10-myah-oss-followup-issues.md
Phase F.1.

* feat(plugin): F-streaming pre_llm_call hook for stock vanilla

Closes Issue #7 from the OSS follow-up spec. Plugin-side replacement
for the fork's get_structured_callbacks polymorphic dispatch in
gateway/run.py:_run_agent.

Three coordinated pieces:
1. pre_llm_call hook (runtime_extensions/streaming_callbacks.py) that
   mutates AIAgent callback attributes just-in-time before each LLM
   call. Resolves session_key from chat_id via the adapter's
   _chat_id_session_keys map; finds the cached agent in
   runner._agent_cache (private-attribute pattern; same risk class as
   _session_model_overrides used elsewhere in the plugin).
2. Marks _native_streaming_used per session so send() knows.
3. Duplicate-send suppression in MyahAdapter.send() — drops the
   gateway's final adapter.send(chat_id, full_response) call after
   streaming completes, preventing the message from duplicating.

CI guards verify pre_llm_call is in VALID_HOOKS, GatewayRunner has
_agent_cache, and AIAgent has the four callback attributes.

Removable when upstream U-CB PR lands. Until then, structured streaming
on stock vanilla matches the fork's behaviour.

Refs: docs/superpowers/specs/2026-05-10-myah-oss-followup-issues.md
Phase F.

* fix(plugin): lazy gateway_runner self-discovery via _gateway_runner_ref

`GatewayRunner._create_adapter` sets `adapter.gateway_runner` only for
built-in adapters (Discord, Webhook at gateway/run.py:4620/4730). For
plugin-registered platforms it returns the adapter from
`platform_registry.create_adapter` directly without setting the backref,
so MyahAdapter.gateway_runner stays None forever.

This silently disabled three flows:
1. Phase B model override block (_handle_message_endpoint) — every model
   pick from the platform was dropped, agent always used hermes default.
2. Per-message model attribution (_dispatch_message finally) —
   _attribution_model/_attribution_provider stayed empty.
3. Phase F pre_llm_call streaming hook — early-returned at runner-None.

Diagnostic confirmed during E2E:
  [myah-modelswitch] DIAGNOSTIC entry _override_model='moonshotai/kimi-k2.6'
  runner_is_none=True gateway_runner_id=None

Add `MyahAdapter._resolve_runner()` that reads the upstream-exposed
module weakref `gateway.run._gateway_runner_ref` (populated by
GatewayRunner.__init__ at run.py:1206) when the fast-path attribute is
unset. Cache the resolved runner on `self.gateway_runner` for next
call. Same direct-attribute access pattern Tier 2B established for
`_session_model_overrides` and `_agent_cache` access.

Replace three reader sites — Phase B override, per-message attribution,
admin route registration — with `self._resolve_runner()`. Update the
`pre_llm_call` hook in `streaming_callbacks.py` similarly; keep a
defensive fallback to `getattr(adapter, 'gateway_runner', None)` for
older plugin builds.

Works on both this branch and stock vanilla pip-installed hermes —
both expose the same `_gateway_runner_ref` symbol. Hosted Myah
behavior is unchanged because the fast-path returns the externally-set
attribute when present.

Verified E2E by sending a curl POST to /myah/v1/message with
model='moonshotai/kimi-k2.6'; observed all three modelswitch INFO logs
fire, agent's auxiliary client used `provider=openrouter
model=moonshotai/kimi-k2.6`.

Refs: e2e-output/report.md ISSUE-001

* fix(plugin): add /myah/v1/admin/config endpoint for hermes config readback

Phase C.5's `fetch_hermes_default_model` previously hit the hermes
dashboard at :9119/api/config which requires `HERMES_WEB_SESSION_TOKEN`
— a token the OSS `setup-myah-oss.sh` script doesn't generate. Result:
the dashboard returned 401, the helper silently returned None, the
platform's /whoami returned `default_model: null`, and no sync ever
fired. User stuck with open-webui's gpt-4o-mini default.

Hosted Myah dodged this because the per-user-container spawner injects
both tokens. OSS users have to opt into dashboard auth manually, which
is invisible / poorly documented.

Replace the dashboard call with a new plugin-side endpoint
`GET /myah/v1/admin/config` that:
- Reads `get_hermes_home() / 'config.yaml'` in-process
- Returns only the model block (provider/default/base_url/api_mode) —
  no API keys, no plugin settings
- Auths via MYAH_ADAPTER_AUTH_KEY (same bearer as every other
  /myah/v1/admin/* endpoint) which OSS sets to empty (allow-all) and
  hosted spawner sets to the per-user token

The platform's `fetch_hermes_default_model` rewires to call this new
endpoint on the adapter port (8643 OSS, per-user-container port
hosted) instead of the dashboard. Auth simplifies: same bearer
everywhere, no separate session token needed.

Drops the broken plugin-side POST to /api/v1/users/user/default-model
(that endpoint requires JWT auth via get_verified_user — the plugin
only has the agent bearer). The platform's /whoami now performs the
sync itself directly via Users.update_user_by_id — single-tenant OSS
makes this safe; multi-tenant hosted doesn't reach this path because
of the deployment_mode gate.

Tests:
- 7 new tests in tests/test_runtime_admin_config.py covering happy
  path, missing file, malformed YAML, partial model block, auth on/off
- 4 new tests in platform test_myah.py covering the sync behavior
- E2E verified: /whoami returns hermes default; DB updates to match;
  deliberate user choices (anything not gpt-4o-mini) are preserved

Refs: e2e-output/report.md ISSUE-004

* fix(plugin): add /myah/v1/admin/providers endpoint with credential status

Phase C.2's catalog short-circuit and the planned auto-import path both
need a way to enumerate hermes-configured providers from the platform.
The existing dashboard endpoint at /api/plugins/myah-admin/providers
returns the raw catalog without a 'has_credential' field — callers had
no way to distinguish 'provider available in UI' from 'user actually
configured this'. The dashboard also requires HERMES_WEB_SESSION_TOKEN
which OSS users don't have.

New plugin-side admin endpoint:
  GET /myah/v1/admin/providers
  -> {"providers": [{"id": ..., "has_credential": bool, ...}, ...]}

Calls the dashboard's _build_catalog() in-process (same plugin pip
package) and enriches each entry with has_credential computed from:
  1. auth.json['credential_pool'] — the canonical hermes-configured
     provider set (covers both api_key + oauth via 'hermes auth')
  2. auth.json['providers'] — legacy OAuth-token storage
  3. os.environ[<env_var>] — api_key fallback for providers configured
     via .env (e.g. OPENROUTER_API_KEY)

Auth uses the standard adapter auth_key (MYAH_AGENT_BEARER_TOKEN in
both OSS and hosted) — no separate session token needed.

E2E verified: against the user's hermes (6 configured providers:
openrouter, opencode-go, zai, openai-codex, copilot, xiaomi), the
endpoint returns 36 total providers with exactly those 6 flagged as
has_credential=True.

Tests added (7 in tests/test_runtime_admin_providers.py): api_key via
env, oauth via auth.json.providers, credential_pool path, all-three
empty, response shape, catalog failure, auth on/off.

Refs: e2e-output/report.md ISSUE-003

* fix(plugin): surface inline error when gateway suppresses failed response

When the agent's LLM call fails (e.g. provider 402 / fallback exhausted /
network timeout), the agent's `run_conversation` returns:
  { final_response: 'API call failed after 3 retries: ...', failed: True }

But the gateway's `_run_agent` at gateway/run.py:14701-14720 constructs
a NEW response dict that DOES NOT preserve the `failed` field. The
downstream suppression check at gateway/run.py:15326 then sees
`failed=None` (falsy), treats the run as successful, and combined with
`native_streamed=True` (set optimistically when our structured
callbacks were wired) suppresses the call to `adapter.send()`.

Net result: user sees 'Thinking...' forever — no token streamed, no
error rendered, no indication anything happened. Backend log shows
'Suppressing normal final send for session ... (streamed=False
previewed=False native_streamed=True)' with no follow-up Send.

We can't fix the upstream bug (don't-touch-hermes-core constraint).
Plugin-side workaround: track whether `stream_delta` actually fired
for each session. In `_dispatch_message`'s finally block (right before
emitting `run.completed`), if no stream_delta ever fired for this
session, emit a generic 'LLM call did not produce a response' error as
a synthetic `message.delta` event. The frontend renders this inline so
the user sees SOMETHING instead of staring at an empty Thinking spinner.

Three coordinated changes:
1. MyahAdapter._stream_delta_invoked: new per-session set tracking
   actual stream_delta callback invocations.
2. get_structured_callbacks: stream_delta wrapper marks the session
   on first call.
3. _dispatch_message finally: emit error message.delta when the set
   doesn't contain this session_key by the time the run ends.

Also adds a `post_llm_call` hook in streaming_callbacks.py for cases
where the agent's run_conversation completes normally and the
post-call hook can surface the actual response text. The hook is
defensive and complements the _dispatch_message catch-all.

E2E verified: triggered openrouter 402 fallback via curl POST to
/myah/v1/message with model=moonshotai/kimi-k2.6; SSE stream now
contains a message.delta event with the user-visible error string
followed by run.completed. Browser test confirmed the error renders
inline in the chat instead of empty Thinking spinner.

Tests added (5 in test_streaming_callbacks.py): post_llm_call success
path, no-op when stream fired, ignores other platforms, ignores empty
response, no-op when no active stream, post_llm_call in VALID_HOOKS
CI guard.

Refs: e2e-output/report.md (new finding — surfaced during user
manual testing after the original 4 ISSUE fixes landed)

* fix(plugin): slash commands no longer trigger false-positive warning

The previous gateway-suppression-bug workaround checked
`session_key in _stream_delta_invoked` to decide whether to emit the
fallback warning. `_stream_delta_invoked` is marked inside the
`stream_delta_callback` wrapper in `get_structured_callbacks` — which
only fires for LLM streaming output.

Slash commands like `/model` deliver their response a different way:
hermes' gateway processes the command in-process and calls
`adapter.send(chat_id, response_text)`. `send()` pushes a
`message.delta` event via `_push_event_sync` directly — never
touching the stream_delta callback. So `_stream_delta_invoked` stays
empty for slash commands, and the finally block fired the warning
even after a valid response had just been delivered.

User-visible bug: chat showed the /model picker output with the
warning text appended at the end:
  Current: gpt-5.4-mini on OpenAI Codex
  ...
  /model <name> --global — persist⚠️ The agent's LLM call did not
  produce a response. This usually means...

Fix: add `_stream_had_content`, a per-stream_id set that's marked
whenever ANY `message.delta` event passes through
`_push_event_sync`. Unified tracking across:
- stream_delta_callback wrapper (LLM streaming output)
- send() live-chat path (slash commands, agent reply)
- send() cron-delivery live-preview
- post_llm_call hook synthetic emission

The `_dispatch_message` finally block now checks
`stream_id not in self._stream_had_content` instead of
`session_key not in self._stream_delta_invoked`. False-positive
eliminated; real failure case still triggers the warning.

E2E verified:
- /model command: SSE stream contains exactly one message.delta with
  the picker text, NO warning appended
- Failure case (kimi via openrouter 402): warning still emitted as
  expected

Tests added (4 in TestStreamHadContent):
- message.delta push marks the set
- non-delta events (reasoning/status/tool/run.*) don't mark
- unknown stream_id is a graceful no-op
- repeated pushes are idempotent (set semantics)

Found via user manual testing — chat log analysis showed the warning
appended to a successful /model response.

Refs: e2e-output/report.md (follow-up to ISSUE-001..004 fixes)
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