mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
commit
dda3894523
108 changed files with 7729 additions and 1207 deletions
204
AGENTS.md
204
AGENTS.md
|
|
@ -39,12 +39,17 @@ hermes-agent/
|
|||
│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
|
||||
│ │ # homeassistant, signal, matrix, mattermost, email, sms,
|
||||
│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
|
||||
│ │ # webhook, api_server, ...). See ADDING_A_PLATFORM.md.
|
||||
│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
|
||||
│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
|
||||
├── plugins/ # Plugin system (see "Plugins" section below)
|
||||
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
|
||||
│ ├── context_engine/ # Context-engine plugins
|
||||
│ └── <others>/ # Dashboard, image-gen, disk-cleanup, examples, ...
|
||||
│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
|
||||
│ ├── hermes-achievements/ # Gamified achievement tracking
|
||||
│ ├── observability/ # Metrics / traces / logs plugin
|
||||
│ ├── image_gen/ # Image-generation providers
|
||||
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
|
||||
│ # spotify, strike-freedom-cockpit, ...
|
||||
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
|
||||
├── skills/ # Built-in skills bundled with the repo
|
||||
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
|
||||
|
|
@ -55,7 +60,7 @@ hermes-agent/
|
|||
├── environments/ # RL training environments (Atropos)
|
||||
├── scripts/ # run_tests.sh, release.py, auxiliary scripts
|
||||
├── website/ # Docusaurus docs site
|
||||
└── tests/ # Pytest suite (~15k tests across ~700 files as of Apr 2026)
|
||||
└── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026)
|
||||
```
|
||||
|
||||
**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
|
||||
|
|
@ -314,9 +319,9 @@ registry.register(
|
|||
)
|
||||
```
|
||||
|
||||
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset.
|
||||
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only *exposed to an agent* if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from.
|
||||
|
||||
Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain.
|
||||
Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step.
|
||||
|
||||
The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
|
||||
|
||||
|
|
@ -338,6 +343,22 @@ The registry handles schema collection, dispatch, availability checking, and err
|
|||
section is handled automatically by the deep-merge and does NOT require
|
||||
a version bump.
|
||||
|
||||
### Top-level `config.yaml` sections (non-exhaustive):
|
||||
|
||||
`model`, `agent`, `terminal`, `compression`, `display`, `stt`, `tts`,
|
||||
`memory`, `security`, `delegation`, `smart_model_routing`, `checkpoints`,
|
||||
`auxiliary`, `curator`, `skills`, `gateway`, `logging`, `cron`, `profiles`,
|
||||
`plugins`, `honcho`.
|
||||
|
||||
`auxiliary` holds per-task overrides for side-LLM work (curator, vision,
|
||||
embedding, title generation, session_search, etc.) — each task can pin
|
||||
its own provider/model/base_url/max_tokens/reasoning_effort. See
|
||||
`agent/auxiliary_client.py::_resolve_auto` for resolution order.
|
||||
|
||||
`curator` holds the background skill-maintenance config —
|
||||
`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
|
||||
`archive_after_days`, `backup` (nested).
|
||||
|
||||
### .env variables (SECRETS ONLY — API keys, tokens, passwords):
|
||||
1. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
|
||||
```python
|
||||
|
|
@ -544,11 +565,176 @@ niche skills belong in `optional-skills/`.
|
|||
|
||||
### SKILL.md frontmatter
|
||||
|
||||
Standard fields: `name`, `description`, `version`, `platforms`
|
||||
(OS-gating list: `[macos]`, `[linux, macos]`, ...),
|
||||
Standard fields: `name`, `description`, `version`, `author`, `license`,
|
||||
`platforms` (OS-gating list: `[macos]`, `[linux, macos]`, ...),
|
||||
`metadata.hermes.tags`, `metadata.hermes.category`,
|
||||
`metadata.hermes.config` (config.yaml settings the skill needs — stored
|
||||
under `skills.config.<key>`, prompted during setup, injected at load time).
|
||||
`metadata.hermes.related_skills`, `metadata.hermes.config` (config.yaml
|
||||
settings the skill needs — stored under `skills.config.<key>`, prompted
|
||||
during setup, injected at load time).
|
||||
|
||||
Top-level `tags:` and `category:` are also accepted and mirrored from
|
||||
`metadata.hermes.*` by the loader.
|
||||
|
||||
---
|
||||
|
||||
## Toolsets
|
||||
|
||||
All toolsets are defined in `toolsets.py` as a single `TOOLSETS` dict.
|
||||
Each platform's adapter picks a base toolset (e.g. Telegram uses
|
||||
`"messaging"`); `_HERMES_CORE_TOOLS` is the default bundle most
|
||||
platforms inherit from.
|
||||
|
||||
Current toolset keys: `browser`, `clarify`, `code_execution`, `cronjob`,
|
||||
`debugging`, `delegation`, `discord`, `discord_admin`, `feishu_doc`,
|
||||
`feishu_drive`, `file`, `homeassistant`, `image_gen`, `kanban`, `memory`,
|
||||
`messaging`, `moa`, `rl`, `safe`, `search`, `session_search`, `skills`,
|
||||
`spotify`, `terminal`, `todo`, `tts`, `video`, `vision`, `web`, `yuanbao`.
|
||||
|
||||
Enable/disable per platform via `hermes tools` (the curses UI) or the
|
||||
`tools.<platform>.enabled` / `tools.<platform>.disabled` lists in
|
||||
`config.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Delegation (`delegate_task`)
|
||||
|
||||
`tools/delegate_tool.py` spawns a subagent with an isolated
|
||||
context + terminal session. Synchronous: the parent waits for the
|
||||
child's summary before continuing its own loop — if the parent is
|
||||
interrupted, the child is cancelled.
|
||||
|
||||
Two shapes:
|
||||
|
||||
- **Single:** pass `goal` (+ optional `context`, `toolsets`).
|
||||
- **Batch (parallel):** pass `tasks: [...]` — each gets its own subagent
|
||||
running concurrently. Concurrency is capped by
|
||||
`delegation.max_concurrent_children` (default 3).
|
||||
|
||||
Roles:
|
||||
|
||||
- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
|
||||
`clarify`, `memory`, `send_message`, `execute_code`.
|
||||
- `role="orchestrator"` — retains `delegate_task` so it can spawn its
|
||||
own workers. Gated by `delegation.orchestrator_enabled` (default true)
|
||||
and bounded by `delegation.max_spawn_depth` (default 2).
|
||||
|
||||
Key config knobs (under `delegation:` in `config.yaml`):
|
||||
`max_concurrent_children`, `max_spawn_depth`, `child_timeout_seconds`,
|
||||
`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
|
||||
`max_iterations`.
|
||||
|
||||
Synchronicity rule: delegate_task is **not** durable. For long-running
|
||||
work that must outlive the current turn, use `cronjob` or
|
||||
`terminal(background=True, notify_on_complete=True)` instead.
|
||||
|
||||
---
|
||||
|
||||
## Curator (skill lifecycle)
|
||||
|
||||
Background skill-maintenance system that tracks usage on agent-created
|
||||
skills and auto-archives stale ones. Users never lose skills; archives
|
||||
go to `~/.hermes/skills/.archive/` and are restorable.
|
||||
|
||||
- **Core:** `agent/curator.py` (review loop, auto-transitions, LLM review
|
||||
prompt) + `agent/curator_backup.py` (pre-run tar.gz snapshots).
|
||||
- **CLI:** `hermes_cli/curator.py` wires `hermes curator <verb>` where
|
||||
verbs are: `status`, `run`, `pause`, `resume`, `pin`, `unpin`,
|
||||
`archive`, `restore`, `prune`, `backup`, `rollback`.
|
||||
- **Telemetry:** `tools/skill_usage.py` owns the sidecar
|
||||
`~/.hermes/skills/.usage.json` — per-skill `use_count`, `view_count`,
|
||||
`patch_count`, `last_activity_at`, `state` (active / stale /
|
||||
archived), `pinned`.
|
||||
|
||||
Invariants:
|
||||
- Curator only touches skills with `created_by: "agent"` provenance —
|
||||
bundled + hub-installed skills are off-limits.
|
||||
- Never deletes; max destructive action is archive.
|
||||
- Pinned skills are exempt from every auto-transition and from the
|
||||
LLM review pass.
|
||||
- `skill_manage(action="delete")` refuses pinned skills; patch/edit/
|
||||
write_file/remove_file go through so the agent can keep improving
|
||||
pinned skills.
|
||||
|
||||
Config section (`curator:` in `config.yaml`):
|
||||
`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
|
||||
`archive_after_days`, `backup.*`.
|
||||
|
||||
Full user-facing docs: `website/docs/user-guide/features/curator.md`.
|
||||
|
||||
---
|
||||
|
||||
## Cron (scheduled jobs)
|
||||
|
||||
`cron/jobs.py` (job store) + `cron/scheduler.py` (tick loop). Agents
|
||||
schedule jobs via the `cronjob` tool; users via `hermes cron <verb>`
|
||||
(`list`, `add`, `edit`, `pause`, `resume`, `run`, `remove`) or the
|
||||
`/cron` slash command.
|
||||
|
||||
Supported schedule formats:
|
||||
- Duration: `"30m"`, `"2h"`, `"1d"`
|
||||
- "every" phrase: `"every 2h"`, `"every monday 9am"`
|
||||
- 5-field cron expression: `"0 9 * * *"`
|
||||
- ISO timestamp (one-shot): `"2026-06-01T09:00:00Z"`
|
||||
|
||||
Per-job fields include `skills` (load specific skills), `model` /
|
||||
`provider` overrides, `script` (pre-run data-collection script whose
|
||||
stdout is injected into the prompt; `no_agent=True` turns the script
|
||||
into the entire job), `context_from` (chain job A's last output into
|
||||
job B's prompt), `workdir` (run in a specific directory with its
|
||||
`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery.
|
||||
|
||||
Hardening invariants:
|
||||
- **3-minute hard interrupt** on cron sessions — runaway agent loops
|
||||
cannot monopolize the scheduler.
|
||||
- Catchup window: half the job's period, clamped to 120s–2h.
|
||||
- Grace window: 120s for one-shot jobs whose fire time was missed.
|
||||
- File lock at `~/.hermes/cron/.tick.lock` prevents duplicate ticks
|
||||
across processes.
|
||||
- Cron sessions pass `skip_memory=True` by default; memory providers
|
||||
intentionally do not run during cron.
|
||||
|
||||
Cron deliveries are **not** mirrored into the target gateway session —
|
||||
they land in their own cron session with a header/footer frame so the
|
||||
main conversation's message-role alternation stays intact.
|
||||
|
||||
---
|
||||
|
||||
## Kanban (multi-agent work queue)
|
||||
|
||||
Durable SQLite-backed board that lets multiple profiles / workers
|
||||
collaborate on shared tasks. Users drive it via `hermes kanban <verb>`;
|
||||
workers spawned by the dispatcher drive it via a dedicated `kanban_*`
|
||||
toolset so their schema footprint is zero when they're not inside a
|
||||
kanban task.
|
||||
|
||||
- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
|
||||
`init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
|
||||
`unlink`, `comment`, `complete`, `block`, `unblock`, `archive`,
|
||||
`tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`,
|
||||
`assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`.
|
||||
- **Worker toolset:** `tools/kanban_tools.py` exposes `kanban_show`,
|
||||
`kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`,
|
||||
`kanban_create`, `kanban_link` — gated by `HERMES_KANBAN_TASK` so
|
||||
the schema only appears for processes actually running as a worker.
|
||||
- **Dispatcher:** long-lived loop that (default every 60s) reclaims
|
||||
stale claims, promotes ready tasks, atomically claims, and spawns
|
||||
assigned profiles. Runs **inside the gateway** by default via
|
||||
`kanban.dispatch_in_gateway: true`.
|
||||
- **Plugin assets:** `plugins/kanban/dashboard/` (web UI) +
|
||||
`plugins/kanban/systemd/` (`hermes-kanban-dispatcher.service` for
|
||||
standalone dispatcher deployment).
|
||||
|
||||
Isolation model:
|
||||
- **Board** is the hard boundary — workers are spawned with
|
||||
`HERMES_KANBAN_BOARD` pinned in their env so they can't see other
|
||||
boards.
|
||||
- **Tenant** is a soft namespace *within* a board — one specialist
|
||||
fleet can serve multiple businesses with workspace-path + memory-key
|
||||
isolation.
|
||||
- After ~5 consecutive spawn failures on the same task the dispatcher
|
||||
auto-blocks it to prevent spin loops.
|
||||
|
||||
Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -466,17 +466,10 @@ class SessionManager:
|
|||
except Exception:
|
||||
logger.debug("Failed to update ACP session metadata", exc_info=True)
|
||||
|
||||
# Replace stored messages with current history.
|
||||
db.clear_messages(state.session_id)
|
||||
for msg in state.history:
|
||||
db.append_message(
|
||||
session_id=state.session_id,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content"),
|
||||
tool_name=msg.get("tool_name") or msg.get("name"),
|
||||
tool_calls=msg.get("tool_calls"),
|
||||
tool_call_id=msg.get("tool_call_id"),
|
||||
)
|
||||
# Replace stored messages with current history atomically so a
|
||||
# mid-rewrite failure rolls back and the previously persisted
|
||||
# conversation is preserved (salvaged from #13675).
|
||||
db.replace_messages(state.session_id, state.history)
|
||||
except Exception:
|
||||
logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -259,10 +259,12 @@ _PROVIDERS_WITHOUT_VISION: frozenset = frozenset({
|
|||
"kimi-coding-cn",
|
||||
})
|
||||
|
||||
# OpenRouter app attribution headers (base — always sent)
|
||||
# OpenRouter app attribution headers (base — always sent).
|
||||
# `X-Title` is the canonical attribution header OpenRouter's dashboard
|
||||
# reads; the previous `X-OpenRouter-Title` label was not recognized there.
|
||||
_OR_HEADERS_BASE = {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||
"X-OpenRouter-Title": "Hermes Agent",
|
||||
"X-Title": "Hermes Agent",
|
||||
"X-OpenRouter-Categories": "productivity,cli-agent",
|
||||
}
|
||||
|
||||
|
|
@ -567,7 +569,12 @@ class _CodexCompletionsAdapter:
|
|||
# API allows it.
|
||||
pass
|
||||
else:
|
||||
effort = reasoning_cfg.get("effort", "medium")
|
||||
# Truthy-only check mirrors agent/transports/codex.py
|
||||
# build_kwargs(): falsy values (None, "", 0) fall back
|
||||
# to the default rather than being forwarded to the
|
||||
# Codex backend, which rejects e.g. {"effort": null}
|
||||
# with a 400.
|
||||
effort = reasoning_cfg.get("effort") or "medium"
|
||||
# Codex backend rejects "minimal"; clamp to "low" to
|
||||
# match the main-agent Codex transport behavior.
|
||||
if effort == "minimal":
|
||||
|
|
@ -1643,6 +1650,39 @@ def _is_payment_error(exc: Exception) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _is_rate_limit_error(exc: Exception) -> bool:
|
||||
"""Detect rate-limit errors that warrant provider fallback.
|
||||
|
||||
Returns True for HTTP 429 errors whose message indicates rate limiting
|
||||
(as opposed to billing/quota exhaustion, which _is_payment_error handles).
|
||||
Also catches OpenAI SDK RateLimitError instances that may not set
|
||||
.status_code on the exception object.
|
||||
"""
|
||||
status = getattr(exc, "status_code", None)
|
||||
err_lower = str(exc).lower()
|
||||
|
||||
# OpenAI SDK's RateLimitError sometimes omits .status_code —
|
||||
# detect by class name so we don't miss these. (PR #8023 pattern)
|
||||
if type(exc).__name__ == "RateLimitError":
|
||||
return True
|
||||
|
||||
if status == 429:
|
||||
# Distinguish rate-limit from billing: billing keywords are handled
|
||||
# by _is_payment_error, everything else on 429 is a rate limit.
|
||||
if any(kw in err_lower for kw in (
|
||||
"rate limit", "rate_limit", "too many requests",
|
||||
"try again", "retry after", "resets in",
|
||||
)):
|
||||
return True
|
||||
# Generic 429 without billing keywords = likely a rate limit
|
||||
if not any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds", "billing",
|
||||
"payment required", "can only afford",
|
||||
)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_connection_error(exc: Exception) -> bool:
|
||||
"""Detect connection/network errors that warrant provider fallback.
|
||||
|
||||
|
|
@ -3127,8 +3167,14 @@ def _resolve_task_provider_model(
|
|||
|
||||
if task:
|
||||
# Config.yaml is the primary source for per-task overrides.
|
||||
if cfg_base_url:
|
||||
if cfg_base_url and cfg_api_key:
|
||||
# Both base_url and api_key explicitly set → custom endpoint.
|
||||
return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode
|
||||
if cfg_base_url and cfg_provider and cfg_provider != "auto":
|
||||
# base_url set without api_key but with a known provider — use
|
||||
# the provider so it can resolve credentials from env vars
|
||||
# (e.g. OPENROUTER_API_KEY) instead of locking into "custom".
|
||||
return cfg_provider, resolved_model, cfg_base_url, None, resolved_api_mode
|
||||
if cfg_provider and cfg_provider != "auto":
|
||||
return cfg_provider, resolved_model, None, None, resolved_api_mode
|
||||
|
||||
|
|
@ -3529,7 +3575,7 @@ def call_llm(
|
|||
except Exception as retry_err:
|
||||
# If the max_tokens retry also hits a payment or connection
|
||||
# error, fall through to the fallback chain below.
|
||||
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
|
||||
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err) or _is_rate_limit_error(retry_err)):
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
|
|
@ -3612,13 +3658,27 @@ def call_llm(
|
|||
# Codex/OAuth tokens that authenticate but whose endpoint is down,
|
||||
# and providers the user never configured that got picked up by
|
||||
# the auto-detection chain.
|
||||
should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
|
||||
#
|
||||
# ── Rate-limit fallback (#13579) ─────────────────────────────
|
||||
# When the provider returns a 429 rate-limit (not billing), fall
|
||||
# back to an alternative provider instead of exhausting retries
|
||||
# against the same rate-limited endpoint.
|
||||
should_fallback = (
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
)
|
||||
# Only try alternative providers when the user didn't explicitly
|
||||
# configure this task's provider. Explicit provider = hard constraint;
|
||||
# auto (the default) = best-effort fallback chain. (#7559)
|
||||
is_auto = resolved_provider in ("auto", "", None)
|
||||
if should_fallback and is_auto:
|
||||
reason = "payment error" if _is_payment_error(first_err) else "connection error"
|
||||
if _is_payment_error(first_err):
|
||||
reason = "payment error"
|
||||
elif _is_rate_limit_error(first_err):
|
||||
reason = "rate limit"
|
||||
else:
|
||||
reason = "connection error"
|
||||
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
|
||||
task or "call", reason, resolved_provider, first_err)
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
|
|
@ -3821,7 +3881,7 @@ async def async_call_llm(
|
|||
except Exception as retry_err:
|
||||
# If the max_tokens retry also hits a payment or connection
|
||||
# error, fall through to the fallback chain below.
|
||||
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
|
||||
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err) or _is_rate_limit_error(retry_err)):
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
|
|
@ -3890,11 +3950,20 @@ async def async_call_llm(
|
|||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
|
||||
# ── Payment / connection fallback (mirrors sync call_llm) ─────
|
||||
should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
|
||||
# ── Payment / connection / rate-limit fallback (mirrors sync call_llm) ──
|
||||
should_fallback = (
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
)
|
||||
is_auto = resolved_provider in ("auto", "", None)
|
||||
if should_fallback and is_auto:
|
||||
reason = "payment error" if _is_payment_error(first_err) else "connection error"
|
||||
if _is_payment_error(first_err):
|
||||
reason = "payment error"
|
||||
elif _is_rate_limit_error(first_err):
|
||||
reason = "rate limit"
|
||||
else:
|
||||
reason = "connection error"
|
||||
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
|
||||
task or "call", reason, resolved_provider, first_err)
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
|
|
|
|||
|
|
@ -993,15 +993,39 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
|||
return None
|
||||
|
||||
@staticmethod
|
||||
def _with_summary_prefix(summary: str) -> str:
|
||||
"""Normalize summary text to the current compaction handoff format."""
|
||||
def _strip_summary_prefix(summary: str) -> str:
|
||||
"""Return summary body without the current or legacy handoff prefix."""
|
||||
text = (summary or "").strip()
|
||||
for prefix in (LEGACY_SUMMARY_PREFIX, SUMMARY_PREFIX):
|
||||
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX):
|
||||
if text.startswith(prefix):
|
||||
text = text[len(prefix):].lstrip()
|
||||
break
|
||||
return text[len(prefix):].lstrip()
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def _with_summary_prefix(cls, summary: str) -> str:
|
||||
"""Normalize summary text to the current compaction handoff format."""
|
||||
text = cls._strip_summary_prefix(summary)
|
||||
return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX
|
||||
|
||||
@staticmethod
|
||||
def _is_context_summary_content(content: Any) -> bool:
|
||||
text = _content_text_for_contains(content).lstrip()
|
||||
return text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX)
|
||||
|
||||
@classmethod
|
||||
def _find_latest_context_summary(
|
||||
cls,
|
||||
messages: List[Dict[str, Any]],
|
||||
start: int,
|
||||
end: int,
|
||||
) -> tuple[Optional[int], str]:
|
||||
"""Find the newest handoff summary inside a compression window."""
|
||||
for idx in range(end - 1, start - 1, -1):
|
||||
content = messages[idx].get("content")
|
||||
if cls._is_context_summary_content(content):
|
||||
return idx, cls._strip_summary_prefix(_content_text_for_contains(content))
|
||||
return None, ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool-call / tool-result pair integrity helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -1308,6 +1332,15 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
|||
return messages
|
||||
|
||||
turns_to_summarize = messages[compress_start:compress_end]
|
||||
summary_idx, summary_body = self._find_latest_context_summary(
|
||||
messages,
|
||||
compress_start,
|
||||
compress_end,
|
||||
)
|
||||
if summary_idx is not None:
|
||||
if summary_body and not self._previous_summary:
|
||||
self._previous_summary = summary_body
|
||||
turns_to_summarize = messages[summary_idx + 1:compress_end]
|
||||
|
||||
if not self.quiet_mode:
|
||||
logger.info(
|
||||
|
|
@ -1385,6 +1418,19 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
|||
# Merge the summary into the first tail message instead
|
||||
# of inserting a standalone message that breaks alternation.
|
||||
_merge_summary_into_tail = True
|
||||
|
||||
# When the summary lands as a standalone role="user" message,
|
||||
# weak models read the verbatim "## Active Task" quote of a past
|
||||
# user request as fresh input (#11475, #14521). Append the explicit
|
||||
# end marker — the same one used in the merge-into-tail path — so
|
||||
# the model has a clear "summary above, not new input" signal.
|
||||
if not _merge_summary_into_tail and summary_role == "user":
|
||||
summary = (
|
||||
summary
|
||||
+ "\n\n--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---"
|
||||
)
|
||||
|
||||
if not _merge_summary_into_tail:
|
||||
compressed.append({"role": summary_role, "content": summary})
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ class FailoverReason(enum.Enum):
|
|||
thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid
|
||||
long_context_tier = "long_context_tier" # Anthropic "extra usage" tier gate
|
||||
oauth_long_context_beta_forbidden = "oauth_long_context_beta_forbidden" # Anthropic OAuth subscription rejects 1M context beta — disable beta and retry
|
||||
llama_cpp_grammar_pattern = "llama_cpp_grammar_pattern" # llama.cpp json-schema-to-grammar rejects regex escapes in `pattern` / `format` — strip from tools and retry
|
||||
|
||||
# Catch-all
|
||||
unknown = "unknown" # Unclassifiable — retry with backoff
|
||||
|
|
@ -470,6 +471,31 @@ def classify_api_error(
|
|||
should_compress=False,
|
||||
)
|
||||
|
||||
# llama.cpp's ``json-schema-to-grammar`` converter (used by its OAI
|
||||
# server to build GBNF tool-call parsers) rejects regex escape classes
|
||||
# like ``\d``/``\w``/``\s`` and most ``format`` values. MCP servers
|
||||
# routinely emit ``"pattern": "\\d{4}-\\d{2}-\\d{2}"`` for date/phone/
|
||||
# email params. llama.cpp surfaces this as HTTP 400 with one of a few
|
||||
# recognizable phrases; on match we strip ``pattern``/``format`` from
|
||||
# ``self.tools`` in the retry loop and retry once. Cloud providers are
|
||||
# unaffected — they accept these keywords and we never hit this branch.
|
||||
if (
|
||||
status_code == 400
|
||||
and (
|
||||
"error parsing grammar" in error_msg
|
||||
or "json-schema-to-grammar" in error_msg
|
||||
or (
|
||||
"unable to generate parser" in error_msg
|
||||
and "template" in error_msg
|
||||
)
|
||||
)
|
||||
):
|
||||
return _result(
|
||||
FailoverReason.llama_cpp_grammar_pattern,
|
||||
retryable=True,
|
||||
should_compress=False,
|
||||
)
|
||||
|
||||
# ── 2. HTTP status code classification ──────────────────────────
|
||||
|
||||
if status_code is not None:
|
||||
|
|
|
|||
230
agent/i18n.py
Normal file
230
agent/i18n.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
"""Lightweight internationalization (i18n) for Hermes static user-facing messages.
|
||||
|
||||
Scope (thin slice, by design): only the highest-impact static strings shown
|
||||
to the user by Hermes itself -- approval prompts, a handful of gateway slash
|
||||
command replies, restart-drain notices. Agent-generated output, log lines,
|
||||
error tracebacks, tool outputs, and slash-command descriptions all stay in
|
||||
English.
|
||||
|
||||
Catalog files live under ``locales/<lang>.yaml`` at the repo root. Each
|
||||
catalog is a flat dict keyed by dotted paths (e.g. ``approval.choose`` or
|
||||
``gateway.approval_expired``). Missing keys fall back to English; if English
|
||||
is missing too, the key path itself is returned so a broken catalog never
|
||||
crashes the agent.
|
||||
|
||||
Usage::
|
||||
|
||||
from agent.i18n import t
|
||||
print(t("approval.choose_long")) # current lang
|
||||
print(t("gateway.draining", count=3)) # {count} formatted
|
||||
print(t("approval.choose_long", lang="zh")) # explicit override
|
||||
|
||||
Language resolution order:
|
||||
1. Explicit ``lang=`` argument passed to :func:`t`
|
||||
2. ``HERMES_LANGUAGE`` environment variable (for tests / quick override)
|
||||
3. ``display.language`` from config.yaml
|
||||
4. ``"en"`` (baseline)
|
||||
|
||||
Supported languages: en, zh, ja, de, es. Unknown values fall back to en.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es")
|
||||
DEFAULT_LANGUAGE = "en"
|
||||
|
||||
# Accept a few natural aliases so users who type "chinese" / "zh-CN" / "jp"
|
||||
# get the right catalog instead of silently falling back to English.
|
||||
_LANGUAGE_ALIASES: dict[str, str] = {
|
||||
"english": "en", "en-us": "en", "en-gb": "en",
|
||||
"chinese": "zh", "mandarin": "zh", "zh-cn": "zh", "zh-tw": "zh", "zh-hans": "zh", "zh-hant": "zh",
|
||||
"japanese": "ja", "jp": "ja", "ja-jp": "ja",
|
||||
"german": "de", "deutsch": "de", "de-de": "de",
|
||||
"spanish": "es", "español": "es", "espanol": "es", "es-es": "es", "es-mx": "es",
|
||||
}
|
||||
|
||||
_catalog_cache: dict[str, dict[str, str]] = {}
|
||||
_catalog_lock = threading.Lock()
|
||||
|
||||
|
||||
def _locales_dir() -> Path:
|
||||
"""Return the directory containing locale YAML files.
|
||||
|
||||
Lives next to the repo root so both the bundled install and editable
|
||||
checkouts find it without PYTHONPATH gymnastics.
|
||||
"""
|
||||
# agent/i18n.py -> agent/ -> repo root
|
||||
return Path(__file__).resolve().parent.parent / "locales"
|
||||
|
||||
|
||||
def _normalize_lang(value: Any) -> str:
|
||||
"""Normalize a user-supplied language value to a supported code.
|
||||
|
||||
Accepts supported codes directly, common aliases (``chinese`` -> ``zh``),
|
||||
and case-insensitive regional tags (``zh-CN`` -> ``zh``). Returns the
|
||||
default language for unknown values.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return DEFAULT_LANGUAGE
|
||||
key = value.strip().lower()
|
||||
if not key:
|
||||
return DEFAULT_LANGUAGE
|
||||
if key in SUPPORTED_LANGUAGES:
|
||||
return key
|
||||
if key in _LANGUAGE_ALIASES:
|
||||
return _LANGUAGE_ALIASES[key]
|
||||
# Try stripping a region suffix (e.g. "pt-br" -> "pt" won't be supported,
|
||||
# but "zh-CN" -> "zh" will).
|
||||
base = key.split("-", 1)[0]
|
||||
if base in SUPPORTED_LANGUAGES:
|
||||
return base
|
||||
return DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def _load_catalog(lang: str) -> dict[str, str]:
|
||||
"""Load and flatten one locale YAML file into a dotted-key dict.
|
||||
|
||||
YAML files can be nested for human readability; this produces the flat
|
||||
key space :func:`t` expects. Cached per-language for the process.
|
||||
"""
|
||||
with _catalog_lock:
|
||||
cached = _catalog_cache.get(lang)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
path = _locales_dir() / f"{lang}.yaml"
|
||||
if not path.is_file():
|
||||
logger.debug("i18n catalog missing for %s at %s", lang, path)
|
||||
with _catalog_lock:
|
||||
_catalog_cache[lang] = {}
|
||||
return {}
|
||||
|
||||
try:
|
||||
import yaml # PyYAML is already a hermes dependency
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load i18n catalog %s: %s", path, exc)
|
||||
with _catalog_lock:
|
||||
_catalog_cache[lang] = {}
|
||||
return {}
|
||||
|
||||
flat: dict[str, str] = {}
|
||||
_flatten_into(raw, "", flat)
|
||||
with _catalog_lock:
|
||||
_catalog_cache[lang] = flat
|
||||
return flat
|
||||
|
||||
|
||||
def _flatten_into(node: Any, prefix: str, out: dict[str, str]) -> None:
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
child_key = f"{prefix}.{key}" if prefix else str(key)
|
||||
_flatten_into(value, child_key, out)
|
||||
elif isinstance(node, str):
|
||||
out[prefix] = node
|
||||
# Non-string, non-dict leaves are ignored -- catalogs are text-only.
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _config_language_cached() -> str | None:
|
||||
"""Read ``display.language`` from config.yaml once per process.
|
||||
|
||||
Cached because ``t()`` is called in hot paths (every approval prompt,
|
||||
every gateway reply) and re-reading YAML each call would be wasteful.
|
||||
``reset_language_cache()`` clears this when config changes at runtime
|
||||
(e.g. after the setup wizard).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
lang = (cfg.get("display") or {}).get("language")
|
||||
if lang:
|
||||
return _normalize_lang(lang)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read display.language from config: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def reset_language_cache() -> None:
|
||||
"""Invalidate cached language resolution and catalogs.
|
||||
|
||||
Call after :func:`hermes_cli.config.save_config` if a running process
|
||||
needs to pick up a changed ``display.language`` without restart.
|
||||
"""
|
||||
_config_language_cached.cache_clear()
|
||||
with _catalog_lock:
|
||||
_catalog_cache.clear()
|
||||
|
||||
|
||||
def get_language() -> str:
|
||||
"""Resolve the active language using env > config > default order."""
|
||||
env_lang = os.environ.get("HERMES_LANGUAGE")
|
||||
if env_lang:
|
||||
return _normalize_lang(env_lang)
|
||||
cfg_lang = _config_language_cached()
|
||||
if cfg_lang:
|
||||
return cfg_lang
|
||||
return DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str:
|
||||
"""Translate a dotted key to the active language.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key
|
||||
Dotted path into the catalog, e.g. ``"approval.choose_long"``.
|
||||
lang
|
||||
Explicit language override. Takes precedence over env + config.
|
||||
**format_kwargs
|
||||
``str.format`` substitution arguments (``t("gateway.drain", count=3)``
|
||||
expects a catalog entry with a ``{count}`` placeholder).
|
||||
|
||||
Returns
|
||||
-------
|
||||
The translated string, or the English fallback if the key is missing in
|
||||
the target language, or the bare key if English is also missing.
|
||||
"""
|
||||
target = _normalize_lang(lang) if lang else get_language()
|
||||
catalog = _load_catalog(target)
|
||||
value = catalog.get(key)
|
||||
|
||||
if value is None and target != DEFAULT_LANGUAGE:
|
||||
# Fall through to English rather than showing a key path to the user.
|
||||
value = _load_catalog(DEFAULT_LANGUAGE).get(key)
|
||||
|
||||
if value is None:
|
||||
# Last-ditch: return the key itself. A broken catalog should not
|
||||
# crash anything; it just looks ugly until someone fixes it.
|
||||
logger.debug("i18n miss: key=%r lang=%r", key, target)
|
||||
value = key
|
||||
|
||||
if format_kwargs:
|
||||
try:
|
||||
return value.format(**format_kwargs)
|
||||
except (KeyError, IndexError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"i18n format failed for key=%r lang=%r kwargs=%r: %s",
|
||||
key, target, format_kwargs, exc,
|
||||
)
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SUPPORTED_LANGUAGES",
|
||||
"DEFAULT_LANGUAGE",
|
||||
"t",
|
||||
"get_language",
|
||||
"reset_language_cache",
|
||||
]
|
||||
|
|
@ -513,6 +513,12 @@ PLATFORM_HINTS = {
|
|||
"image and is the WRONG path. Bare Unicode emoji in text is also not a substitute "
|
||||
"— when a sticker is the right response, use yb_send_sticker."
|
||||
),
|
||||
"api_server": (
|
||||
"You're responding through an API server. The rendering layer is unknown — "
|
||||
"assume plain text. No markdown formatting (no asterisks, bullets, headers, "
|
||||
"code fences). Treat this like a conversation, not a document. Keep responses "
|
||||
"brief and natural."
|
||||
),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
386
agent/think_scrubber.py
Normal file
386
agent/think_scrubber.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
"""Stateful scrubber for reasoning/thinking blocks in streamed assistant text.
|
||||
|
||||
``run_agent._strip_think_blocks`` is regex-based and correct for a complete
|
||||
string, but when it runs *per-delta* in ``_fire_stream_delta`` it destroys
|
||||
the state that downstream consumers (CLI ``_stream_delta``, gateway
|
||||
``GatewayStreamConsumer._filter_and_accumulate``) rely on.
|
||||
|
||||
Concretely, when MiniMax-M2.7 streams
|
||||
|
||||
delta1 = "<think>"
|
||||
delta2 = "Let me check their config"
|
||||
delta3 = "</think>"
|
||||
|
||||
the per-delta regex erases delta1 entirely (case 2: unterminated-open at
|
||||
boundary matches ``^<think>...``), so the downstream state machine never
|
||||
sees the open tag, treats delta2 as regular content, and leaks reasoning
|
||||
to the user. Consumers that don't run their own state machine (ACP,
|
||||
api_server, TTS) never had any defence at all — they just emitted
|
||||
whatever survived the upstream regex.
|
||||
|
||||
This module centralises the tag-suppression state machine at the
|
||||
upstream layer so every stream_delta_callback sees text that has
|
||||
already had reasoning blocks removed. Partial tags at delta
|
||||
boundaries are held back until the next delta resolves them, and
|
||||
end-of-stream flushing surfaces any held-back prose that turned out
|
||||
not to be a real tag.
|
||||
|
||||
Usage::
|
||||
|
||||
scrubber = StreamingThinkScrubber()
|
||||
for delta in stream:
|
||||
visible = scrubber.feed(delta)
|
||||
if visible:
|
||||
emit(visible)
|
||||
tail = scrubber.flush() # at end of stream
|
||||
if tail:
|
||||
emit(tail)
|
||||
|
||||
The scrubber is re-entrant per agent instance. Call ``reset()`` at
|
||||
the top of each new turn so a hung block from an interrupted prior
|
||||
stream cannot taint the next turn's output.
|
||||
|
||||
Tag variants handled (case-insensitive):
|
||||
``<think>``, ``<thinking>``, ``<reasoning>``, ``<thought>``,
|
||||
``<REASONING_SCRATCHPAD>``.
|
||||
|
||||
Block-boundary rule for opens: an opening tag is only treated as a
|
||||
reasoning-block opener when it appears at the start of the stream,
|
||||
after a newline (optionally followed by whitespace), or when only
|
||||
whitespace has been emitted on the current line. This prevents prose
|
||||
that *mentions* the tag name (e.g. ``"use <think> tags here"``) from
|
||||
being incorrectly suppressed. Closed pairs (``<think>X</think>``) are
|
||||
always suppressed regardless of boundary; a closed pair is an
|
||||
intentional, bounded construct.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
__all__ = ["StreamingThinkScrubber"]
|
||||
|
||||
|
||||
class StreamingThinkScrubber:
|
||||
"""Stateful scrubber for streaming reasoning/thinking blocks.
|
||||
|
||||
State machine:
|
||||
- ``_in_block``: True while inside an opened block, waiting for
|
||||
a close tag. All text inside is discarded.
|
||||
- ``_buf``: held-back partial-tag tail. Emitted / discarded on
|
||||
the next ``feed()`` call or by ``flush()``.
|
||||
- ``_last_emitted_ended_newline``: True iff the most recent
|
||||
emission to the consumer ended with ``\\n``, or nothing has
|
||||
been emitted yet (start-of-stream counts as a boundary). Used
|
||||
to decide whether an open tag at buffer position 0 is at a
|
||||
block boundary.
|
||||
"""
|
||||
|
||||
_OPEN_TAG_NAMES: Tuple[str, ...] = (
|
||||
"think",
|
||||
"thinking",
|
||||
"reasoning",
|
||||
"thought",
|
||||
"REASONING_SCRATCHPAD",
|
||||
)
|
||||
|
||||
# Materialise literal tag strings so the hot path does string
|
||||
# operations, not regex compilation per feed().
|
||||
_OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name}>" for name in _OPEN_TAG_NAMES)
|
||||
_CLOSE_TAGS: Tuple[str, ...] = tuple(f"</{name}>" for name in _OPEN_TAG_NAMES)
|
||||
|
||||
# Pre-compute the longest tag (for partial-tag hold-back bound).
|
||||
_MAX_TAG_LEN: int = max(len(tag) for tag in _OPEN_TAGS + _CLOSE_TAGS)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._in_block: bool = False
|
||||
self._buf: str = ""
|
||||
self._last_emitted_ended_newline: bool = True
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all state. Call at the top of every new turn."""
|
||||
self._in_block = False
|
||||
self._buf = ""
|
||||
self._last_emitted_ended_newline = True
|
||||
|
||||
def feed(self, text: str) -> str:
|
||||
"""Feed one delta; return the scrubbed visible portion.
|
||||
|
||||
May return an empty string when the entire delta is reasoning
|
||||
content or is being held back pending resolution of a partial
|
||||
tag at the boundary.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
buf = self._buf + text
|
||||
self._buf = ""
|
||||
out: list[str] = []
|
||||
|
||||
while buf:
|
||||
if self._in_block:
|
||||
# Hunt for the earliest close tag.
|
||||
close_idx, close_len = self._find_first_tag(
|
||||
buf, self._CLOSE_TAGS,
|
||||
)
|
||||
if close_idx == -1:
|
||||
# No close yet — hold back a potential partial
|
||||
# close-tag prefix; discard everything else.
|
||||
held = self._max_partial_suffix(buf, self._CLOSE_TAGS)
|
||||
self._buf = buf[-held:] if held else ""
|
||||
return "".join(out)
|
||||
# Found close: discard block content + tag, continue.
|
||||
buf = buf[close_idx + close_len:]
|
||||
self._in_block = False
|
||||
else:
|
||||
# Priority 1 — closed <tag>X</tag> pair anywhere in
|
||||
# buf. Closed pairs are always an intentional,
|
||||
# bounded construct (even mid-line prose containing
|
||||
# an open/close pair is almost certainly a model
|
||||
# leaking reasoning inline), so no boundary gating.
|
||||
pair = self._find_earliest_closed_pair(buf)
|
||||
# Priority 2 — unterminated open tag at a block
|
||||
# boundary. Boundary-gated so prose that mentions
|
||||
# '<think>' isn't over-stripped.
|
||||
open_idx, open_len = self._find_open_at_boundary(
|
||||
buf, out,
|
||||
)
|
||||
|
||||
# Pick whichever match comes earliest in the buffer.
|
||||
if pair is not None and (
|
||||
open_idx == -1 or pair[0] <= open_idx
|
||||
):
|
||||
start_idx, end_idx = pair
|
||||
preceding = buf[:start_idx]
|
||||
if preceding:
|
||||
preceding = self._strip_orphan_close_tags(preceding)
|
||||
if preceding:
|
||||
out.append(preceding)
|
||||
self._last_emitted_ended_newline = (
|
||||
preceding.endswith("\n")
|
||||
)
|
||||
buf = buf[end_idx:]
|
||||
continue
|
||||
|
||||
if open_idx != -1:
|
||||
# Unterminated open at boundary — emit preceding,
|
||||
# enter block, continue loop with remainder.
|
||||
preceding = buf[:open_idx]
|
||||
if preceding:
|
||||
preceding = self._strip_orphan_close_tags(preceding)
|
||||
if preceding:
|
||||
out.append(preceding)
|
||||
self._last_emitted_ended_newline = (
|
||||
preceding.endswith("\n")
|
||||
)
|
||||
self._in_block = True
|
||||
buf = buf[open_idx + open_len:]
|
||||
continue
|
||||
|
||||
# No resolvable tag structure in buf. Hold back any
|
||||
# partial-tag prefix at the tail so a split tag
|
||||
# across deltas isn't missed, then emit the rest.
|
||||
held = self._max_partial_suffix(buf, self._OPEN_TAGS)
|
||||
held_close = self._max_partial_suffix(
|
||||
buf, self._CLOSE_TAGS,
|
||||
)
|
||||
held = max(held, held_close)
|
||||
if held:
|
||||
emit_text = buf[:-held]
|
||||
self._buf = buf[-held:]
|
||||
else:
|
||||
emit_text = buf
|
||||
self._buf = ""
|
||||
if emit_text:
|
||||
emit_text = self._strip_orphan_close_tags(emit_text)
|
||||
if emit_text:
|
||||
out.append(emit_text)
|
||||
self._last_emitted_ended_newline = (
|
||||
emit_text.endswith("\n")
|
||||
)
|
||||
return "".join(out)
|
||||
|
||||
return "".join(out)
|
||||
|
||||
def flush(self) -> str:
|
||||
"""End-of-stream flush.
|
||||
|
||||
If still inside an unterminated block, held-back content is
|
||||
discarded — leaking partial reasoning is worse than a
|
||||
truncated answer. Otherwise the held-back partial-tag tail is
|
||||
emitted verbatim (it turned out not to be a real tag prefix).
|
||||
"""
|
||||
if self._in_block:
|
||||
self._buf = ""
|
||||
self._in_block = False
|
||||
return ""
|
||||
tail = self._buf
|
||||
self._buf = ""
|
||||
if not tail:
|
||||
return ""
|
||||
tail = self._strip_orphan_close_tags(tail)
|
||||
if tail:
|
||||
self._last_emitted_ended_newline = tail.endswith("\n")
|
||||
return tail
|
||||
|
||||
# ── internal helpers ───────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _find_first_tag(
|
||||
buf: str, tags: Tuple[str, ...],
|
||||
) -> Tuple[int, int]:
|
||||
"""Return (earliest_index, tag_length) over *tags*, or (-1, 0).
|
||||
|
||||
Case-insensitive match.
|
||||
"""
|
||||
buf_lower = buf.lower()
|
||||
best_idx = -1
|
||||
best_len = 0
|
||||
for tag in tags:
|
||||
idx = buf_lower.find(tag.lower())
|
||||
if idx != -1 and (best_idx == -1 or idx < best_idx):
|
||||
best_idx = idx
|
||||
best_len = len(tag)
|
||||
return best_idx, best_len
|
||||
|
||||
def _find_earliest_closed_pair(self, buf: str):
|
||||
"""Return (start_idx, end_idx) of the earliest closed pair, else None.
|
||||
|
||||
A closed pair is ``<tag>...</tag>`` of any variant. Matches are
|
||||
case-insensitive and non-greedy (the closest close tag after
|
||||
an open tag wins), matching the regex ``<tag>.*?</tag>``
|
||||
semantics of ``_strip_think_blocks`` case 1. When two tag
|
||||
variants could both match, the one whose open tag appears
|
||||
earlier wins.
|
||||
"""
|
||||
buf_lower = buf.lower()
|
||||
best: "tuple[int, int] | None" = None
|
||||
for open_tag, close_tag in zip(self._OPEN_TAGS, self._CLOSE_TAGS):
|
||||
open_lower = open_tag.lower()
|
||||
close_lower = close_tag.lower()
|
||||
open_idx = buf_lower.find(open_lower)
|
||||
if open_idx == -1:
|
||||
continue
|
||||
close_idx = buf_lower.find(
|
||||
close_lower, open_idx + len(open_lower),
|
||||
)
|
||||
if close_idx == -1:
|
||||
continue
|
||||
end_idx = close_idx + len(close_lower)
|
||||
if best is None or open_idx < best[0]:
|
||||
best = (open_idx, end_idx)
|
||||
return best
|
||||
|
||||
def _find_open_at_boundary(
|
||||
self, buf: str, already_emitted: list[str],
|
||||
) -> Tuple[int, int]:
|
||||
"""Return the earliest block-boundary open-tag (idx, len).
|
||||
|
||||
Returns (-1, 0) if no boundary-legal opener is present.
|
||||
"""
|
||||
buf_lower = buf.lower()
|
||||
best_idx = -1
|
||||
best_len = 0
|
||||
for tag in self._OPEN_TAGS:
|
||||
tag_lower = tag.lower()
|
||||
search_start = 0
|
||||
while True:
|
||||
idx = buf_lower.find(tag_lower, search_start)
|
||||
if idx == -1:
|
||||
break
|
||||
if self._is_block_boundary(buf, idx, already_emitted):
|
||||
if best_idx == -1 or idx < best_idx:
|
||||
best_idx = idx
|
||||
best_len = len(tag)
|
||||
break # first boundary hit for this tag is enough
|
||||
search_start = idx + 1
|
||||
return best_idx, best_len
|
||||
|
||||
def _is_block_boundary(
|
||||
self, buf: str, idx: int, already_emitted: list[str],
|
||||
) -> bool:
|
||||
"""True iff position *idx* in *buf* is a block boundary.
|
||||
|
||||
A block boundary is:
|
||||
- buf position 0 AND the most recent emission ended with
|
||||
a newline (or nothing has been emitted yet)
|
||||
- any position whose preceding text on the current line
|
||||
(since the last newline in buf) is whitespace-only, AND
|
||||
if there is no newline in the preceding buf portion, the
|
||||
most recent prior emission ended with a newline
|
||||
"""
|
||||
if idx == 0:
|
||||
# Check whether the last already-emitted chunk in THIS
|
||||
# feed() call ended with a newline, otherwise fall back
|
||||
# to the cross-feed flag.
|
||||
if already_emitted:
|
||||
return already_emitted[-1].endswith("\n")
|
||||
return self._last_emitted_ended_newline
|
||||
preceding = buf[:idx]
|
||||
last_nl = preceding.rfind("\n")
|
||||
if last_nl == -1:
|
||||
# No newline in buf before the tag — boundary only if the
|
||||
# prior emission ended with a newline AND everything since
|
||||
# is whitespace.
|
||||
if already_emitted:
|
||||
prior_newline = already_emitted[-1].endswith("\n")
|
||||
else:
|
||||
prior_newline = self._last_emitted_ended_newline
|
||||
return prior_newline and preceding.strip() == ""
|
||||
# Newline present — text between it and the tag must be
|
||||
# whitespace-only.
|
||||
return preceding[last_nl + 1:].strip() == ""
|
||||
|
||||
@classmethod
|
||||
def _max_partial_suffix(
|
||||
cls, buf: str, tags: Tuple[str, ...],
|
||||
) -> int:
|
||||
"""Return the longest buf-suffix that is a prefix of any tag.
|
||||
|
||||
Only prefixes strictly shorter than the tag itself count
|
||||
(full-length suffixes are the tag and are handled as matches,
|
||||
not held-back partials). Case-insensitive.
|
||||
"""
|
||||
if not buf:
|
||||
return 0
|
||||
buf_lower = buf.lower()
|
||||
max_check = min(len(buf_lower), cls._MAX_TAG_LEN - 1)
|
||||
for i in range(max_check, 0, -1):
|
||||
suffix = buf_lower[-i:]
|
||||
for tag in tags:
|
||||
tag_lower = tag.lower()
|
||||
if len(tag_lower) > i and tag_lower.startswith(suffix):
|
||||
return i
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _strip_orphan_close_tags(cls, text: str) -> str:
|
||||
"""Remove any close tags from *text* (orphan-close handling).
|
||||
|
||||
An orphan close tag has no matching open in the current
|
||||
scrubber state; it's always noise, stripped with any trailing
|
||||
whitespace so the surrounding prose flows naturally.
|
||||
"""
|
||||
if "</" not in text:
|
||||
return text
|
||||
text_lower = text.lower()
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
matched = False
|
||||
if text_lower[i:i + 2] == "</":
|
||||
for tag in cls._CLOSE_TAGS:
|
||||
tag_lower = tag.lower()
|
||||
tag_len = len(tag_lower)
|
||||
if text_lower[i:i + tag_len] == tag_lower:
|
||||
# Skip the tag and any trailing whitespace,
|
||||
# matching _strip_think_blocks case 3.
|
||||
j = i + tag_len
|
||||
while j < len(text) and text[j] in " \t\n\r":
|
||||
j += 1
|
||||
i = j
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
out.append(text[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
13
cli.py
13
cli.py
|
|
@ -1890,8 +1890,8 @@ _skill_commands = scan_skill_commands()
|
|||
def _get_plugin_cmd_handler_names() -> set:
|
||||
"""Return plugin command names (without slash prefix) for dispatch matching."""
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
return set(get_plugin_manager()._plugin_commands.keys())
|
||||
from hermes_cli.plugins import get_plugin_commands
|
||||
return set(get_plugin_commands().keys())
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
|
@ -2145,7 +2145,10 @@ class HermesCLI:
|
|||
elif CLI_CONFIG.get("max_turns"): # Backwards compat: root-level max_turns
|
||||
self.max_turns = CLI_CONFIG["max_turns"]
|
||||
elif os.getenv("HERMES_MAX_ITERATIONS"):
|
||||
self.max_turns = int(os.getenv("HERMES_MAX_ITERATIONS"))
|
||||
try:
|
||||
self.max_turns = int(os.getenv("HERMES_MAX_ITERATIONS", ""))
|
||||
except (TypeError, ValueError):
|
||||
self.max_turns = 90
|
||||
else:
|
||||
self.max_turns = 90
|
||||
|
||||
|
|
@ -7659,6 +7662,10 @@ class HermesCLI:
|
|||
):
|
||||
self.session_id = self.agent.session_id
|
||||
self._pending_title = None
|
||||
# Manual /compress replaces conversation_history with a new
|
||||
# compressed handoff for the child session. Persist it from
|
||||
# offset 0 so resume can recover the continuation after exit.
|
||||
self.agent._flush_messages_to_session_db(self.conversation_history, None)
|
||||
new_tokens = estimate_request_tokens_rough(
|
||||
self.conversation_history,
|
||||
system_prompt=_sys_prompt,
|
||||
|
|
|
|||
|
|
@ -114,12 +114,20 @@ from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_
|
|||
# locally for audit.
|
||||
SILENT_MARKER = "[SILENT]"
|
||||
|
||||
# Resolve Hermes home directory (respects HERMES_HOME override)
|
||||
_hermes_home = get_hermes_home()
|
||||
# Backward-compatible module override used by tests and emergency monkeypatches.
|
||||
_hermes_home: Path | None = None
|
||||
|
||||
# File-based lock prevents concurrent ticks from gateway + daemon + systemd timer
|
||||
_LOCK_DIR = _hermes_home / "cron"
|
||||
_LOCK_FILE = _LOCK_DIR / ".tick.lock"
|
||||
|
||||
def _get_hermes_home() -> Path:
|
||||
"""Resolve Hermes home dynamically while preserving test monkeypatch hooks."""
|
||||
return _hermes_home or get_hermes_home()
|
||||
|
||||
|
||||
def _get_lock_paths() -> tuple[Path, Path]:
|
||||
"""Resolve cron lock paths at call time so profile/env changes are honored."""
|
||||
hermes_home = _get_hermes_home()
|
||||
lock_dir = hermes_home / "cron"
|
||||
return lock_dir, lock_dir / ".tick.lock"
|
||||
|
||||
|
||||
def _resolve_origin(job: dict) -> Optional[dict]:
|
||||
|
|
@ -597,7 +605,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
|||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
scripts_dir = get_hermes_home() / "scripts"
|
||||
scripts_dir = _get_hermes_home() / "scripts"
|
||||
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||
scripts_dir_resolved = scripts_dir.resolve()
|
||||
|
||||
|
|
@ -1058,9 +1066,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
|||
# changes take effect without a gateway restart.
|
||||
from dotenv import load_dotenv
|
||||
try:
|
||||
load_dotenv(str(_hermes_home / ".env"), override=True, encoding="utf-8")
|
||||
load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
load_dotenv(str(_hermes_home / ".env"), override=True, encoding="latin-1")
|
||||
load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="latin-1")
|
||||
|
||||
delivery_target = _resolve_delivery_target(job)
|
||||
if delivery_target:
|
||||
|
|
@ -1078,7 +1086,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
|||
_cfg = {}
|
||||
try:
|
||||
import yaml
|
||||
_cfg_path = str(_hermes_home / "config.yaml")
|
||||
_cfg_path = str(_get_hermes_home() / "config.yaml")
|
||||
if os.path.exists(_cfg_path):
|
||||
with open(_cfg_path) as _f:
|
||||
_cfg = yaml.safe_load(_f) or {}
|
||||
|
|
@ -1112,7 +1120,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
|||
if prefill_file:
|
||||
pfpath = Path(prefill_file).expanduser()
|
||||
if not pfpath.is_absolute():
|
||||
pfpath = _hermes_home / pfpath
|
||||
pfpath = _get_hermes_home() / pfpath
|
||||
if pfpath.exists():
|
||||
try:
|
||||
with open(pfpath, "r", encoding="utf-8") as _pf:
|
||||
|
|
@ -1436,12 +1444,13 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
|
|||
Returns:
|
||||
Number of jobs executed (0 if another tick is already running)
|
||||
"""
|
||||
_LOCK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
lock_dir, lock_file = _get_lock_paths()
|
||||
lock_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Cross-platform file locking: fcntl on Unix, msvcrt on Windows
|
||||
lock_fd = None
|
||||
try:
|
||||
lock_fd = open(_LOCK_FILE, "w")
|
||||
lock_fd = open(lock_file, "w")
|
||||
if fcntl:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
elif msvcrt:
|
||||
|
|
|
|||
|
|
@ -845,6 +845,16 @@ def load_gateway_config() -> GatewayConfig:
|
|||
):
|
||||
if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
|
||||
os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
|
||||
# reply_to_mode: top-level preferred, falls back to extra.reply_to_mode
|
||||
# YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
|
||||
_discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {}
|
||||
_discord_rtm = (
|
||||
discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg
|
||||
else _discord_extra.get("reply_to_mode")
|
||||
)
|
||||
if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"):
|
||||
_rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower()
|
||||
os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str
|
||||
|
||||
# Bridge top-level require_mention to Telegram when the telegram: section
|
||||
# does not already provide one. Users often write "require_mention: true"
|
||||
|
|
@ -881,6 +891,16 @@ def load_gateway_config() -> GatewayConfig:
|
|||
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
|
||||
if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"):
|
||||
os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip()
|
||||
# reply_to_mode: top-level preferred, falls back to extra.reply_to_mode
|
||||
# YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
|
||||
_telegram_extra = telegram_cfg.get("extra") if isinstance(telegram_cfg.get("extra"), dict) else {}
|
||||
_telegram_rtm = (
|
||||
telegram_cfg["reply_to_mode"] if "reply_to_mode" in telegram_cfg
|
||||
else _telegram_extra.get("reply_to_mode")
|
||||
)
|
||||
if _telegram_rtm is not None and not os.getenv("TELEGRAM_REPLY_TO_MODE"):
|
||||
_rtm_str = "off" if _telegram_rtm is False else str(_telegram_rtm).lower()
|
||||
os.environ["TELEGRAM_REPLY_TO_MODE"] = _rtm_str
|
||||
allowed_users = telegram_cfg.get("allow_from")
|
||||
if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"):
|
||||
if isinstance(allowed_users, list):
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
OpenAI-compatible API server platform adapter.
|
||||
|
||||
Exposes an HTTP server with endpoints:
|
||||
- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header)
|
||||
- POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id)
|
||||
- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header; opt-in long-term memory scoping via X-Hermes-Session-Key header)
|
||||
- POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id; X-Hermes-Session-Key supported)
|
||||
- GET /v1/responses/{response_id} — Retrieve a stored response
|
||||
- DELETE /v1/responses/{response_id} — Delete a stored response
|
||||
- GET /v1/models — lists hermes-agent as an available model
|
||||
|
|
@ -698,6 +698,71 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
status=401,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session header helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Soft length cap for session identifiers. Headers are bounded in
|
||||
# aggregate by aiohttp (``client_max_size`` / default 8 KiB per
|
||||
# header), but we impose a tighter limit on the session headers so a
|
||||
# caller can't burn memory by passing a multi-kilobyte "session key".
|
||||
# 256 chars is well above any realistic stable channel identifier
|
||||
# (e.g. ``agent:main:webui:dm:user-42``) while staying small enough
|
||||
# that the sanitized form is safe to pass into Honcho / state.db.
|
||||
_MAX_SESSION_HEADER_LEN = 256
|
||||
|
||||
def _parse_session_key_header(
|
||||
self, request: "web.Request"
|
||||
) -> tuple[Optional[str], Optional["web.Response"]]:
|
||||
"""Extract and validate the ``X-Hermes-Session-Key`` header.
|
||||
|
||||
The session key is a stable per-channel identifier that scopes
|
||||
long-term memory (e.g. Honcho sessions) across transcripts. It
|
||||
is independent of ``X-Hermes-Session-Id``: callers may send
|
||||
either, both, or neither.
|
||||
|
||||
Returns ``(session_key, None)`` on success (with an empty/absent
|
||||
header yielding ``None`` for the key), or ``(None, error_response)``
|
||||
on validation failure.
|
||||
|
||||
Security: like session continuation, accepting a caller-supplied
|
||||
memory scope requires API-key authentication so that an
|
||||
unauthenticated client on a local-only server can't inject itself
|
||||
into another user's long-term memory scope by guessing a key.
|
||||
"""
|
||||
raw = request.headers.get("X-Hermes-Session-Key", "").strip()
|
||||
if not raw:
|
||||
return None, None
|
||||
|
||||
if not self._api_key:
|
||||
logger.warning(
|
||||
"X-Hermes-Session-Key rejected: no API key configured. "
|
||||
"Set API_SERVER_KEY to enable long-term memory scoping."
|
||||
)
|
||||
return None, web.json_response(
|
||||
_openai_error(
|
||||
"X-Hermes-Session-Key requires API key authentication. "
|
||||
"Configure API_SERVER_KEY to enable this feature."
|
||||
),
|
||||
status=403,
|
||||
)
|
||||
|
||||
# Reject control characters that could enable header injection on
|
||||
# the echo path.
|
||||
if re.search(r'[\r\n\x00]', raw):
|
||||
return None, web.json_response(
|
||||
{"error": {"message": "Invalid session key", "type": "invalid_request_error"}},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if len(raw) > self._MAX_SESSION_HEADER_LEN:
|
||||
return None, web.json_response(
|
||||
{"error": {"message": "Session key too long", "type": "invalid_request_error"}},
|
||||
status=400,
|
||||
)
|
||||
|
||||
return raw, None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session DB helper
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -728,6 +793,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
tool_progress_callback=None,
|
||||
tool_start_callback=None,
|
||||
tool_complete_callback=None,
|
||||
gateway_session_key: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Create an AIAgent instance using the gateway's runtime config.
|
||||
|
|
@ -736,6 +802,13 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
base_url, etc. from config.yaml / env vars. Toolsets are resolved
|
||||
from config.yaml platform_toolsets.api_server (same as all other
|
||||
gateway platforms), falling back to the hermes-api-server default.
|
||||
|
||||
``gateway_session_key`` is a stable per-channel identifier supplied
|
||||
by the client (via ``X-Hermes-Session-Key``). Unlike ``session_id``
|
||||
which scopes the short-term transcript and rotates on /new, this
|
||||
key is meant to persist across transcripts so long-term memory
|
||||
providers (e.g. Honcho) can scope their per-chat state correctly
|
||||
— matching the semantics of the native gateway's ``session_key``.
|
||||
"""
|
||||
from run_agent import AIAgent
|
||||
from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config, GatewayRunner
|
||||
|
|
@ -771,6 +844,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
session_db=self._ensure_session_db(),
|
||||
fallback_model=fallback_model,
|
||||
reasoning_config=reasoning_config,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
return agent
|
||||
|
||||
|
|
@ -854,6 +928,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
"run_stop": True,
|
||||
"tool_progress_events": True,
|
||||
"session_continuity_header": "X-Hermes-Session-Id",
|
||||
"session_key_header": "X-Hermes-Session-Key",
|
||||
"cors": bool(self._cors_origins),
|
||||
},
|
||||
"endpoints": {
|
||||
|
|
@ -925,6 +1000,15 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
status=400,
|
||||
)
|
||||
|
||||
# Allow caller to scope long-term memory (e.g. Honcho) with a
|
||||
# stable per-channel identifier via X-Hermes-Session-Key. This
|
||||
# is independent of X-Hermes-Session-Id: the key persists across
|
||||
# transcripts while the id rotates when the caller starts a new
|
||||
# transcript (i.e. /new semantics). See _parse_session_key_header.
|
||||
gateway_session_key, key_err = self._parse_session_key_header(request)
|
||||
if key_err is not None:
|
||||
return key_err
|
||||
|
||||
# Allow caller to continue an existing session by passing X-Hermes-Session-Id.
|
||||
# When provided, history is loaded from state.db instead of from the request body.
|
||||
#
|
||||
|
|
@ -1059,11 +1143,13 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
tool_start_callback=_on_tool_start,
|
||||
tool_complete_callback=_on_tool_complete,
|
||||
agent_ref=agent_ref,
|
||||
gateway_session_key=gateway_session_key,
|
||||
))
|
||||
|
||||
return await self._write_sse_chat_completion(
|
||||
request, completion_id, model_name, created, _stream_q,
|
||||
agent_task, agent_ref, session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
|
||||
# Non-streaming: run the agent (with optional Idempotency-Key)
|
||||
|
|
@ -1073,6 +1159,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
conversation_history=history,
|
||||
ephemeral_system_prompt=system_prompt,
|
||||
session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
|
||||
idempotency_key = request.headers.get("Idempotency-Key")
|
||||
|
|
@ -1122,11 +1209,17 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
},
|
||||
}
|
||||
|
||||
return web.json_response(response_data, headers={"X-Hermes-Session-Id": session_id})
|
||||
response_headers = {
|
||||
"X-Hermes-Session-Id": result.get("session_id", session_id),
|
||||
}
|
||||
if gateway_session_key:
|
||||
response_headers["X-Hermes-Session-Key"] = gateway_session_key
|
||||
return web.json_response(response_data, headers=response_headers)
|
||||
|
||||
async def _write_sse_chat_completion(
|
||||
self, request: "web.Request", completion_id: str, model: str,
|
||||
created: int, stream_q, agent_task, agent_ref=None, session_id: str = None,
|
||||
gateway_session_key: str = None,
|
||||
) -> "web.StreamResponse":
|
||||
"""Write real streaming SSE from agent's stream_delta_callback queue.
|
||||
|
||||
|
|
@ -1149,6 +1242,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
sse_headers.update(cors)
|
||||
if session_id:
|
||||
sse_headers["X-Hermes-Session-Id"] = session_id
|
||||
if gateway_session_key:
|
||||
sse_headers["X-Hermes-Session-Key"] = gateway_session_key
|
||||
response = web.StreamResponse(status=200, headers=sse_headers)
|
||||
await response.prepare(request)
|
||||
|
||||
|
|
@ -1272,6 +1367,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
conversation: Optional[str],
|
||||
store: bool,
|
||||
session_id: str,
|
||||
gateway_session_key: Optional[str] = None,
|
||||
) -> "web.StreamResponse":
|
||||
"""Write an SSE stream for POST /v1/responses (OpenAI Responses API).
|
||||
|
||||
|
|
@ -1314,6 +1410,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
sse_headers.update(cors)
|
||||
if session_id:
|
||||
sse_headers["X-Hermes-Session-Id"] = session_id
|
||||
if gateway_session_key:
|
||||
sse_headers["X-Hermes-Session-Key"] = gateway_session_key
|
||||
response = web.StreamResponse(status=200, headers=sse_headers)
|
||||
await response.prepare(request)
|
||||
|
||||
|
|
@ -1763,6 +1861,11 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
# Long-term memory scope header (see chat_completions for details).
|
||||
gateway_session_key, key_err = self._parse_session_key_header(request)
|
||||
if key_err is not None:
|
||||
return key_err
|
||||
|
||||
# Parse request body
|
||||
try:
|
||||
body = await request.json()
|
||||
|
|
@ -1914,6 +2017,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
tool_start_callback=_on_tool_start,
|
||||
tool_complete_callback=_on_tool_complete,
|
||||
agent_ref=agent_ref,
|
||||
gateway_session_key=gateway_session_key,
|
||||
))
|
||||
|
||||
response_id = f"resp_{uuid.uuid4().hex[:28]}"
|
||||
|
|
@ -1934,6 +2038,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
conversation=conversation,
|
||||
store=store,
|
||||
session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
|
||||
async def _compute_response():
|
||||
|
|
@ -1942,6 +2047,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
conversation_history=conversation_history,
|
||||
ephemeral_system_prompt=instructions,
|
||||
session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
|
||||
idempotency_key = request.headers.get("Idempotency-Key")
|
||||
|
|
@ -2016,7 +2122,10 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
if conversation:
|
||||
self._response_store.set_conversation(conversation, response_id)
|
||||
|
||||
return web.json_response(response_data)
|
||||
response_headers = {"X-Hermes-Session-Id": session_id}
|
||||
if gateway_session_key:
|
||||
response_headers["X-Hermes-Session-Key"] = gateway_session_key
|
||||
return web.json_response(response_data, headers=response_headers)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET / DELETE response endpoints
|
||||
|
|
@ -2338,6 +2447,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
tool_start_callback=None,
|
||||
tool_complete_callback=None,
|
||||
agent_ref: Optional[list] = None,
|
||||
gateway_session_key: Optional[str] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Create an agent and run a conversation in a thread executor.
|
||||
|
|
@ -2360,6 +2470,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
tool_progress_callback=tool_progress_callback,
|
||||
tool_start_callback=tool_start_callback,
|
||||
tool_complete_callback=tool_complete_callback,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
if agent_ref is not None:
|
||||
agent_ref[0] = agent
|
||||
|
|
@ -2374,6 +2485,12 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
|
||||
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
|
||||
}
|
||||
# Include the effective session ID in the result so callers
|
||||
# (e.g. X-Hermes-Session-Id header) can track compression-
|
||||
# triggered session rotations. (#16938)
|
||||
_eff_sid = getattr(agent, "session_id", session_id)
|
||||
if isinstance(_eff_sid, str) and _eff_sid:
|
||||
result["session_id"] = _eff_sid
|
||||
return result, usage
|
||||
|
||||
return await loop.run_in_executor(None, _run)
|
||||
|
|
@ -2453,6 +2570,11 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
# Long-term memory scope header (see chat_completions for details).
|
||||
gateway_session_key, key_err = self._parse_session_key_header(request)
|
||||
if key_err is not None:
|
||||
return key_err
|
||||
|
||||
# Enforce concurrency limit
|
||||
if len(self._run_streams) >= self._MAX_CONCURRENT_RUNS:
|
||||
return web.json_response(
|
||||
|
|
@ -2561,6 +2683,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
session_id=session_id,
|
||||
stream_delta_callback=_text_cb,
|
||||
tool_progress_callback=event_cb,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
self._active_run_agents[run_id] = agent
|
||||
def _run_sync():
|
||||
|
|
@ -2661,7 +2784,14 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
if hasattr(task, "add_done_callback"):
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
return web.json_response({"run_id": run_id, "status": "started"}, status=202)
|
||||
response_headers = (
|
||||
{"X-Hermes-Session-Key": gateway_session_key} if gateway_session_key else {}
|
||||
)
|
||||
return web.json_response(
|
||||
{"run_id": run_id, "status": "started"},
|
||||
status=202,
|
||||
headers=response_headers,
|
||||
)
|
||||
|
||||
async def _handle_get_run(self, request: "web.Request") -> "web.Response":
|
||||
"""GET /v1/runs/{run_id} — return pollable run status for external UIs."""
|
||||
|
|
|
|||
|
|
@ -2675,10 +2675,18 @@ class BasePlatformAdapter(ABC):
|
|||
mode = os.getenv("HERMES_HUMAN_DELAY_MODE", "off").lower()
|
||||
if mode == "off":
|
||||
return 0.0
|
||||
min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800"))
|
||||
max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500"))
|
||||
if mode == "natural":
|
||||
min_ms, max_ms = 800, 2500
|
||||
return random.uniform(min_ms / 1000.0, max_ms / 1000.0)
|
||||
# custom mode — tolerate malformed env vars instead of crashing.
|
||||
try:
|
||||
min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800"))
|
||||
except (TypeError, ValueError):
|
||||
min_ms = 800
|
||||
try:
|
||||
max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500"))
|
||||
except (TypeError, ValueError):
|
||||
max_ms = 2500
|
||||
return random.uniform(min_ms / 1000.0, max_ms / 1000.0)
|
||||
|
||||
async def _process_message_background(self, event: MessageEvent, session_key: str) -> None:
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ _MARKDOWN_HINT_RE = re.compile(
|
|||
r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(<u>.+?</u>)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
# Detect markdown tables: a line starting with | followed by a separator line.
|
||||
# Feishu post-type 'md' elements do not render tables, so we force text mode.
|
||||
_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE)
|
||||
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
||||
_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
|
||||
_MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$")
|
||||
|
|
@ -3862,47 +3865,50 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
and self-sent bot event filtering.
|
||||
|
||||
Populates ``_bot_open_id`` and ``_bot_name`` from /open-apis/bot/v3/info
|
||||
(no extra scopes required beyond the tenant access token). Falls back to
|
||||
the application info endpoint for ``_bot_name`` only when the first probe
|
||||
doesn't return it. Each field is hydrated independently — a value already
|
||||
supplied via env vars (FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID /
|
||||
FEISHU_BOT_NAME) is preserved and skips its probe.
|
||||
(no extra scopes required beyond the tenant access token). The probe
|
||||
always runs when a client is available so stale env vars from app/bot
|
||||
migrations do not break group @mention gating. Falls back to the
|
||||
application info endpoint for ``_bot_name`` only when the first probe
|
||||
doesn't return it. If the probe fails, env-provided values are preserved.
|
||||
"""
|
||||
if not self._client:
|
||||
return
|
||||
if self._bot_open_id and self._bot_name:
|
||||
# Everything the self-send filter and precise mention gate need is
|
||||
# already in place; nothing to probe.
|
||||
return
|
||||
|
||||
# Primary probe: /open-apis/bot/v3/info — returns bot_name + open_id, no
|
||||
# extra scopes required. This is the same endpoint the onboarding wizard
|
||||
# uses via probe_bot().
|
||||
if not self._bot_open_id or not self._bot_name:
|
||||
try:
|
||||
req = (
|
||||
BaseRequest.builder()
|
||||
.http_method(HttpMethod.GET)
|
||||
.uri("/open-apis/bot/v3/info")
|
||||
.token_types({AccessTokenType.TENANT})
|
||||
.build()
|
||||
)
|
||||
resp = await asyncio.to_thread(self._client.request, req)
|
||||
content = getattr(getattr(resp, "raw", None), "content", None)
|
||||
if content:
|
||||
payload = json.loads(content)
|
||||
parsed = _parse_bot_response(payload) or {}
|
||||
open_id = (parsed.get("bot_open_id") or "").strip()
|
||||
bot_name = (parsed.get("bot_name") or "").strip()
|
||||
if open_id and not self._bot_open_id:
|
||||
self._bot_open_id = open_id
|
||||
if bot_name and not self._bot_name:
|
||||
self._bot_name = bot_name
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[Feishu] /bot/v3/info probe failed during hydration",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
req = (
|
||||
BaseRequest.builder()
|
||||
.http_method(HttpMethod.GET)
|
||||
.uri("/open-apis/bot/v3/info")
|
||||
.token_types({AccessTokenType.TENANT})
|
||||
.build()
|
||||
)
|
||||
resp = await asyncio.to_thread(self._client.request, req)
|
||||
content = getattr(getattr(resp, "raw", None), "content", None)
|
||||
if content:
|
||||
payload = json.loads(content)
|
||||
parsed = _parse_bot_response(payload) or {}
|
||||
open_id = (parsed.get("bot_open_id") or "").strip()
|
||||
bot_name = (parsed.get("bot_name") or "").strip()
|
||||
if open_id:
|
||||
if self._bot_open_id and self._bot_open_id != open_id:
|
||||
logger.warning(
|
||||
"[Feishu] FEISHU_BOT_OPEN_ID is stale; using /bot/v3/info open_id for group @mention gating."
|
||||
)
|
||||
self._bot_open_id = open_id
|
||||
if bot_name:
|
||||
if self._bot_name and self._bot_name != bot_name:
|
||||
logger.info(
|
||||
"[Feishu] FEISHU_BOT_NAME differs from /bot/v3/info; using hydrated bot name for group @mention gating."
|
||||
)
|
||||
self._bot_name = bot_name
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[Feishu] /bot/v3/info probe failed during hydration",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Fallback probe for _bot_name only: application info endpoint. Needs
|
||||
# admin:app.info:readonly or application:application:self_manage scope,
|
||||
|
|
@ -3947,7 +3953,14 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
if isinstance(seen_data, list):
|
||||
entries: Dict[str, float] = {str(item).strip(): 0.0 for item in seen_data if str(item).strip()}
|
||||
elif isinstance(seen_data, dict):
|
||||
entries = {k: float(v) for k, v in seen_data.items() if isinstance(k, str) and k.strip()}
|
||||
entries = {}
|
||||
for key, value in seen_data.items():
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
continue
|
||||
try:
|
||||
entries[key] = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
else:
|
||||
return
|
||||
# Filter out TTL-expired entries (entries saved with ts=0.0 are treated as immortal
|
||||
|
|
@ -3992,6 +4005,12 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
# =========================================================================
|
||||
|
||||
def _build_outbound_payload(self, content: str) -> tuple[str, str]:
|
||||
# Feishu post-type 'md' elements do not render markdown tables; sending
|
||||
# table content as post causes the message to appear blank on the client.
|
||||
# Force plain text for anything that looks like a markdown table.
|
||||
if _MARKDOWN_TABLE_RE.search(content):
|
||||
text_payload = {"text": content}
|
||||
return "text", json.dumps(text_payload, ensure_ascii=False)
|
||||
if _MARKDOWN_HINT_RE.search(content):
|
||||
return "post", _build_markdown_post_payload(content)
|
||||
text_payload = {"text": content}
|
||||
|
|
@ -4087,7 +4106,15 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
content=payload,
|
||||
uuid_value=str(uuid.uuid4()),
|
||||
)
|
||||
request = self._build_create_message_request("chat_id", body)
|
||||
# Detect whether chat_id is a user open_id (DM) or a chat_id (group).
|
||||
# Feishu API expects receive_id_type="open_id" for user DMs (ou_ prefix)
|
||||
# and receive_id_type="chat_id" for group chats (oc_ prefix, which IS
|
||||
# the chat_id format — see https://open.feishu.cn/document/).
|
||||
if chat_id.startswith("ou_"):
|
||||
receive_id_type = "open_id"
|
||||
else:
|
||||
receive_id_type = "chat_id"
|
||||
request = self._build_create_message_request(receive_id_type, body)
|
||||
return await asyncio.to_thread(self._client.im.v1.message.create, request)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -222,33 +222,37 @@ class ThreadParticipationTracker:
|
|||
def __init__(self, platform_name: str, max_tracked: int = 500):
|
||||
self._platform = platform_name
|
||||
self._max_tracked = max_tracked
|
||||
self._threads: set = self._load()
|
||||
self._threads: dict[str, None] = {
|
||||
str(thread_id): None for thread_id in self._load()
|
||||
}
|
||||
|
||||
def _state_path(self) -> Path:
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / f"{self._platform}_threads.json"
|
||||
|
||||
def _load(self) -> set:
|
||||
def _load(self) -> list[str]:
|
||||
path = self._state_path()
|
||||
if path.exists():
|
||||
try:
|
||||
return set(json.loads(path.read_text(encoding="utf-8")))
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return [str(thread_id) for thread_id in data]
|
||||
except Exception:
|
||||
pass
|
||||
return set()
|
||||
return []
|
||||
|
||||
def _save(self) -> None:
|
||||
path = self._state_path()
|
||||
thread_list = list(self._threads)
|
||||
if len(thread_list) > self._max_tracked:
|
||||
thread_list = thread_list[-self._max_tracked:]
|
||||
self._threads = set(thread_list)
|
||||
self._threads = {thread_id: None for thread_id in thread_list}
|
||||
atomic_json_write(path, thread_list, indent=None)
|
||||
|
||||
def mark(self, thread_id: str) -> None:
|
||||
"""Mark *thread_id* as participated and persist."""
|
||||
if thread_id not in self._threads:
|
||||
self._threads.add(thread_id)
|
||||
self._threads[thread_id] = None
|
||||
self._save()
|
||||
|
||||
def __contains__(self, thread_id: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -185,10 +185,13 @@ async def _query_doh_provider(
|
|||
async def discover_fallback_ips() -> list[str]:
|
||||
"""Auto-discover Telegram API IPs via DNS-over-HTTPS.
|
||||
|
||||
Resolves api.telegram.org through Google and Cloudflare DoH, collects all
|
||||
unique IPs, and excludes the system-DNS-resolved IP (which is presumably
|
||||
unreachable on this network). Falls back to a hardcoded seed list when DoH
|
||||
is also unavailable.
|
||||
Resolves api.telegram.org through Google and Cloudflare DoH and returns all
|
||||
unique A records. IPs that match the local system resolver are kept rather
|
||||
than excluded: in many networks the system-DNS IP is the most reliable path
|
||||
to api.telegram.org and a transient primary-path failure should be retried
|
||||
against the same address via the IP-rewrite path before the seed list is
|
||||
consulted (#14520). Falls back to a hardcoded seed list only when DoH
|
||||
yields no usable answers.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(_DOH_TIMEOUT)) as client:
|
||||
doh_tasks = [_query_doh_provider(client, p) for p in _DOH_PROVIDERS]
|
||||
|
|
@ -203,11 +206,11 @@ async def discover_fallback_ips() -> list[str]:
|
|||
if isinstance(r, list):
|
||||
doh_ips.extend(r)
|
||||
|
||||
# Deduplicate preserving order, exclude system-DNS IPs
|
||||
# Deduplicate preserving order
|
||||
seen: set[str] = set()
|
||||
candidates: list[str] = []
|
||||
for ip in doh_ips:
|
||||
if ip not in seen and ip not in system_ips:
|
||||
if ip not in seen:
|
||||
seen.add(ip)
|
||||
candidates.append(ip)
|
||||
|
||||
|
|
@ -219,7 +222,7 @@ async def discover_fallback_ips() -> list[str]:
|
|||
return validated
|
||||
|
||||
logger.info(
|
||||
"DoH discovery yielded no new IPs (system DNS: %s); using seed fallback IPs %s",
|
||||
"DoH discovery yielded no usable IPs (system DNS: %s); using seed fallback IPs %s",
|
||||
", ".join(system_ips) or "unknown",
|
||||
", ".join(_SEED_FALLBACK_IPS),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1015,6 +1015,8 @@ class WeComAdapter(BasePlatformAdapter):
|
|||
if not aes_key:
|
||||
raise ValueError("aes_key is required")
|
||||
|
||||
# WeCom doesn't pad base64 keys; add padding if needed
|
||||
aes_key = aes_key + '=' * ((4 - len(aes_key) % 4) % 4)
|
||||
key = base64.b64decode(aes_key)
|
||||
if len(key) != 32:
|
||||
raise ValueError(f"Invalid WeCom AES key length: expected 32 bytes, got {len(key)}")
|
||||
|
|
|
|||
342
gateway/run.py
342
gateway/run.py
|
|
@ -39,6 +39,7 @@ from typing import Dict, Optional, Any, List, Union
|
|||
# gateway is a long-running daemon, so its boot cost matters less than
|
||||
# preserving the established test-patch surface.
|
||||
from agent.account_usage import fetch_account_usage, render_account_usage_lines
|
||||
from agent.i18n import t
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
# --- Agent cache tuning ---------------------------------------------------
|
||||
|
|
@ -93,153 +94,6 @@ def _telegramize_command_mentions(text: str, platform: Any) -> str:
|
|||
_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT = 60 * 60
|
||||
|
||||
|
||||
# --- Stale-code self-check ------------------------------------------------
|
||||
# Long-running gateway processes that survive an ``hermes update`` keep the
|
||||
# old ``hermes_cli.config`` (and friends) cached in ``sys.modules``. When
|
||||
# the updated tool files on disk then try to ``from hermes_cli.config
|
||||
# import cfg_get`` (added in PR #17304), the import resolves against the
|
||||
# already-loaded stale module object and raises ``ImportError`` — see
|
||||
# Issue #17648. Rather than papering over the import failure site-by-site
|
||||
# in every tool file, detect the stale state centrally and auto-restart
|
||||
# so the gateway reloads with fresh code.
|
||||
#
|
||||
# The signal we use is ``git rev-parse HEAD`` — the only thing ``hermes
|
||||
# update`` moves that is NOT moved by agent-driven file edits. Earlier
|
||||
# revisions of this check compared file mtimes across a sentinel set
|
||||
# (run_agent.py, gateway/run.py, ...), but that produced false positives
|
||||
# whenever the agent edited its own source files during a session:
|
||||
# mtime jumps, stale-check fires, gateway restarts, user must retype.
|
||||
# See the conversation at PR #<this> for the motivating incident.
|
||||
#
|
||||
# The legacy mtime sentinels are kept ONLY as a last-resort fallback for
|
||||
# non-git installs (pip install from wheel, sparse clones with no .git
|
||||
# dir). In those environments ``hermes update`` is not a supported path,
|
||||
# so the check effectively no-ops — which is the safe behavior: better
|
||||
# to ship one broken import than to restart on every agent-edit.
|
||||
_STALE_CODE_SENTINELS: tuple[str, ...] = (
|
||||
"hermes_cli/config.py",
|
||||
"hermes_cli/__init__.py",
|
||||
"run_agent.py",
|
||||
"gateway/run.py",
|
||||
"pyproject.toml",
|
||||
)
|
||||
|
||||
# Cache git HEAD reads across consecutive messages so a chat burst doesn't
|
||||
# spawn one subprocess per message. 5s is long enough to collapse a burst
|
||||
# and short enough that the real post-update detection still fires within
|
||||
# the user's perceived "next message" window.
|
||||
_GIT_SHA_CACHE_TTL_SECS = 5.0
|
||||
|
||||
|
||||
def _read_git_head_sha(repo_root: Path) -> Optional[str]:
|
||||
"""Return the git HEAD SHA for ``repo_root``, or None if unavailable.
|
||||
|
||||
Reads ``.git/HEAD`` directly (and follows one level of ref) instead
|
||||
of shelling out to ``git`` — cheaper, no subprocess tax, works on
|
||||
gateway hosts that don't have a ``git`` binary on PATH. Returns
|
||||
None for non-git installs (no ``.git`` dir) or any I/O error; callers
|
||||
treat None as "can't tell" and skip the check.
|
||||
|
||||
Supports the three layouts we care about:
|
||||
1. Main checkout: ``<repo>/.git/`` is a directory.
|
||||
2. Git worktree: ``<repo>/.git`` is a file ``gitdir: <path>`` that
|
||||
points at ``<main>/.git/worktrees/<name>/``. The worktree's
|
||||
gitdir has HEAD + index but NOT refs/heads/ — those live in
|
||||
the main checkout, and ``<worktree-gitdir>/commondir`` points
|
||||
at the main ``.git``. We search both locations for refs.
|
||||
3. Packed refs: ``refs/heads/<branch>`` is absent on disk but
|
||||
listed in ``<main-git-dir>/packed-refs``.
|
||||
"""
|
||||
try:
|
||||
git_dir = repo_root / ".git"
|
||||
# Worktrees store ``.git`` as a file pointing at gitdir: <path>
|
||||
if git_dir.is_file():
|
||||
try:
|
||||
content = git_dir.read_text().strip()
|
||||
if content.startswith("gitdir:"):
|
||||
git_dir = Path(content.split(":", 1)[1].strip())
|
||||
if not git_dir.is_absolute():
|
||||
git_dir = (repo_root / git_dir).resolve()
|
||||
except OSError:
|
||||
return None
|
||||
if not git_dir.is_dir():
|
||||
return None
|
||||
|
||||
# Figure out the "common" git dir — the one that owns shared refs.
|
||||
# For a worktree, commondir points at it (relative path, resolve
|
||||
# against git_dir). For a main checkout, common_dir == git_dir.
|
||||
common_dir = git_dir
|
||||
commondir_file = git_dir / "commondir"
|
||||
if commondir_file.is_file():
|
||||
try:
|
||||
rel = commondir_file.read_text().strip()
|
||||
candidate = (git_dir / rel).resolve() if rel else git_dir
|
||||
if candidate.is_dir():
|
||||
common_dir = candidate
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
head_path = git_dir / "HEAD"
|
||||
if not head_path.is_file():
|
||||
return None
|
||||
head_content = head_path.read_text().strip()
|
||||
|
||||
if head_content.startswith("ref:"):
|
||||
# Symbolic ref — follow one level (e.g. ref: refs/heads/main).
|
||||
# Worktree-local refs (bisect, rebase-merge state) live under
|
||||
# git_dir; shared refs (refs/heads/*, refs/tags/*) live under
|
||||
# common_dir. Try git_dir first, then common_dir.
|
||||
ref_rel = head_content.split(":", 1)[1].strip()
|
||||
for base in (git_dir, common_dir) if git_dir != common_dir else (git_dir,):
|
||||
ref_path = base / ref_rel
|
||||
if ref_path.is_file():
|
||||
try:
|
||||
sha = ref_path.read_text().strip()
|
||||
except OSError:
|
||||
continue
|
||||
if sha:
|
||||
return sha
|
||||
# Packed refs fallback — always stored in the common dir.
|
||||
packed = common_dir / "packed-refs"
|
||||
if packed.is_file():
|
||||
try:
|
||||
for line in packed.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("^"):
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) == 2 and parts[1] == ref_rel:
|
||||
return parts[0] or None
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
# Detached HEAD — content is the SHA directly.
|
||||
return head_content or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _compute_repo_mtime(repo_root: Path) -> float:
|
||||
"""Return the newest mtime across the stale-code sentinel files.
|
||||
|
||||
Legacy fallback used only for non-git installs (``.git`` missing).
|
||||
Missing files are ignored (they may not exist on older checkouts).
|
||||
Returns 0.0 if no sentinel file is readable — treat that as "can't
|
||||
tell", which downstream callers interpret as "not stale" to avoid
|
||||
false-positive restart loops.
|
||||
"""
|
||||
newest = 0.0
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
try:
|
||||
st = (repo_root / rel).stat()
|
||||
except (OSError, FileNotFoundError):
|
||||
continue
|
||||
if st.st_mtime > newest:
|
||||
newest = st.st_mtime
|
||||
return newest
|
||||
|
||||
|
||||
def _coerce_gateway_timestamp(value: Any) -> Optional[float]:
|
||||
"""Best-effort conversion of stored gateway timestamps to epoch seconds.
|
||||
|
||||
|
|
@ -1107,13 +961,6 @@ class GatewayRunner:
|
|||
_stop_task: Optional[asyncio.Task] = None
|
||||
_session_model_overrides: Dict[str, Dict[str, str]] = {}
|
||||
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
|
||||
# Stale-code self-check defaults (see _detect_stale_code()). Class-level
|
||||
# so tests that construct GatewayRunner via ``object.__new__`` without
|
||||
# running __init__ don't crash when _handle_message reads these.
|
||||
_boot_wall_time: float = 0.0
|
||||
_boot_repo_mtime: float = 0.0
|
||||
_boot_git_sha: Optional[str] = None
|
||||
_stale_code_restart_triggered: bool = False
|
||||
|
||||
def __init__(self, config: Optional[GatewayConfig] = None):
|
||||
global _gateway_runner_ref
|
||||
|
|
@ -1122,30 +969,6 @@ class GatewayRunner:
|
|||
self._warn_if_docker_media_delivery_is_risky()
|
||||
_gateway_runner_ref = _weakref.ref(self)
|
||||
|
||||
# Boot-time snapshot used by the stale-code self-check. Captured
|
||||
# before any work happens so post-update file writes are guaranteed
|
||||
# to have newer mtimes. See _detect_stale_code() / Issue #17648.
|
||||
try:
|
||||
self._boot_wall_time: float = time.time()
|
||||
self._repo_root_for_staleness: Path = Path(__file__).resolve().parent.parent
|
||||
self._boot_git_sha: Optional[str] = _read_git_head_sha(
|
||||
self._repo_root_for_staleness,
|
||||
)
|
||||
self._boot_repo_mtime: float = _compute_repo_mtime(
|
||||
self._repo_root_for_staleness,
|
||||
)
|
||||
except Exception:
|
||||
self._boot_wall_time = 0.0
|
||||
self._repo_root_for_staleness = Path(".")
|
||||
self._boot_git_sha = None
|
||||
self._boot_repo_mtime = 0.0
|
||||
self._stale_code_notified: set[str] = set()
|
||||
self._stale_code_restart_triggered: bool = False
|
||||
# Cached current-SHA read, refreshed at most every
|
||||
# _GIT_SHA_CACHE_TTL_SECS so bursty chats don't hammer the filesystem.
|
||||
self._cached_current_sha: Optional[str] = self._boot_git_sha
|
||||
self._cached_current_sha_at: float = self._boot_wall_time
|
||||
|
||||
# Load ephemeral config from config.yaml / env vars.
|
||||
# Both are injected at API-call time only and never persisted.
|
||||
self._prefill_messages = self._load_prefill_messages()
|
||||
|
|
@ -2853,101 +2676,6 @@ class GatewayRunner:
|
|||
task.add_done_callback(self._background_tasks.discard)
|
||||
return True
|
||||
|
||||
def _current_git_sha_cached(self) -> Optional[str]:
|
||||
"""Return the current HEAD SHA, cached for _GIT_SHA_CACHE_TTL_SECS.
|
||||
|
||||
A bursty chat (user mashes "hello?" three times) would otherwise
|
||||
re-read ``.git/HEAD`` on every message. Caching collapses that
|
||||
into a single read and still re-checks within the user's
|
||||
perceived "next message" window.
|
||||
"""
|
||||
now = time.time()
|
||||
if (
|
||||
self._cached_current_sha is not None
|
||||
and (now - self._cached_current_sha_at) < _GIT_SHA_CACHE_TTL_SECS
|
||||
):
|
||||
return self._cached_current_sha
|
||||
try:
|
||||
sha = _read_git_head_sha(self._repo_root_for_staleness)
|
||||
except Exception:
|
||||
sha = None
|
||||
self._cached_current_sha = sha
|
||||
self._cached_current_sha_at = now
|
||||
return sha
|
||||
|
||||
def _detect_stale_code(self) -> bool:
|
||||
"""Return True if the git HEAD moved since this process booted.
|
||||
|
||||
A gateway that survives ``hermes update`` (manual SIGTERM never
|
||||
escalated, systemd restart race, detached-process respawn failed,
|
||||
etc.) keeps pre-update modules cached in ``sys.modules``. Later
|
||||
imports of names added post-update — e.g. ``cfg_get`` from PR
|
||||
#17304 — raise ImportError against the stale module object (see
|
||||
Issue #17648).
|
||||
|
||||
We compare the git HEAD SHA at boot to the current SHA on disk.
|
||||
``hermes update`` always moves HEAD forward via ``git pull``;
|
||||
agent file edits (the agent patching ``run_agent.py`` or
|
||||
``gateway/run.py`` during a self-dev session) never move HEAD.
|
||||
That makes SHA comparison free of the false-positive class that
|
||||
the old mtime check suffered from — the agent can edit any file
|
||||
without triggering a phantom restart.
|
||||
|
||||
Returns False when:
|
||||
- the boot SHA is unavailable (non-git install, first call
|
||||
during partial init, etc.); we can't tell and refuse to loop
|
||||
- the current SHA matches the boot SHA
|
||||
- reading the current SHA fails for any reason
|
||||
"""
|
||||
if not self._boot_wall_time:
|
||||
return False
|
||||
if not self._boot_git_sha:
|
||||
# Non-git install. ``hermes update`` is git-based, so a
|
||||
# non-git install can't experience the stale-modules class
|
||||
# this check exists to catch. Return False — no check, no
|
||||
# false positives. (If we ever ship a pip-install update
|
||||
# path, we'd add a persistent update marker here and compare
|
||||
# its timestamp to self._boot_wall_time.)
|
||||
return False
|
||||
try:
|
||||
current = self._current_git_sha_cached()
|
||||
except Exception:
|
||||
return False
|
||||
if not current:
|
||||
return False
|
||||
return current != self._boot_git_sha
|
||||
|
||||
def _trigger_stale_code_restart(self) -> None:
|
||||
"""Idempotently kick off a graceful restart after stale-code detection.
|
||||
|
||||
Runs at most once per process. The restart request goes through
|
||||
the normal drain path so in-flight agent turns finish before the
|
||||
process exits; the service manager (systemd / launchd / detached
|
||||
profile watcher) then respawns with fresh code. On manual
|
||||
``hermes gateway run`` installs without a supervisor, the
|
||||
process exits and the user must restart by hand — but they get a
|
||||
user-visible message telling them so.
|
||||
"""
|
||||
if self._stale_code_restart_triggered:
|
||||
return
|
||||
self._stale_code_restart_triggered = True
|
||||
current_sha = None
|
||||
try:
|
||||
current_sha = self._current_git_sha_cached()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(
|
||||
"Stale-code self-check: git HEAD moved since gateway boot "
|
||||
"(boot=%s, current=%s) — requesting graceful restart. "
|
||||
"See Issue #17648.",
|
||||
(self._boot_git_sha or "?")[:12],
|
||||
(current_sha or "?")[:12],
|
||||
)
|
||||
try:
|
||||
self.request_restart(detached=False, via_service=True)
|
||||
except Exception as exc:
|
||||
logger.error("Stale-code restart request failed: %s", exc)
|
||||
|
||||
async def start(self) -> bool:
|
||||
"""
|
||||
Start the gateway and all configured platform adapters.
|
||||
|
|
@ -3906,7 +3634,17 @@ class GatewayRunner:
|
|||
return out
|
||||
|
||||
def _ready_nonempty() -> bool:
|
||||
"""Cheap probe: is there a ready+assigned+unclaimed task on ANY board?"""
|
||||
"""Cheap probe: is there at least one ready+assigned+unclaimed
|
||||
task on ANY board whose assignee maps to a real Hermes profile
|
||||
(i.e. one the dispatcher would actually spawn for)?
|
||||
|
||||
Tasks assigned to control-plane lanes (e.g. ``orion-cc``,
|
||||
``orion-research``) are pulled by terminals via
|
||||
``claim_task`` directly and never spawnable, so a queue full
|
||||
of those is "correctly idle", not "stuck". Filtering them out
|
||||
here keeps the stuck-warn fire only on real failures (broken
|
||||
PATH, missing venv, credential loss for a real Hermes profile).
|
||||
"""
|
||||
try:
|
||||
boards = _kb.list_boards(include_archived=False)
|
||||
except Exception:
|
||||
|
|
@ -3916,12 +3654,7 @@ class GatewayRunner:
|
|||
conn = None
|
||||
try:
|
||||
conn = _kb.connect(board=slug)
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM tasks "
|
||||
"WHERE status = 'ready' AND assignee IS NOT NULL "
|
||||
" AND claim_lock IS NULL LIMIT 1"
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if _kb.has_spawnable_ready(conn):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
|
|
@ -4873,27 +4606,6 @@ class GatewayRunner:
|
|||
"""
|
||||
source = event.source
|
||||
|
||||
# Stale-code self-check (Issue #17648). A gateway that survives
|
||||
# ``hermes update`` keeps old modules cached in sys.modules; the
|
||||
# first inbound message is our earliest safe chance to detect
|
||||
# this and restart gracefully before we dispatch to the agent
|
||||
# and hit ImportError on freshly-added names (e.g. cfg_get).
|
||||
# Idempotent — runs the real check at most once per message, and
|
||||
# request_restart() no-ops after the first call.
|
||||
try:
|
||||
if self._detect_stale_code():
|
||||
self._trigger_stale_code_restart()
|
||||
# Acknowledge to the user so they don't see a silent
|
||||
# drop; the gateway will be back up in a moment via the
|
||||
# service manager / profile-watcher respawn.
|
||||
return (
|
||||
"⟳ Gateway code was updated in the background — "
|
||||
"restarting this gateway so your next message runs "
|
||||
"on the new code. Please retry in a moment."
|
||||
)
|
||||
except Exception as _stale_exc:
|
||||
logger.debug("Stale-code self-check failed: %s", _stale_exc)
|
||||
|
||||
# Internal events (e.g. background-process completion notifications)
|
||||
# are system-generated and must skip user authorization.
|
||||
is_internal = bool(getattr(event, "internal", False))
|
||||
|
|
@ -5020,10 +4732,12 @@ class GatewayRunner:
|
|||
response_text = raw
|
||||
if response_text:
|
||||
response_path = _hermes_home / ".update_response"
|
||||
prompt_path = _hermes_home / ".update_prompt.json"
|
||||
try:
|
||||
tmp = response_path.with_suffix(".tmp")
|
||||
tmp.write_text(response_text)
|
||||
tmp.replace(response_path)
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to write update response: %s", e)
|
||||
return f"✗ Failed to send response to update process: {e}"
|
||||
|
|
@ -5038,10 +4752,12 @@ class GatewayRunner:
|
|||
# The slash command then falls through to normal dispatch.
|
||||
if _recognized_cmd:
|
||||
response_path = _hermes_home / ".update_response"
|
||||
prompt_path = _hermes_home / ".update_prompt.json"
|
||||
try:
|
||||
tmp = response_path.with_suffix(".tmp")
|
||||
tmp.write_text("")
|
||||
tmp.replace(response_path)
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
logger.info(
|
||||
"Recognized /%s during pending update prompt for %s; "
|
||||
"cancelled prompt with default and dispatching command",
|
||||
|
|
@ -7662,7 +7378,7 @@ class GatewayRunner:
|
|||
if self._restart_requested or self._draining:
|
||||
count = self._running_agent_count()
|
||||
if count:
|
||||
return f"⏳ Draining {count} active agent(s) before restart..."
|
||||
return t("gateway.draining", count=count)
|
||||
return EphemeralReply("⏳ Gateway restart already in progress...")
|
||||
|
||||
# Save the requester's routing info so the new gateway process can
|
||||
|
|
@ -7714,7 +7430,7 @@ class GatewayRunner:
|
|||
else:
|
||||
self.request_restart(detached=True, via_service=False)
|
||||
if active_agents:
|
||||
return f"⏳ Draining {active_agents} active agent(s) before restart..."
|
||||
return t("gateway.draining", count=active_agents)
|
||||
return EphemeralReply("♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`.")
|
||||
|
||||
def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool:
|
||||
|
|
@ -7862,6 +7578,7 @@ class GatewayRunner:
|
|||
from hermes_cli.model_switch import (
|
||||
switch_model as _switch_model, parse_model_flags,
|
||||
list_authenticated_providers,
|
||||
list_picker_providers,
|
||||
)
|
||||
from hermes_cli.providers import get_label
|
||||
|
||||
|
|
@ -7916,7 +7633,7 @@ class GatewayRunner:
|
|||
|
||||
if has_picker:
|
||||
try:
|
||||
providers = list_authenticated_providers(
|
||||
providers = list_picker_providers(
|
||||
current_provider=current_provider,
|
||||
current_base_url=current_base_url,
|
||||
current_model=current_model,
|
||||
|
|
@ -8384,7 +8101,7 @@ class GatewayRunner:
|
|||
if lower in ("clear", "stop", "done"):
|
||||
had = mgr.has_goal()
|
||||
mgr.clear()
|
||||
return "✓ Goal cleared." if had else "No active goal."
|
||||
return t("gateway.goal_cleared") if had else t("gateway.no_active_goal")
|
||||
|
||||
# Otherwise — treat the remaining text as the new goal.
|
||||
try:
|
||||
|
|
@ -9602,7 +9319,7 @@ class GatewayRunner:
|
|||
try:
|
||||
user_config: dict = _load_gateway_config()
|
||||
except Exception as e:
|
||||
return f"⚠️ Could not read config.yaml: {e}"
|
||||
return t("gateway.config_read_failed", error=e)
|
||||
|
||||
effective = resolve_footer_config(user_config, platform_key)
|
||||
|
||||
|
|
@ -9635,7 +9352,7 @@ class GatewayRunner:
|
|||
atomic_yaml_write(config_path, user_config)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save runtime_footer.enabled: %s", e)
|
||||
return f"⚠️ Could not save config: {e}"
|
||||
return t("gateway.config_save_failed", error=e)
|
||||
|
||||
state = "ON" if new_state else "OFF"
|
||||
example = ""
|
||||
|
|
@ -11073,7 +10790,7 @@ class GatewayRunner:
|
|||
if not has_blocking_approval(session_key):
|
||||
if session_key in self._pending_approvals:
|
||||
self._pending_approvals.pop(session_key)
|
||||
return "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again."
|
||||
return t("gateway.approval_expired")
|
||||
return "No pending command to approve."
|
||||
|
||||
# Parse args: support "all", "all session", "all always", "session", "always"
|
||||
|
|
@ -11488,12 +11205,13 @@ class GatewayRunner:
|
|||
f"or type your answer directly.",
|
||||
metadata=metadata,
|
||||
)
|
||||
# Keep the prompt marker on disk until the user
|
||||
# answers. If the gateway restarts mid-prompt, the
|
||||
# next watcher can recover by re-forwarding it from
|
||||
# disk. Duplicate sends in the same process are
|
||||
# still suppressed by _update_prompt_pending.
|
||||
self._update_prompt_pending[session_key] = True
|
||||
# Remove the prompt file so it isn't re-read on the
|
||||
# next poll cycle. The update process only needs
|
||||
# .update_response to continue — it doesn't re-check
|
||||
# .update_prompt.json while waiting.
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
logger.info("Forwarded update prompt to %s: %s", session_key, prompt_text[:80])
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.debug("Failed to read update prompt: %s", e)
|
||||
|
|
|
|||
|
|
@ -1276,8 +1276,9 @@ class SessionStore:
|
|||
|
||||
# Also write legacy JSONL (keeps existing tooling working during transition)
|
||||
transcript_path = self.get_transcript_path(session_id)
|
||||
with open(transcript_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(message, ensure_ascii=False) + "\n")
|
||||
with self._lock:
|
||||
with open(transcript_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(message, ensure_ascii=False) + "\n")
|
||||
|
||||
def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
|
||||
"""Replace the entire transcript for a session with new messages.
|
||||
|
|
|
|||
|
|
@ -235,6 +235,9 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
|
|||
"""
|
||||
findings: list[tuple[Path, str]] = []
|
||||
|
||||
if not source_dir.exists():
|
||||
return findings
|
||||
|
||||
# Direct state files in the root
|
||||
for name in ("todo.json", "sessions", "logs"):
|
||||
candidate = source_dir / name
|
||||
|
|
@ -243,7 +246,12 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
|
|||
findings.append((candidate, f"Root {kind}: {name}"))
|
||||
|
||||
# State files inside workspace directories
|
||||
for child in sorted(source_dir.iterdir()):
|
||||
try:
|
||||
children = sorted(source_dir.iterdir())
|
||||
except OSError:
|
||||
return findings
|
||||
|
||||
for child in children:
|
||||
if not child.is_dir() or child.name.startswith("."):
|
||||
continue
|
||||
# Check for workspace-like subdirectories
|
||||
|
|
|
|||
|
|
@ -781,6 +781,11 @@ DEFAULT_CONFIG = {
|
|||
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
# UI language for static user-facing messages (approval prompts, a
|
||||
# handful of gateway slash-command replies). Does NOT affect agent
|
||||
# responses, log lines, tool outputs, or slash-command descriptions.
|
||||
# Supported: en, zh, ja, de, es. Unknown values fall back to en.
|
||||
"language": "en",
|
||||
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
|
||||
# spinner), or ascii. Live-swappable via `/indicator <style>`.
|
||||
"tui_status_indicator": "kaomoji",
|
||||
|
|
|
|||
|
|
@ -245,6 +245,111 @@ def _cmd_restore(args) -> int:
|
|||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _cmd_archive(args) -> int:
|
||||
"""Manually archive an agent-created skill. Refuses if pinned.
|
||||
|
||||
The auto-curator archives stale skills on its own schedule; this verb is
|
||||
for the user who wants to archive *now* without waiting for a run.
|
||||
"""
|
||||
from tools import skill_usage
|
||||
if skill_usage.get_record(args.skill).get("pinned"):
|
||||
print(
|
||||
f"curator: '{args.skill}' is pinned — unpin first with "
|
||||
f"`hermes curator unpin {args.skill}`"
|
||||
)
|
||||
return 1
|
||||
ok, msg = skill_usage.archive_skill(args.skill)
|
||||
print(f"curator: {msg}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _idle_days(record: dict) -> Optional[int]:
|
||||
"""Days since the skill's last activity (view / use / patch).
|
||||
|
||||
Falls back to ``created_at`` so a skill that was authored but never used
|
||||
can still be pruned — otherwise never-touched skills would be immortal.
|
||||
Returns None only when both fields are missing or unparseable.
|
||||
"""
|
||||
ts = record.get("last_activity_at") or record.get("created_at")
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(ts))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return max(0, (datetime.now(timezone.utc) - dt).days)
|
||||
|
||||
|
||||
def _cmd_prune(args) -> int:
|
||||
"""Bulk-archive agent-created skills idle for >= N days.
|
||||
|
||||
Pinned skills are exempt. Already-archived skills are skipped. Default
|
||||
``--days 90`` matches a conservative read of the curator's own archive
|
||||
threshold; adjust with ``--days``. Use ``--dry-run`` to preview.
|
||||
"""
|
||||
from tools import skill_usage
|
||||
days = getattr(args, "days", 90)
|
||||
if days < 1:
|
||||
print(f"curator: --days must be >= 1 (got {days})", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
dry_run = bool(getattr(args, "dry_run", False))
|
||||
skip_confirm = bool(getattr(args, "yes", False))
|
||||
|
||||
candidates = []
|
||||
for r in skill_usage.agent_created_report():
|
||||
if r.get("pinned"):
|
||||
continue
|
||||
if r.get("state") == skill_usage.STATE_ARCHIVED:
|
||||
continue
|
||||
idle = _idle_days(r)
|
||||
if idle is None or idle < days:
|
||||
continue
|
||||
candidates.append((r["name"], idle))
|
||||
|
||||
if not candidates:
|
||||
print(f"curator: nothing to prune (no unpinned skills idle >= {days}d)")
|
||||
return 0
|
||||
|
||||
candidates.sort(key=lambda c: -c[1])
|
||||
print(f"curator: {len(candidates)} skill(s) idle >= {days}d:")
|
||||
for name, idle in candidates:
|
||||
print(f" {name:40s} idle {idle}d")
|
||||
|
||||
if dry_run:
|
||||
print("\n(dry run — no changes made)")
|
||||
return 0
|
||||
|
||||
if not skip_confirm:
|
||||
try:
|
||||
reply = input(f"\nArchive {len(candidates)} skill(s)? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\ncurator: aborted")
|
||||
return 1
|
||||
if reply not in ("y", "yes"):
|
||||
print("curator: aborted")
|
||||
return 1
|
||||
|
||||
archived = 0
|
||||
failures = []
|
||||
for name, _ in candidates:
|
||||
ok, msg = skill_usage.archive_skill(name)
|
||||
if ok:
|
||||
archived += 1
|
||||
else:
|
||||
failures.append((name, msg))
|
||||
|
||||
print(f"\ncurator: archived {archived}/{len(candidates)}")
|
||||
if failures:
|
||||
print("failures:")
|
||||
for name, msg in failures:
|
||||
print(f" {name}: {msg}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_backup(args) -> int:
|
||||
"""Take a manual snapshot of the skills tree. Same mechanism as the
|
||||
automatic pre-run snapshot, just user-initiated."""
|
||||
|
|
@ -383,6 +488,31 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
|||
p_restore.add_argument("skill", help="Skill name")
|
||||
p_restore.set_defaults(func=_cmd_restore)
|
||||
|
||||
p_archive = subs.add_parser(
|
||||
"archive",
|
||||
help="Manually archive a skill (move to .archive/, excluded from prompt)",
|
||||
)
|
||||
p_archive.add_argument("skill", help="Skill name")
|
||||
p_archive.set_defaults(func=_cmd_archive)
|
||||
|
||||
p_prune = subs.add_parser(
|
||||
"prune",
|
||||
help="Bulk-archive agent-created skills idle for >= N days (default 90)",
|
||||
)
|
||||
p_prune.add_argument(
|
||||
"--days", type=int, default=90,
|
||||
help="Archive skills idle for at least N days (default: 90)",
|
||||
)
|
||||
p_prune.add_argument(
|
||||
"-y", "--yes", action="store_true",
|
||||
help="Skip the confirmation prompt",
|
||||
)
|
||||
p_prune.add_argument(
|
||||
"--dry-run", dest="dry_run", action="store_true",
|
||||
help="Show what would be archived without doing it",
|
||||
)
|
||||
p_prune.set_defaults(func=_cmd_prune)
|
||||
|
||||
p_backup = subs.add_parser(
|
||||
"backup",
|
||||
help="Take a manual tar.gz snapshot of ~/.hermes/skills/ "
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import importlib.util
|
|||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
PROJECT_ROOT = get_project_root()
|
||||
|
|
@ -19,15 +20,8 @@ HERMES_HOME = get_hermes_home()
|
|||
_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)
|
||||
|
||||
# Load environment variables from ~/.hermes/.env so API key checks work
|
||||
from dotenv import load_dotenv
|
||||
_env_path = get_env_path()
|
||||
if _env_path.exists():
|
||||
try:
|
||||
load_dotenv(_env_path, encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
load_dotenv(_env_path, encoding="latin-1")
|
||||
# Also try project .env as dev fallback
|
||||
load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8")
|
||||
load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".env")
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.models import _HERMES_USER_AGENT
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import sys
|
|||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_env_path, get_project_root, load_config
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
|
||||
|
|
@ -195,15 +196,11 @@ def run_dump(args):
|
|||
show_keys = getattr(args, "show_keys", False)
|
||||
|
||||
# Load env from .env file so key checks work
|
||||
from dotenv import load_dotenv
|
||||
env_path = get_env_path()
|
||||
if env_path.exists():
|
||||
try:
|
||||
load_dotenv(env_path, encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
load_dotenv(env_path, encoding="latin-1")
|
||||
# Also try project .env as dev fallback
|
||||
load_dotenv(get_project_root() / ".env", override=False, encoding="utf-8")
|
||||
load_hermes_dotenv(
|
||||
hermes_home=env_path.parent,
|
||||
project_env=get_project_root() / ".env",
|
||||
)
|
||||
|
||||
project_root = get_project_root()
|
||||
hermes_home = get_hermes_home()
|
||||
|
|
|
|||
|
|
@ -308,6 +308,35 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
|||
p_assign.add_argument("task_id")
|
||||
p_assign.add_argument("profile", help="Profile name (or 'none' to unassign)")
|
||||
|
||||
# --- reclaim / reassign (recovery) ---
|
||||
p_reclaim = sub.add_parser(
|
||||
"reclaim",
|
||||
help="Release an active worker claim on a running task",
|
||||
)
|
||||
p_reclaim.add_argument("task_id")
|
||||
p_reclaim.add_argument(
|
||||
"--reason", default=None,
|
||||
help="Human-readable reason (recorded on the reclaimed event)",
|
||||
)
|
||||
|
||||
p_reassign = sub.add_parser(
|
||||
"reassign",
|
||||
help="Reassign a task to a different profile, optionally reclaiming first",
|
||||
)
|
||||
p_reassign.add_argument("task_id")
|
||||
p_reassign.add_argument(
|
||||
"profile",
|
||||
help="New profile name (or 'none' to unassign)",
|
||||
)
|
||||
p_reassign.add_argument(
|
||||
"--reclaim", action="store_true",
|
||||
help="Release any active claim before reassigning (required if task is running)",
|
||||
)
|
||||
p_reassign.add_argument(
|
||||
"--reason", default=None,
|
||||
help="Human-readable reason (recorded on the reclaimed event)",
|
||||
)
|
||||
|
||||
# --- link / unlink ---
|
||||
p_link = sub.add_parser("link", help="Add a parent->child dependency")
|
||||
p_link.add_argument("parent_id")
|
||||
|
|
@ -343,6 +372,27 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
|||
help='JSON dict of structured facts (e.g. \'{"changed_files": [...], '
|
||||
'"tests_run": 12}\'). Stored on the closing run.')
|
||||
|
||||
p_edit = sub.add_parser(
|
||||
"edit",
|
||||
help="Edit recovery fields on an already-completed task",
|
||||
)
|
||||
p_edit.add_argument("task_id")
|
||||
p_edit.add_argument(
|
||||
"--result",
|
||||
required=True,
|
||||
help="Backfilled task result text for a done task",
|
||||
)
|
||||
p_edit.add_argument(
|
||||
"--summary",
|
||||
default=None,
|
||||
help="Structured handoff summary. Falls back to --result if omitted.",
|
||||
)
|
||||
p_edit.add_argument(
|
||||
"--metadata",
|
||||
default=None,
|
||||
help="JSON dict of structured facts to store on the latest completed run.",
|
||||
)
|
||||
|
||||
p_block = sub.add_parser("block", help="Mark one or more tasks blocked")
|
||||
p_block.add_argument("task_id")
|
||||
p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)")
|
||||
|
|
@ -576,11 +626,14 @@ def kanban_command(args: argparse.Namespace) -> int:
|
|||
"ls": _cmd_list,
|
||||
"show": _cmd_show,
|
||||
"assign": _cmd_assign,
|
||||
"reclaim": _cmd_reclaim,
|
||||
"reassign": _cmd_reassign,
|
||||
"link": _cmd_link,
|
||||
"unlink": _cmd_unlink,
|
||||
"claim": _cmd_claim,
|
||||
"comment": _cmd_comment,
|
||||
"complete": _cmd_complete,
|
||||
"edit": _cmd_edit,
|
||||
"block": _cmd_block,
|
||||
"unblock": _cmd_unblock,
|
||||
"archive": _cmd_archive,
|
||||
|
|
@ -1095,6 +1148,45 @@ def _cmd_assign(args: argparse.Namespace) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def _cmd_reclaim(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
ok = kb.reclaim_task(
|
||||
conn, args.task_id,
|
||||
reason=getattr(args, "reason", None),
|
||||
)
|
||||
if not ok:
|
||||
print(
|
||||
f"cannot reclaim {args.task_id} (not running or unknown id)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(f"Reclaimed {args.task_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_reassign(args: argparse.Namespace) -> int:
|
||||
profile = None if args.profile.lower() in ("none", "-", "null") else args.profile
|
||||
with kb.connect() as conn:
|
||||
ok = kb.reassign_task(
|
||||
conn, args.task_id, profile,
|
||||
reclaim_first=bool(getattr(args, "reclaim", False)),
|
||||
reason=getattr(args, "reason", None),
|
||||
)
|
||||
if not ok:
|
||||
print(
|
||||
f"cannot reassign {args.task_id} "
|
||||
f"(unknown id, or still running — pass --reclaim to release first)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(
|
||||
f"Reassigned {args.task_id} to "
|
||||
f"{profile or '(unassigned)'}"
|
||||
+ (" (claim reclaimed)" if getattr(args, "reclaim", False) else "")
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_link(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
kb.link_tasks(conn, args.parent_id, args.child_id)
|
||||
|
|
@ -1187,6 +1279,34 @@ def _cmd_complete(args: argparse.Namespace) -> int:
|
|||
return 0 if not failed else 1
|
||||
|
||||
|
||||
def _cmd_edit(args: argparse.Namespace) -> int:
|
||||
raw_meta = getattr(args, "metadata", None)
|
||||
metadata = None
|
||||
if raw_meta:
|
||||
try:
|
||||
metadata = json.loads(raw_meta)
|
||||
if not isinstance(metadata, dict):
|
||||
raise ValueError("must be a JSON object")
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"kanban: --metadata: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
if not kb.edit_completed_task_result(
|
||||
conn,
|
||||
args.task_id,
|
||||
result=args.result,
|
||||
summary=getattr(args, "summary", None),
|
||||
metadata=metadata,
|
||||
):
|
||||
print(
|
||||
f"cannot edit {args.task_id} (unknown id or task is not done)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(f"Edited {args.task_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_block(args: argparse.Namespace) -> int:
|
||||
reason = " ".join(args.reason).strip() if args.reason else None
|
||||
author = _profile_author()
|
||||
|
|
@ -1274,6 +1394,7 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
|
|||
for (tid, who, ws) in res.spawned
|
||||
],
|
||||
"skipped_unassigned": res.skipped_unassigned,
|
||||
"skipped_nonspawnable": res.skipped_nonspawnable,
|
||||
}, indent=2))
|
||||
return 0
|
||||
print(f"Reclaimed: {res.reclaimed}")
|
||||
|
|
@ -1293,6 +1414,11 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
|
|||
print(f" - {tid} -> {who} @ {ws or '-'}{tag}")
|
||||
if res.skipped_unassigned:
|
||||
print(f"Skipped (unassigned): {', '.join(res.skipped_unassigned)}")
|
||||
if res.skipped_nonspawnable:
|
||||
print(
|
||||
f"Skipped (non-spawnable assignee — terminal lane, OK): "
|
||||
f"{', '.join(res.skipped_nonspawnable)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
|
@ -1404,16 +1530,18 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
|
|||
)
|
||||
|
||||
def _ready_queue_nonempty() -> bool:
|
||||
"""Cheap SELECT — just asks whether there's at least one ready
|
||||
task with an assignee that the dispatcher could have picked up."""
|
||||
"""Cheap probe — is there at least one ready+assigned+unclaimed
|
||||
task whose assignee maps to a real Hermes profile (i.e. one the
|
||||
dispatcher would actually try to spawn for)?
|
||||
|
||||
Filters out tasks assigned to control-plane lanes
|
||||
(e.g. ``orion-cc``, ``orion-research``) that are pulled by
|
||||
terminals via ``claim_task`` directly — those are correctly idle
|
||||
from the dispatcher's perspective, not stuck.
|
||||
"""
|
||||
try:
|
||||
with kb.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM tasks "
|
||||
"WHERE status = 'ready' AND assignee IS NOT NULL "
|
||||
" AND claim_lock IS NULL LIMIT 1"
|
||||
).fetchone()
|
||||
return row is not None
|
||||
return kb.has_spawnable_ready(conn)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ import os
|
|||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
|
@ -190,12 +191,12 @@ def get_current_board() -> str:
|
|||
1. ``HERMES_KANBAN_BOARD`` env var (set by the dispatcher on worker
|
||||
spawn, or manually for ad-hoc overrides).
|
||||
2. ``<root>/kanban/current`` on disk (set by ``hermes kanban boards
|
||||
switch``).
|
||||
switch``), but only when that board still exists.
|
||||
3. ``DEFAULT_BOARD`` (``"default"``).
|
||||
|
||||
A malformed slug at any step falls through to the next layer with a
|
||||
best-effort warning — the dispatcher must never crash because a user
|
||||
hand-edited a file.
|
||||
A malformed or stale slug at any step falls through to the next layer
|
||||
with a best-effort warning — the dispatcher must never crash because a
|
||||
user hand-edited a file or removed a board directory.
|
||||
"""
|
||||
env = os.environ.get("HERMES_KANBAN_BOARD", "").strip()
|
||||
if env:
|
||||
|
|
@ -212,7 +213,7 @@ def get_current_board() -> str:
|
|||
if val:
|
||||
try:
|
||||
normed = _normalize_board_slug(val)
|
||||
if normed:
|
||||
if normed and board_exists(normed):
|
||||
return normed
|
||||
except ValueError:
|
||||
pass
|
||||
|
|
@ -1841,6 +1842,212 @@ def release_stale_claims(conn: sqlite3.Connection) -> int:
|
|||
return reclaimed
|
||||
|
||||
|
||||
def reclaim_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Operator-driven reclaim: release the claim and reset to ``ready``.
|
||||
|
||||
Unlike :func:`release_stale_claims` which only acts on tasks whose
|
||||
``claim_expires`` has passed, this function reclaims immediately
|
||||
regardless of TTL. Intended for the dashboard/CLI recovery flow
|
||||
when an operator wants to abort a running worker without waiting
|
||||
for the TTL to expire (e.g. after seeing a hallucination warning).
|
||||
|
||||
Returns True if a reclaim happened, False if the task isn't in a
|
||||
reclaimable state (not running, or doesn't exist).
|
||||
"""
|
||||
with write_txn(conn):
|
||||
row = conn.execute(
|
||||
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
if row["status"] != "running" and row["claim_lock"] is None:
|
||||
# Nothing to reclaim — already ready / blocked / done.
|
||||
return False
|
||||
prev_lock = row["claim_lock"]
|
||||
prev_pid = row["worker_pid"]
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
|
||||
"claim_expires = NULL, worker_pid = NULL "
|
||||
"WHERE id = ? AND status IN ('running', 'ready', 'blocked')",
|
||||
(task_id,),
|
||||
)
|
||||
run_id = _end_run(
|
||||
conn, task_id,
|
||||
outcome="reclaimed", status="reclaimed",
|
||||
error=(
|
||||
f"manual_reclaim: {reason}" if reason
|
||||
else f"manual_reclaim lock={prev_lock}"
|
||||
),
|
||||
)
|
||||
_append_event(
|
||||
conn, task_id, "reclaimed",
|
||||
{
|
||||
"manual": True,
|
||||
"reason": reason,
|
||||
"prev_lock": prev_lock,
|
||||
"prev_pid": prev_pid,
|
||||
},
|
||||
run_id=run_id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def reassign_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
profile: Optional[str],
|
||||
*,
|
||||
reclaim_first: bool = False,
|
||||
reason: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Reassign a task, optionally reclaiming a stuck running worker first.
|
||||
|
||||
This is the recovery path for "this profile's model is broken, try
|
||||
a different one". If ``reclaim_first`` is True, any active claim is
|
||||
released (via :func:`reclaim_task`) before the reassign happens;
|
||||
otherwise the function refuses to reassign a currently-running task
|
||||
and returns False (caller can retry with ``reclaim_first=True``).
|
||||
|
||||
Returns True if the reassign landed. ``profile`` may be ``None`` to
|
||||
unassign entirely.
|
||||
"""
|
||||
if reclaim_first:
|
||||
# Safe to call even if nothing to reclaim.
|
||||
reclaim_task(conn, task_id, reason=reason or "reassign")
|
||||
# assign_task handles its own txn + the still-running guard.
|
||||
try:
|
||||
return assign_task(conn, task_id, profile)
|
||||
except RuntimeError:
|
||||
# Task is still running and reclaim_first was False; caller
|
||||
# needs to decide whether to retry with reclaim.
|
||||
return False
|
||||
|
||||
|
||||
def _verify_created_cards(
|
||||
conn: sqlite3.Connection,
|
||||
completing_task_id: str,
|
||||
claimed_ids: Iterable[str],
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Partition ``claimed_ids`` into (verified, phantom).
|
||||
|
||||
A card is "verified" iff a row exists in ``tasks`` with the given id
|
||||
AND ``created_by`` matches the completing task's ``assignee`` (or
|
||||
the completing task itself — workers that create children of their
|
||||
own task also qualify).
|
||||
|
||||
``phantom`` returns ids that either don't exist at all or exist but
|
||||
were not created by the completing worker. The caller decides what
|
||||
to do with each bucket; this helper never mutates.
|
||||
"""
|
||||
claimed = [str(x).strip() for x in (claimed_ids or []) if str(x).strip()]
|
||||
if not claimed:
|
||||
return [], []
|
||||
# Dedupe while preserving order.
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for cid in claimed:
|
||||
if cid not in seen:
|
||||
seen.add(cid)
|
||||
ordered.append(cid)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT assignee FROM tasks WHERE id = ?", (completing_task_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
# Completing task not found — nothing resolves.
|
||||
return [], ordered
|
||||
completing_assignee = row["assignee"]
|
||||
|
||||
# Batch-fetch existence + created_by in one query.
|
||||
placeholders = ",".join(["?"] * len(ordered))
|
||||
rows = conn.execute(
|
||||
f"SELECT id, created_by FROM tasks WHERE id IN ({placeholders})",
|
||||
tuple(ordered),
|
||||
).fetchall()
|
||||
found = {r["id"]: r["created_by"] for r in rows}
|
||||
|
||||
verified: list[str] = []
|
||||
phantom: list[str] = []
|
||||
for cid in ordered:
|
||||
created_by = found.get(cid)
|
||||
if created_by is None:
|
||||
phantom.append(cid)
|
||||
continue
|
||||
# Accept if created_by matches the completing task's assignee
|
||||
# profile, OR the task itself (workers whose created_by happens
|
||||
# to match their task id are unusual but harmless to accept).
|
||||
if completing_assignee and created_by == completing_assignee:
|
||||
verified.append(cid)
|
||||
elif created_by == completing_task_id:
|
||||
verified.append(cid)
|
||||
else:
|
||||
phantom.append(cid)
|
||||
return verified, phantom
|
||||
|
||||
|
||||
# Task-id pattern used both by ``kanban_create`` (``t_<12 hex>``) and
|
||||
# ``_new_task_id`` below. Kept permissive on length for forward compat:
|
||||
# accept 8+ hex chars after the ``t_`` prefix.
|
||||
_TASK_ID_PROSE_RE = re.compile(r"\bt_[a-f0-9]{8,}\b")
|
||||
|
||||
|
||||
def _scan_prose_for_phantom_ids(
|
||||
conn: sqlite3.Connection,
|
||||
text: str,
|
||||
) -> list[str]:
|
||||
"""Regex-scan free-form text for ``t_<hex>`` references; return the
|
||||
ones that don't exist in ``tasks``.
|
||||
|
||||
Used as a non-blocking advisory check on completion summaries. An
|
||||
empty return means "no suspicious references found" — either the
|
||||
text had no IDs at all, or every ID it mentioned resolves to a real
|
||||
task. Duplicates are deduped.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
matches = _TASK_ID_PROSE_RE.findall(text)
|
||||
if not matches:
|
||||
return []
|
||||
# Dedupe preserving order.
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for m in matches:
|
||||
if m not in seen:
|
||||
seen.add(m)
|
||||
unique.append(m)
|
||||
placeholders = ",".join(["?"] * len(unique))
|
||||
rows = conn.execute(
|
||||
f"SELECT id FROM tasks WHERE id IN ({placeholders})",
|
||||
tuple(unique),
|
||||
).fetchall()
|
||||
existing = {r["id"] for r in rows}
|
||||
return [m for m in unique if m not in existing]
|
||||
|
||||
|
||||
class HallucinatedCardsError(ValueError):
|
||||
"""Raised by ``complete_task`` when ``created_cards`` contains ids
|
||||
that don't exist or weren't created by the completing worker.
|
||||
|
||||
The phantom list is attached as ``.phantom`` for callers that want
|
||||
structured access. Kept as ``ValueError`` subclass so existing
|
||||
tool-error handlers treat it as a recoverable user error.
|
||||
"""
|
||||
|
||||
def __init__(self, phantom: list[str], completing_task_id: str):
|
||||
self.phantom = list(phantom)
|
||||
self.completing_task_id = completing_task_id
|
||||
super().__init__(
|
||||
f"completion blocked: claimed created_cards that do not exist "
|
||||
f"or were not created by this worker: {', '.join(phantom)}"
|
||||
)
|
||||
|
||||
|
||||
def complete_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
|
|
@ -1848,21 +2055,65 @@ def complete_task(
|
|||
result: Optional[str] = None,
|
||||
summary: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
created_cards: Optional[Iterable[str]] = None,
|
||||
) -> bool:
|
||||
"""Transition ``running|ready -> done`` and record ``result``.
|
||||
|
||||
Accepts a task that's merely ``ready`` too, so a manual CLI
|
||||
Accepts a task that is merely ``ready`` too, so a manual CLI
|
||||
completion (``hermes kanban complete <id>``) works without requiring
|
||||
a claim/start/complete sequence.
|
||||
|
||||
``summary`` and ``metadata`` are stored on the closing run (if any)
|
||||
and surfaced to downstream children via :func:`build_worker_context`.
|
||||
When ``summary`` is omitted we fall back to ``result`` so single-run
|
||||
callers don't have to pass both. ``metadata`` is a free-form dict
|
||||
callers do not have to pass both. ``metadata`` is a free-form dict
|
||||
(e.g. ``{"changed_files": [...], "tests_run": [...]}``) — workers
|
||||
are encouraged to use it for structured handoff facts.
|
||||
|
||||
``created_cards`` is an optional list of task ids the completing
|
||||
worker claims to have created. Each id is verified against
|
||||
``tasks.created_by``. If any id is phantom (does not exist or was
|
||||
not created by this worker's assignee profile), completion is blocked
|
||||
with a ``HallucinatedCardsError`` and a
|
||||
``completion_blocked_hallucination`` event is emitted so the rejected
|
||||
attempt is auditable. When all ids verify, they are recorded on the
|
||||
``completed`` event payload.
|
||||
|
||||
After a successful completion, ``summary`` and ``result`` are scanned
|
||||
for prose references like ``t_deadbeefcafe`` that do not resolve.
|
||||
Any suspected phantom references are recorded as a
|
||||
``suspected_hallucinated_references`` event. This pass is advisory
|
||||
and never blocks.
|
||||
"""
|
||||
now = int(time.time())
|
||||
|
||||
# Gate: verify created_cards BEFORE the main write txn. A rejected
|
||||
# completion still needs an auditable event, so we emit it in a
|
||||
# tiny dedicated txn, then raise. The caller is responsible for
|
||||
# surfacing HallucinatedCardsError to the worker; this function
|
||||
# never mutates task state on a phantom-card rejection.
|
||||
if created_cards:
|
||||
verified_cards, phantom_cards = _verify_created_cards(
|
||||
conn, task_id, created_cards
|
||||
)
|
||||
if phantom_cards:
|
||||
with write_txn(conn):
|
||||
_append_event(
|
||||
conn, task_id, "completion_blocked_hallucination",
|
||||
{
|
||||
"phantom_cards": phantom_cards,
|
||||
"verified_cards": verified_cards,
|
||||
"summary_preview": (
|
||||
(summary or result or "").strip().splitlines()[0][:200]
|
||||
if (summary or result)
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
raise HallucinatedCardsError(phantom_cards, task_id)
|
||||
else:
|
||||
verified_cards = []
|
||||
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
"""
|
||||
|
|
@ -1903,16 +2154,107 @@ def complete_task(
|
|||
# full summary stays on the run row.
|
||||
ev_summary = (summary if summary is not None else result) or ""
|
||||
ev_summary = ev_summary.strip().splitlines()[0][:400] if ev_summary else ""
|
||||
completed_payload: dict = {
|
||||
"result_len": len(result) if result else 0,
|
||||
"summary": ev_summary or None,
|
||||
}
|
||||
if verified_cards:
|
||||
completed_payload["verified_cards"] = verified_cards
|
||||
_append_event(
|
||||
conn, task_id, "completed",
|
||||
completed_payload,
|
||||
run_id=run_id,
|
||||
)
|
||||
# Prose-scan the summary + result for t_<hex> references that do
|
||||
# not resolve. Advisory — does not block the completion. Runs in
|
||||
# its own txn so the completion itself is already durable by the
|
||||
# time we emit the warning.
|
||||
scan_text = " ".join(filter(None, [summary, result]))
|
||||
if scan_text:
|
||||
phantom_refs = _scan_prose_for_phantom_ids(conn, scan_text)
|
||||
# Drop any phantom refs that were already flagged as verified
|
||||
# above (shouldn't happen — verified means they exist — but
|
||||
# belt-and-suspenders).
|
||||
phantom_refs = [p for p in phantom_refs if p not in set(verified_cards)]
|
||||
if phantom_refs:
|
||||
with write_txn(conn):
|
||||
_append_event(
|
||||
conn, task_id, "suspected_hallucinated_references",
|
||||
{
|
||||
"phantom_refs": phantom_refs,
|
||||
"source": "completion_summary",
|
||||
},
|
||||
run_id=run_id,
|
||||
)
|
||||
# Recompute ready status for dependents (separate txn so children see done).
|
||||
recompute_ready(conn)
|
||||
return True
|
||||
|
||||
|
||||
def edit_completed_task_result(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
result: str,
|
||||
summary: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> bool:
|
||||
"""Backfill the user-visible result for an already completed task."""
|
||||
handoff_summary = summary if summary is not None else result
|
||||
with write_txn(conn):
|
||||
row = conn.execute(
|
||||
"SELECT status FROM tasks WHERE id = ?", (task_id,),
|
||||
).fetchone()
|
||||
if not row or row["status"] != "done":
|
||||
return False
|
||||
conn.execute(
|
||||
"UPDATE tasks SET result = ? WHERE id = ?",
|
||||
(result, task_id),
|
||||
)
|
||||
run = conn.execute(
|
||||
"""
|
||||
SELECT id FROM task_runs
|
||||
WHERE task_id = ?
|
||||
AND outcome = 'completed'
|
||||
ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
run_id = int(run["id"]) if run else None
|
||||
if run_id is None:
|
||||
run_id = _synthesize_ended_run(
|
||||
conn, task_id,
|
||||
outcome="completed",
|
||||
summary=handoff_summary,
|
||||
metadata=metadata,
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET summary = ? WHERE id = ?",
|
||||
(handoff_summary, run_id),
|
||||
)
|
||||
if metadata is not None:
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET metadata = ? WHERE id = ?",
|
||||
(json.dumps(metadata, ensure_ascii=False), run_id),
|
||||
)
|
||||
ev_summary = (
|
||||
handoff_summary.strip().splitlines()[0][:400]
|
||||
if handoff_summary else ""
|
||||
)
|
||||
_append_event(
|
||||
conn, task_id, "edited",
|
||||
{
|
||||
"fields": (
|
||||
["result", "summary"]
|
||||
+ (["metadata"] if metadata is not None else [])
|
||||
),
|
||||
"result_len": len(result) if result else 0,
|
||||
"summary": ev_summary or None,
|
||||
},
|
||||
run_id=run_id,
|
||||
)
|
||||
# Recompute ready status for dependents (separate txn so children see done).
|
||||
recompute_ready(conn)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -2118,6 +2460,15 @@ class DispatchResult:
|
|||
spawned: list[tuple[str, str, str]] = field(default_factory=list)
|
||||
"""List of ``(task_id, assignee, workspace_path)`` triples."""
|
||||
skipped_unassigned: list[str] = field(default_factory=list)
|
||||
"""Ready task ids skipped because they have no assignee at all.
|
||||
Operator-actionable — usually a misfiled task waiting for routing."""
|
||||
skipped_nonspawnable: list[str] = field(default_factory=list)
|
||||
"""Ready task ids skipped because their assignee names a control-plane
|
||||
lane (a Claude Code terminal like ``orion-cc``) rather than a Hermes
|
||||
profile. Expected steady-state on multi-lane setups; NOT an
|
||||
operator-actionable failure. Tracked separately so health telemetry
|
||||
can distinguish "real stuck" (nothing spawned but spawnable work
|
||||
available) from "correctly idle" (nothing spawnable in the queue)."""
|
||||
crashed: list[str] = field(default_factory=list)
|
||||
"""Task ids reclaimed because their worker PID disappeared."""
|
||||
auto_blocked: list[str] = field(default_factory=list)
|
||||
|
|
@ -2132,16 +2483,16 @@ def _pid_alive(pid: Optional[int]) -> bool:
|
|||
Cross-platform: uses ``os.kill(pid, 0)`` on POSIX and ``OpenProcess``
|
||||
on Windows. Returns False for falsy PIDs or on any OS error.
|
||||
|
||||
**Zombie handling (Linux):** ``os.kill(pid, 0)`` succeeds against
|
||||
**Zombie handling:** ``os.kill(pid, 0)`` succeeds against
|
||||
zombie processes (post-exit, pre-reap) because the process table
|
||||
entry still exists. A worker that exits without being reaped by its
|
||||
parent would stay "alive" to the dispatcher forever. Dispatcher
|
||||
workers are started via ``start_new_session=True`` + intentional
|
||||
Popen handle abandonment, so init reaps them quickly — but during
|
||||
the window between exit and reap, we'd otherwise see stale "alive"
|
||||
signals. On Linux we additionally peek at ``/proc/<pid>/status``
|
||||
and treat ``State: Z`` as dead. On other POSIX or on Windows the
|
||||
zombie check is a no-op.
|
||||
signals. On Linux we peek at ``/proc/<pid>/status`` and treat
|
||||
``State: Z`` as dead. On macOS we ask ``ps`` for the BSD ``stat``
|
||||
field and treat values containing ``Z`` as dead.
|
||||
"""
|
||||
if not pid or pid <= 0:
|
||||
return False
|
||||
|
|
@ -2155,7 +2506,8 @@ def _pid_alive(pid: Optional[int]) -> bool:
|
|||
return True
|
||||
except OSError:
|
||||
return False
|
||||
# Still here → kill(0) succeeded. Check for zombie on Linux.
|
||||
# Still here → kill(0) succeeded. Check for zombie on platforms
|
||||
# where we have a cheap, deterministic process-state probe.
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
with open(f"/proc/{int(pid)}/status", "r") as f:
|
||||
|
|
@ -2170,6 +2522,23 @@ def _pid_alive(pid: Optional[int]) -> bool:
|
|||
# PermissionError shouldn't happen for our own children but
|
||||
# be defensive.
|
||||
pass
|
||||
elif sys.platform == "darwin":
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["ps", "-o", "stat=", "-p", str(int(pid))],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=1,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return False
|
||||
if "Z" in (proc.stdout or "").strip():
|
||||
return False
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError):
|
||||
# If the secondary probe fails, keep the kill(0) answer.
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -2459,6 +2828,38 @@ def _clear_spawn_failures(conn: sqlite3.Connection, task_id: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
def has_spawnable_ready(conn: sqlite3.Connection) -> bool:
|
||||
"""Return True iff there is at least one ready+assigned+unclaimed task
|
||||
whose assignee maps to a real Hermes profile.
|
||||
|
||||
Used by the gateway- and CLI-embedded dispatchers' health telemetry to
|
||||
decide whether ``0 spawned`` is a "stuck" condition (real spawnable
|
||||
work waiting) or a "correctly idle" condition (only control-plane
|
||||
lanes like ``orion-cc`` / ``orion-research`` waiting on terminals
|
||||
that pull tasks via ``claim_task`` directly).
|
||||
|
||||
Falls back to "any ready+assigned" if ``profile_exists`` is not
|
||||
importable (e.g. partial install) — preserves the old behavior so
|
||||
the warning still fires in degraded environments.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT assignee FROM tasks "
|
||||
"WHERE status = 'ready' AND assignee IS NOT NULL "
|
||||
" AND claim_lock IS NULL"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.profiles import profile_exists # local import: avoids cycle
|
||||
except Exception:
|
||||
# Can't introspect — assume spawnable, preserve legacy behavior.
|
||||
return True
|
||||
for row in rows:
|
||||
if profile_exists(row["assignee"]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def dispatch_once(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
|
|
@ -2506,6 +2907,29 @@ def dispatch_once(
|
|||
if not row["assignee"]:
|
||||
result.skipped_unassigned.append(row["id"])
|
||||
continue
|
||||
# Skip ready tasks whose assignee is not a real Hermes profile.
|
||||
# `_default_spawn` invokes ``hermes -p <assignee>`` which fails
|
||||
# with "Profile 'X' does not exist" when the assignee names a
|
||||
# control-plane lane (e.g. an interactive Claude Code terminal
|
||||
# like ``orion-cc`` / ``orion-research``) rather than a Hermes
|
||||
# profile. Those task lanes are pulled by terminals via
|
||||
# ``claim_task`` directly and should NEVER auto-spawn — the
|
||||
# subprocess would crash on startup, get reaped as a zombie,
|
||||
# the task would loop back to ``ready`` on next tick, and we'd
|
||||
# burn CPU forever (#kanban-dispatcher-crash-loop 2026-05-05).
|
||||
try:
|
||||
from hermes_cli.profiles import profile_exists # local import: avoids cycle
|
||||
except Exception:
|
||||
profile_exists = None # type: ignore[assignment]
|
||||
if profile_exists is not None and not profile_exists(row["assignee"]):
|
||||
# Bucket separately from skipped_unassigned: the operator
|
||||
# cannot fix this by assigning a profile (the assignee IS the
|
||||
# intended owner — a terminal lane). Health telemetry uses
|
||||
# this distinction to suppress spurious "stuck" warnings on
|
||||
# multi-lane setups where the ready queue is steadily full
|
||||
# of human-pulled work.
|
||||
result.skipped_nonspawnable.append(row["id"])
|
||||
continue
|
||||
if dry_run:
|
||||
result.spawned.append((row["id"], row["assignee"], ""))
|
||||
continue
|
||||
|
|
@ -3213,30 +3637,38 @@ def read_worker_log(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_profiles_on_disk() -> list[str]:
|
||||
"""Return the set of named profiles discovered on disk.
|
||||
"""Return the set of assignee/profile names discovered on disk.
|
||||
|
||||
Reads ``~/.hermes/profiles/`` directly so this module has no import
|
||||
dependency on ``hermes_cli.profiles`` (which pulls in a large chunk
|
||||
of the CLI startup path). Only returns directories that contain a
|
||||
``config.yaml`` — a bare dir without config isn't a real profile.
|
||||
Includes:
|
||||
- named profiles under ``<default-root>/profiles/<name>/config.yaml``
|
||||
- the implicit ``default`` profile when the default Hermes root exists
|
||||
|
||||
Reads profile paths directly so this module has no import dependency on
|
||||
``hermes_cli.profiles`` (which pulls in a large chunk of the CLI startup
|
||||
path).
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
home = get_default_hermes_root() / "profiles"
|
||||
default_root = get_default_hermes_root()
|
||||
profiles_dir = default_root / "profiles"
|
||||
except Exception:
|
||||
return []
|
||||
if not home.is_dir():
|
||||
return []
|
||||
names: list[str] = []
|
||||
try:
|
||||
for entry in sorted(home.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if (entry / "config.yaml").is_file():
|
||||
names.append(entry.name)
|
||||
except OSError:
|
||||
return names
|
||||
return names
|
||||
|
||||
names: set[str] = set()
|
||||
if default_root.exists():
|
||||
names.add("default")
|
||||
|
||||
if profiles_dir.is_dir():
|
||||
try:
|
||||
for entry in sorted(profiles_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if (entry / "config.yaml").is_file():
|
||||
names.add(entry.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def known_assignees(conn: sqlite3.Connection) -> list[dict]:
|
||||
|
|
|
|||
|
|
@ -1216,6 +1216,26 @@ def _launch_tui(
|
|||
sys.exit(code)
|
||||
|
||||
|
||||
def _pin_kanban_board_env() -> None:
|
||||
"""Pin the active kanban board into ``HERMES_KANBAN_BOARD`` for the chat session.
|
||||
|
||||
Without this, in-process tools (``kanban_*``) and shelled-out CLI calls
|
||||
(``hermes kanban …``) resolve the board on different paths: the env-pin if
|
||||
set, otherwise the global ``<root>/kanban/current`` file. A concurrent
|
||||
``hermes kanban boards switch`` from another session can flip the file
|
||||
mid-turn, so the same chat sees its tool calls hit board A while its shell
|
||||
calls hit board B (#20074). Pinning at chat boot mirrors what the
|
||||
dispatcher already does for spawned workers.
|
||||
"""
|
||||
if os.environ.get("HERMES_KANBAN_BOARD"):
|
||||
return
|
||||
try:
|
||||
from hermes_cli.kanban_db import get_current_board
|
||||
os.environ["HERMES_KANBAN_BOARD"] = get_current_board()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cmd_chat(args):
|
||||
"""Run interactive chat CLI."""
|
||||
use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"
|
||||
|
|
@ -1324,6 +1344,8 @@ def cmd_chat(args):
|
|||
if getattr(args, "source", None):
|
||||
os.environ["HERMES_SESSION_SOURCE"] = args.source
|
||||
|
||||
_pin_kanban_board_env()
|
||||
|
||||
if use_tui:
|
||||
_launch_tui(
|
||||
getattr(args, "resume", None),
|
||||
|
|
@ -3974,6 +3996,85 @@ def _model_flow_copilot_acp(config, current_model=""):
|
|||
print(f"Default model set to: {selected} (via {pconfig.name})")
|
||||
|
||||
|
||||
def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple:
|
||||
"""Shared API-key entry point for ``hermes setup`` / ``hermes model``.
|
||||
|
||||
Handles both first-time entry and the already-configured case. When a key
|
||||
is already present, offers [K]eep / [R]eplace / [C]lear so the user can
|
||||
recover from a malformed paste without editing ``~/.hermes/.env`` by hand.
|
||||
|
||||
Returns ``(resolved_key, abort)``. ``abort=True`` means the caller should
|
||||
``return`` immediately — the user cancelled entry, declined to replace, or
|
||||
cleared the key and is now unconfigured.
|
||||
"""
|
||||
import getpass
|
||||
|
||||
from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
|
||||
from hermes_cli.config import save_env_value
|
||||
|
||||
key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
|
||||
|
||||
def _prompt_new_key(*, allow_lmstudio_default: bool) -> str:
|
||||
if provider_id == "lmstudio" and allow_lmstudio_default:
|
||||
prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
|
||||
else:
|
||||
prompt = f"{key_env} (or Enter to cancel): "
|
||||
try:
|
||||
entered = getpass.getpass(prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return ""
|
||||
if not entered and provider_id == "lmstudio" and allow_lmstudio_default:
|
||||
return LMSTUDIO_NOAUTH_PLACEHOLDER
|
||||
return entered
|
||||
|
||||
# First-time entry ────────────────────────────────────────────────────
|
||||
if not existing_key:
|
||||
print(f"No {pconfig.name} API key configured.")
|
||||
if not key_env:
|
||||
return "", True
|
||||
new_key = _prompt_new_key(allow_lmstudio_default=True)
|
||||
if not new_key:
|
||||
print("Cancelled.")
|
||||
return "", True
|
||||
save_env_value(key_env, new_key)
|
||||
print("API key saved.")
|
||||
print()
|
||||
return new_key, False
|
||||
|
||||
# Already configured — offer K / R / C ────────────────────────────────
|
||||
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
||||
if not key_env:
|
||||
# Nothing we can rewrite; just acknowledge and move on.
|
||||
print()
|
||||
return existing_key, False
|
||||
try:
|
||||
choice = input(" [K]eep / [R]eplace / [C]lear (default K): ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
choice = "k"
|
||||
|
||||
if choice.startswith("r"):
|
||||
new_key = _prompt_new_key(allow_lmstudio_default=False)
|
||||
if not new_key:
|
||||
print(" No change.")
|
||||
print()
|
||||
return existing_key, False
|
||||
save_env_value(key_env, new_key)
|
||||
print(" API key updated.")
|
||||
print()
|
||||
return new_key, False
|
||||
|
||||
if choice.startswith("c"):
|
||||
save_env_value(key_env, "")
|
||||
print(f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again.")
|
||||
return "", True
|
||||
|
||||
# Keep (default, or any other input)
|
||||
print()
|
||||
return existing_key, False
|
||||
|
||||
|
||||
def _model_flow_kimi(config, current_model=""):
|
||||
"""Kimi / Moonshot model selection with automatic endpoint routing.
|
||||
|
||||
|
|
@ -4008,26 +4109,9 @@ def _model_flow_kimi(config, current_model=""):
|
|||
if existing_key:
|
||||
break
|
||||
|
||||
if not existing_key:
|
||||
print(f"No {pconfig.name} API key configured.")
|
||||
if key_env:
|
||||
try:
|
||||
import getpass
|
||||
|
||||
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not new_key:
|
||||
print("Cancelled.")
|
||||
return
|
||||
save_env_value(key_env, new_key)
|
||||
existing_key = new_key
|
||||
print("API key saved.")
|
||||
print()
|
||||
else:
|
||||
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
||||
print()
|
||||
existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
|
||||
if abort:
|
||||
return
|
||||
|
||||
# Step 2: Auto-detect endpoint from key prefix
|
||||
is_coding_plan = existing_key.startswith("sk-kimi-")
|
||||
|
|
@ -4128,25 +4212,9 @@ def _model_flow_stepfun(config, current_model=""):
|
|||
if existing_key:
|
||||
break
|
||||
|
||||
if not existing_key:
|
||||
print(f"No {pconfig.name} API key configured.")
|
||||
if key_env:
|
||||
try:
|
||||
import getpass
|
||||
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not new_key:
|
||||
print("Cancelled.")
|
||||
return
|
||||
save_env_value(key_env, new_key)
|
||||
existing_key = new_key
|
||||
print("API key saved.")
|
||||
print()
|
||||
else:
|
||||
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
||||
print()
|
||||
existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
|
||||
if abort:
|
||||
return
|
||||
|
||||
current_base = ""
|
||||
if base_url_env:
|
||||
|
|
@ -4522,33 +4590,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
|||
if existing_key:
|
||||
break
|
||||
|
||||
if not existing_key:
|
||||
print(f"No {pconfig.name} API key configured.")
|
||||
if key_env:
|
||||
try:
|
||||
import getpass
|
||||
|
||||
if provider_id == "lmstudio":
|
||||
prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
|
||||
else:
|
||||
prompt = f"{key_env} (or Enter to cancel): "
|
||||
new_key = getpass.getpass(prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not new_key:
|
||||
if provider_id == "lmstudio":
|
||||
new_key = LMSTUDIO_NOAUTH_PLACEHOLDER
|
||||
else:
|
||||
print("Cancelled.")
|
||||
return
|
||||
save_env_value(key_env, new_key)
|
||||
existing_key = new_key
|
||||
print("API key saved.")
|
||||
print()
|
||||
else:
|
||||
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
||||
print()
|
||||
existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
|
||||
if abort:
|
||||
return
|
||||
|
||||
# Gemini free-tier gate: free-tier daily quotas (<= 250 RPD for Flash)
|
||||
# are exhausted in a handful of agent turns, so refuse to wire up the
|
||||
|
|
|
|||
|
|
@ -190,11 +190,18 @@ def _load_direct_aliases() -> dict[str, DirectAlias]:
|
|||
model: "minimax-m2.7"
|
||||
provider: custom
|
||||
base_url: "https://ollama.com/v1"
|
||||
|
||||
Also reads ``model.aliases`` (set by ``hermes config set model.aliases.xxx``)
|
||||
and converts simple string entries (``ds-flash: deepseek/deepseek-v4-flash``)
|
||||
into DirectAlias objects. The provider is parsed from the ``provider/``
|
||||
prefix in the value; if no slash, the current provider is used.
|
||||
"""
|
||||
merged = dict(_BUILTIN_DIRECT_ALIASES)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
|
||||
# --- model_aliases (dict-based format) ---
|
||||
user_aliases = cfg.get("model_aliases")
|
||||
if isinstance(user_aliases, dict):
|
||||
for name, entry in user_aliases.items():
|
||||
|
|
@ -207,6 +214,30 @@ def _load_direct_aliases() -> dict[str, DirectAlias]:
|
|||
merged[name.strip().lower()] = DirectAlias(
|
||||
model=model, provider=provider, base_url=base_url,
|
||||
)
|
||||
|
||||
# --- model.aliases (string-based format, from config set) ---
|
||||
model_section = cfg.get("model", {})
|
||||
if isinstance(model_section, dict):
|
||||
simple_aliases = model_section.get("aliases")
|
||||
if isinstance(simple_aliases, dict):
|
||||
current_provider = model_section.get("provider", "")
|
||||
for name, value in simple_aliases.items():
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
continue
|
||||
key = name.strip().lower()
|
||||
if key in merged:
|
||||
continue # don't override explicit model_aliases entries
|
||||
val = value.strip()
|
||||
if "/" in val:
|
||||
provider, model = val.split("/", 1)
|
||||
else:
|
||||
provider = current_provider
|
||||
model = val
|
||||
merged[key] = DirectAlias(
|
||||
model=model.strip(),
|
||||
provider=provider.strip() or current_provider,
|
||||
base_url="",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return merged
|
||||
|
|
@ -1652,3 +1683,59 @@ def list_authenticated_providers(
|
|||
results.sort(key=lambda r: (not r["is_current"], -r["total_models"]))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def list_picker_providers(
|
||||
current_provider: str = "",
|
||||
user_providers: dict = None,
|
||||
custom_providers: list | None = None,
|
||||
max_models: int = 8,
|
||||
) -> List[dict]:
|
||||
"""Interactive-picker variant of :func:`list_authenticated_providers`.
|
||||
|
||||
Post-processes the base list so the ``/model`` picker (Telegram/Discord
|
||||
inline keyboards) only surfaces models that are actually callable in the
|
||||
current install:
|
||||
|
||||
- OpenRouter's model list is replaced with the output of
|
||||
:func:`hermes_cli.models.fetch_openrouter_models`, which filters the
|
||||
curated ``OPENROUTER_MODELS`` snapshot against the live OpenRouter
|
||||
catalog. IDs the live catalog no longer carries drop out, so the
|
||||
picker never offers a model the user can't call.
|
||||
- Provider rows whose model list ends up empty are dropped, except
|
||||
custom endpoints (``is_user_defined=True`` with an ``api_url``) where
|
||||
the user may supply their own model set through config.
|
||||
|
||||
All other providers and metadata fields are passed through unchanged.
|
||||
The typed ``/model <name>`` path is unaffected -- only the interactive
|
||||
picker payload is narrowed.
|
||||
"""
|
||||
from hermes_cli.models import fetch_openrouter_models
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
max_models=max_models,
|
||||
)
|
||||
|
||||
filtered: List[dict] = []
|
||||
for p in providers:
|
||||
slug = str(p.get("slug", "")).lower()
|
||||
if slug == "openrouter":
|
||||
try:
|
||||
live = fetch_openrouter_models()
|
||||
live_ids = [mid for mid, _ in live]
|
||||
except Exception:
|
||||
live_ids = list(p.get("models", []))
|
||||
p = dict(p)
|
||||
p["models"] = live_ids[:max_models]
|
||||
p["total_models"] = len(live_ids)
|
||||
|
||||
has_models = bool(p.get("models"))
|
||||
is_custom_endpoint = bool(p.get("is_user_defined")) and bool(p.get("api_url"))
|
||||
if not has_models and not is_custom_endpoint:
|
||||
continue
|
||||
filtered.append(p)
|
||||
|
||||
return filtered
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import importlib.util
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import copy
|
||||
|
|
@ -208,12 +209,23 @@ def prompt(question: str, default: str = None, password: bool = False) -> str:
|
|||
else:
|
||||
value = input(color(display, Colors.YELLOW))
|
||||
|
||||
return value.strip() or default or ""
|
||||
cleaned = _sanitize_pasted_input(value)
|
||||
return cleaned.strip() or default or ""
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
_BRACKETED_PASTE_PATTERN = re.compile(r"\x1b\[\s*200~|\x1b\[\s*201~")
|
||||
|
||||
|
||||
def _sanitize_pasted_input(value: str) -> str:
|
||||
"""Strip terminal bracketed-paste control markers from pasted text."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return value
|
||||
return _BRACKETED_PASTE_PATTERN.sub("", value)
|
||||
|
||||
|
||||
def _curses_prompt_choice(question: str, choices: list, default: int = 0, description: str | None = None) -> int:
|
||||
"""Single-select menu using curses. Delegates to curses_radiolist."""
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
|
|
|
|||
|
|
@ -334,6 +334,144 @@ TIPS = [
|
|||
"MCP ${ENV_VAR} placeholders in config are resolved at server spawn — including vars from ~/.hermes/.env.",
|
||||
"Skills from trusted repos (NousResearch) get a 'trusted' security level; community skills get extra scanning.",
|
||||
"The skills quarantine at ~/.hermes/skills/.hub/quarantine/ holds skills pending security review.",
|
||||
|
||||
# --- Advanced Slash Commands ---
|
||||
'/steer <prompt> injects a note after the next tool call — nudge direction mid-task without interrupting.',
|
||||
'/goal <text> sets a standing Ralph-loop objective — Hermes auto-continues turn after turn until a judge says done.',
|
||||
'/snapshot create [label] saves a full state snapshot of Hermes config; /snapshot restore <id> reverts later.',
|
||||
'/copy [N] copies the last assistant response to your clipboard, or the Nth-from-last with a number.',
|
||||
'/redraw forces a full UI repaint, fixing terminal drift after tmux resize or mouse selection artifacts.',
|
||||
'/agents (alias /tasks) shows active agents and running background tasks across the current session.',
|
||||
'/footer toggles the gateway footer on final replies showing model, tool counts, and turn timing.',
|
||||
'/busy queue|steer|interrupt controls what pressing Enter does while Hermes is working.',
|
||||
'/topic in Telegram DMs enables user-managed multi-session topic mode — /topic <id> restores past sessions inline.',
|
||||
'/approve session|always runs a pending dangerous command with your chosen trust scope; /deny rejects it.',
|
||||
'/restart gracefully restarts the gateway after draining active runs, then pings the requester when back up.',
|
||||
'/kanban boards switch <slug> changes the active multi-project Kanban board from inside chat.',
|
||||
'/reload reloads ~/.hermes/.env into the running session — pick up new API keys without restarting.',
|
||||
|
||||
# --- Cron (no-agent & scripts) ---
|
||||
'cronjob with no_agent=True runs a script on schedule and sends its stdout directly — zero tokens, zero LLM.',
|
||||
'An empty cron script stdout means silent tick — nothing is delivered, perfect for threshold watchdogs.',
|
||||
"HERMES_CRON_MAX_PARALLEL (default 4) caps how many cron jobs run per tick so bursts don't saturate your keys.",
|
||||
|
||||
# --- Gateway Hooks ---
|
||||
'Gateway hooks live under ~/.hermes/hooks/<name>/ with HOOK.yaml + handler.py — handler must be named `handle`.',
|
||||
'Hook events include gateway:startup, session:start, agent:step, and command:* wildcard subscriptions.',
|
||||
'Drop a ~/.hermes/BOOT.md checklist and a gateway:startup hook runs it as a one-shot agent every boot.',
|
||||
|
||||
# --- Curator ---
|
||||
'hermes curator run --dry-run previews what the curator would archive or consolidate without mutating anything.',
|
||||
"hermes curator pin <skill> hard-fences a skill against both auto-archival and the agent's skill_manage tool.",
|
||||
'hermes curator rollback restores skills from a pre-run snapshot — backups live under skills/.curator_backups/.',
|
||||
|
||||
# --- Credential Pools & Routing ---
|
||||
'hermes auth reset <provider> clears all cooldowns and exhaustion flags on a credential pool.',
|
||||
'credential_pool_strategies.<provider>: round_robin cycles keys evenly instead of the fill_first default.',
|
||||
'use_gateway: true per-tool routes web, image, tts, or browser through your Nous subscription — no extra keys.',
|
||||
'provider_routing.data_collection: deny excludes data-storing providers on OpenRouter.',
|
||||
'provider_routing.require_parameters: true only routes to providers that support every param in your request.',
|
||||
|
||||
# --- TUI & Dashboard ---
|
||||
'HERMES_TUI_RESUME=1 auto-re-attaches to the most recent TUI session on launch — handy after SSH drops.',
|
||||
"HERMES_TUI_THEME=light|dark|<hex> forces the TUI theme on terminals that don't set COLORFGBG.",
|
||||
'Ctrl+G or Ctrl+X Ctrl+E in the TUI opens the input buffer in $EDITOR for long multi-line prompts.',
|
||||
'The TUI renders LaTeX inline — $E=mc^2$ becomes Unicode math instead of raw TeX.',
|
||||
'hermes dashboard launches a local web UI at 127.0.0.1:9119 — zero data leaves localhost.',
|
||||
'hermes dashboard --tui embeds the full Hermes TUI in your browser via xterm.js and a WebSocket PTY.',
|
||||
'Drop a YAML in ~/.hermes/dashboard-themes/ with two palette colors to reskin the entire dashboard.',
|
||||
'Dashboard plugins are drop-in: manifest.json + JS bundle in ~/.hermes/dashboard-plugins/ — no npm build required.',
|
||||
'layoutVariant: cockpit in a dashboard theme adds a 260px left rail that plugins can populate via the sidebar slot.',
|
||||
|
||||
# --- Env Vars & Config Gates ---
|
||||
"display.tool_progress_command: true exposes /verbose on messaging platforms; it's CLI-only by default.",
|
||||
'HERMES_BACKGROUND_NOTIFICATIONS=result only pings when background tasks finish (vs all/error/off).',
|
||||
'HERMES_WRITE_SAFE_ROOT restricts write_file and patch to a directory prefix; writes outside require approval.',
|
||||
'HERMES_IGNORE_RULES skips auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills.',
|
||||
'HERMES_ACCEPT_HOOKS auto-approves unseen shell hooks declared in config.yaml without a TTY prompt.',
|
||||
'auxiliary.goal_judge.model routes the /goal judge to a cheap fast model to keep loop cost near zero.',
|
||||
'Checkpoints skip directories with more than 50,000 files to avoid slow git operations on massive monorepos.',
|
||||
|
||||
# --- TTS ---
|
||||
'tts.provider: piper runs 44-language local TTS on CPU — voices auto-download to ~/.hermes/cache/piper-voices/.',
|
||||
'tts.providers.<name>.type: command wires any CLI TTS engine with {input_path} and {output_path} placeholders.',
|
||||
|
||||
# --- API Server & Proxy ---
|
||||
'API_SERVER_ENABLED=true runs an OpenAI-compatible endpoint alongside the gateway for Open WebUI and LibreChat.',
|
||||
'GATEWAY_PROXY_URL runs a split setup: platform I/O locally, agent work delegated to a remote API server.',
|
||||
|
||||
# --- Platform-specific ---
|
||||
'MATRIX_DEVICE_ID pins a stable device ID for E2EE — without it, keys rotate every start and historic decrypt breaks.',
|
||||
'TELEGRAM_WEBHOOK_SECRET is required whenever TELEGRAM_WEBHOOK_URL is set — generate with openssl rand -hex 32.',
|
||||
|
||||
# --- Batch ---
|
||||
"batch_runner.py --resume content-matches completed prompts by text so dataset reorders don't re-run finished work.",
|
||||
|
||||
# --- Less-Known Slash Commands ---
|
||||
'/new starts a fresh session in place (alias /reset) — fresh session ID, clean history, CLI stays open.',
|
||||
'/clear wipes the terminal screen AND starts a new session — one shortcut for a visual reset.',
|
||||
'/history prints the current conversation in-line without leaving the CLI — useful for a quick re-read.',
|
||||
'/save writes the current conversation to disk without ending the session.',
|
||||
'/status shows session info at a glance: ID, title, model, token usage, and elapsed time.',
|
||||
'/image <path> attaches a local image file for your next prompt without pasting or drag-and-drop.',
|
||||
'/platforms shows gateway and messaging-platform connection status right from inside chat.',
|
||||
'/commands paginates the full slash-command + installed-skill list — useful on platforms without tab completion.',
|
||||
'/toolsets lists every available toolset so you know what -t/--toolsets accepts.',
|
||||
'/gquota shows Google Gemini Code Assist quota usage with progress bars when that provider is active.',
|
||||
'/voice tts toggles TTS-only mode — agent replies out loud but you still type your prompts.',
|
||||
'/reload-skills re-scans ~/.hermes/skills/ so drop-in skills appear without restarting the session.',
|
||||
'/indicator kaomoji|emoji|unicode|ascii picks the TUI busy-indicator style shown during agent runs.',
|
||||
'/debug uploads a support bundle (system info + logs) and returns shareable links — works in chat too.',
|
||||
|
||||
# --- CLI Subcommands & Flags ---
|
||||
'hermes -z "<prompt>" is the purest one-shot: final answer on stdout, nothing else — ideal for piping in scripts.',
|
||||
'hermes chat --pass-session-id injects the session ID into the system prompt so the agent can self-reference it.',
|
||||
'hermes chat --image path/to/pic.png attaches a local image to a single -q query without a separate upload step.',
|
||||
'hermes chat --ignore-user-config skips ~/.hermes/config.yaml — reproducible bug reports and CI runs.',
|
||||
"hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.",
|
||||
'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.',
|
||||
'hermes sessions rename <ID> "new title" renames any past session; hermes sessions delete <ID> removes one.',
|
||||
'hermes import restores a session export or profile archive produced by sessions export or profile export.',
|
||||
'hermes fallback manages the fallback_model chain interactively — no hand-editing config.yaml.',
|
||||
'hermes pairing rotates the DM pairing token — the first messager after rotation claims access to the bot.',
|
||||
'hermes setup walks first-time users through provider, keys, and platform wiring in one interactive flow.',
|
||||
'hermes status --deep runs the full health sweep across every component; plain hermes status is the quick view.',
|
||||
|
||||
# --- Agent Behavior Env Vars ---
|
||||
'HERMES_AGENT_TIMEOUT=0 disables the gateway inactivity kill for a running agent — use for long research runs.',
|
||||
'HERMES_ENABLE_PROJECT_PLUGINS=1 auto-loads repo-local plugins from ./.hermes/plugins/ — trust-gated by design.',
|
||||
"HERMES_DISABLE_FILE_STATE_GUARD=1 turns off the 'file changed since you read it' guard on patch and write_file.",
|
||||
'HERMES_ALLOW_PRIVATE_URLS=true lets web tools hit localhost and private networks — off by default in gateway mode.',
|
||||
'HERMES_OPTIONAL_SKILLS=name1,name2 auto-installs extra optional-catalog skills on first run per profile.',
|
||||
'HERMES_BUNDLED_SKILLS points at a custom bundled-skill tree — used by Homebrew and Nix packaging.',
|
||||
'HERMES_DUMP_REQUEST_STDOUT=1 dumps every API request payload to stdout instead of log files.',
|
||||
'HERMES_OAUTH_TRACE=1 logs redacted OAuth token exchange and refresh attempts for debugging provider auth.',
|
||||
'HERMES_STREAM_RETRIES (default 3) controls mid-stream reconnect attempts on transient network errors.',
|
||||
|
||||
# --- Gateway Behavior Env Vars ---
|
||||
'HERMES_GATEWAY_BUSY_ACK_ENABLED=false silences the ⚡/⏳/⏩ ack messages when a user messages a busy agent.',
|
||||
'HERMES_AGENT_NOTIFY_INTERVAL (default 180s) sets how often the gateway pings with progress on long turns.',
|
||||
'HERMES_RESTART_DRAIN_TIMEOUT (default 900s) caps how long /restart waits for in-flight runs before forcing.',
|
||||
'HERMES_CHECKPOINT_TIMEOUT (default 30s) caps filesystem checkpoint creation — raise it on huge monorepos.',
|
||||
|
||||
# --- Auxiliary Tasks & Image Generation ---
|
||||
'image_gen.model in config.yaml picks the FAL model: flux-2/klein, gpt-image-2, nano-banana-pro, and more.',
|
||||
'image_gen.provider routes image generation through a plugin (OpenAI Images, Codex, FAL) instead of the default.',
|
||||
'AUXILIARY_VISION_BASE_URL + AUXILIARY_VISION_API_KEY point vision analysis at any OpenAI-compatible endpoint.',
|
||||
'auxiliary.session_search.max_concurrency bounds how many matched sessions are summarized in parallel (default 3).',
|
||||
'auxiliary.session_search.extra_body forwards provider-specific OpenAI-compatible fields on summarization calls.',
|
||||
|
||||
# --- Security ---
|
||||
'security.tirith_fail_open: false makes Hermes block commands when the tirith scanner itself errors out.',
|
||||
'TIRITH_FAIL_OPEN env var overrides the tirith_fail_open config — a quick toggle without editing config.yaml.',
|
||||
|
||||
# --- Sessions & Source Tags ---
|
||||
'--source tool chats are excluded from hermes sessions list by default — set --source explicitly to see them.',
|
||||
'Session IDs are timestamp-prefixed (20250305_091523_abcd) so sorting works naturally in ls and jq.',
|
||||
|
||||
# --- Misc ---
|
||||
'API_SERVER_MODEL_NAME customizes the model name on /v1/models — essential for multi-profile Open WebUI setups.',
|
||||
'Dashboard plugins are served from /dashboard-plugins/<name>/ — drop files into ~/.hermes/dashboard-plugins/.',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
24
locales/de.yaml
Normal file
24
locales/de.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Hermes-Katalog für statische Meldungen -- Deutsch
|
||||
# See locales/en.yaml for the source of truth; keep keys in sync.
|
||||
|
||||
approval:
|
||||
dangerous_header: "⚠️ GEFÄHRLICHER BEFEHL: {description}"
|
||||
choose_long: " [o]einmal | [s]sitzung | [a]immer | [d]ablehnen"
|
||||
choose_short: " [o]einmal | [s]sitzung | [d]ablehnen"
|
||||
prompt_long: " Auswahl [o/s/a/D]: "
|
||||
prompt_short: " Auswahl [o/s/D]: "
|
||||
timeout: " ⏱ Zeitüberschreitung – Befehl wird abgelehnt"
|
||||
allowed_once: " ✓ Einmalig erlaubt"
|
||||
allowed_session: " ✓ Für diese Sitzung erlaubt"
|
||||
allowed_always: " ✓ Zur dauerhaften Erlaubnisliste hinzugefügt"
|
||||
denied: " ✗ Abgelehnt"
|
||||
cancelled: " ✗ Abgebrochen"
|
||||
blocklist_message: "Dieser Befehl steht auf der unbedingten Sperrliste und kann nicht genehmigt werden."
|
||||
|
||||
gateway:
|
||||
approval_expired: "⚠️ Genehmigung abgelaufen (Agent wartet nicht mehr). Bitten Sie den Agenten, es erneut zu versuchen."
|
||||
draining: "⏳ Warte auf {count} aktive(n) Agent(en) vor dem Neustart..."
|
||||
goal_cleared: "✓ Ziel gelöscht."
|
||||
no_active_goal: "Kein aktives Ziel."
|
||||
config_read_failed: "⚠️ config.yaml konnte nicht gelesen werden: {error}"
|
||||
config_save_failed: "⚠️ Konfiguration konnte nicht gespeichert werden: {error}"
|
||||
35
locales/en.yaml
Normal file
35
locales/en.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Hermes static-message catalog -- English (baseline / source of truth)
|
||||
#
|
||||
# Only user-facing static messages from the CLI approval prompt and a handful
|
||||
# of gateway slash-command replies live here. Agent-generated output, log
|
||||
# lines, error tracebacks, tool outputs, and slash-command descriptions stay
|
||||
# in English and are NOT translated -- see agent/i18n.py for scope rationale.
|
||||
#
|
||||
# Keys are dotted paths; nesting below is purely for readability. Values may
|
||||
# contain {placeholder} tokens for str.format substitution. When adding a
|
||||
# new key, add it to EVERY locale file (en/zh/ja/de/es) in the same commit --
|
||||
# tests/agent/test_i18n.py asserts catalog parity.
|
||||
|
||||
approval:
|
||||
# CLI approval prompt -- shown when a dangerous command needs user review.
|
||||
dangerous_header: "⚠️ DANGEROUS COMMAND: {description}"
|
||||
choose_long: " [o]nce | [s]ession | [a]lways | [d]eny"
|
||||
choose_short: " [o]nce | [s]ession | [d]eny"
|
||||
prompt_long: " Choice [o/s/a/D]: "
|
||||
prompt_short: " Choice [o/s/D]: "
|
||||
timeout: " ⏱ Timeout - denying command"
|
||||
allowed_once: " ✓ Allowed once"
|
||||
allowed_session: " ✓ Allowed for this session"
|
||||
allowed_always: " ✓ Added to permanent allowlist"
|
||||
denied: " ✗ Denied"
|
||||
cancelled: " ✗ Cancelled"
|
||||
blocklist_message: "This command is on the unconditional blocklist and cannot be approved."
|
||||
|
||||
gateway:
|
||||
# Messenger replies to slash commands and implicit state changes.
|
||||
approval_expired: "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again."
|
||||
draining: "⏳ Draining {count} active agent(s) before restart..."
|
||||
goal_cleared: "✓ Goal cleared."
|
||||
no_active_goal: "No active goal."
|
||||
config_read_failed: "⚠️ Could not read config.yaml: {error}"
|
||||
config_save_failed: "⚠️ Could not save config: {error}"
|
||||
24
locales/es.yaml
Normal file
24
locales/es.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Catálogo de mensajes estáticos de Hermes -- Español
|
||||
# See locales/en.yaml for the source of truth; keep keys in sync.
|
||||
|
||||
approval:
|
||||
dangerous_header: "⚠️ COMANDO PELIGROSO: {description}"
|
||||
choose_long: " [o]una vez | [s]sesión | [a]siempre | [d]denegar"
|
||||
choose_short: " [o]una vez | [s]sesión | [d]denegar"
|
||||
prompt_long: " Opción [o/s/a/D]: "
|
||||
prompt_short: " Opción [o/s/D]: "
|
||||
timeout: " ⏱ Tiempo agotado — comando denegado"
|
||||
allowed_once: " ✓ Permitido una vez"
|
||||
allowed_session: " ✓ Permitido en esta sesión"
|
||||
allowed_always: " ✓ Añadido a la lista de permitidos permanente"
|
||||
denied: " ✗ Denegado"
|
||||
cancelled: " ✗ Cancelado"
|
||||
blocklist_message: "Este comando está en la lista de bloqueo incondicional y no se puede aprobar."
|
||||
|
||||
gateway:
|
||||
approval_expired: "⚠️ La aprobación ha caducado (el agente ya no está esperando). Pida al agente que lo intente de nuevo."
|
||||
draining: "⏳ Esperando a que terminen {count} agente(s) activo(s) antes de reiniciar..."
|
||||
goal_cleared: "✓ Objetivo eliminado."
|
||||
no_active_goal: "No hay objetivo activo."
|
||||
config_read_failed: "⚠️ No se pudo leer config.yaml: {error}"
|
||||
config_save_failed: "⚠️ No se pudo guardar la configuración: {error}"
|
||||
24
locales/ja.yaml
Normal file
24
locales/ja.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Hermes 静的メッセージカタログ -- 日本語
|
||||
# See locales/en.yaml for the source of truth; keep keys in sync.
|
||||
|
||||
approval:
|
||||
dangerous_header: "⚠️ 危険なコマンド: {description}"
|
||||
choose_long: " [o]今回のみ | [s]セッション中 | [a]常に許可 | [d]拒否"
|
||||
choose_short: " [o]今回のみ | [s]セッション中 | [d]拒否"
|
||||
prompt_long: " 選択 [o/s/a/D]: "
|
||||
prompt_short: " 選択 [o/s/D]: "
|
||||
timeout: " ⏱ タイムアウト — コマンドを拒否しました"
|
||||
allowed_once: " ✓ 今回のみ許可"
|
||||
allowed_session: " ✓ このセッション中は許可"
|
||||
allowed_always: " ✓ 永続的な許可リストに追加"
|
||||
denied: " ✗ 拒否しました"
|
||||
cancelled: " ✗ キャンセルしました"
|
||||
blocklist_message: "このコマンドは無条件ブロックリストに含まれており、承認できません。"
|
||||
|
||||
gateway:
|
||||
approval_expired: "⚠️ 承認の有効期限が切れました(エージェントはもう待機していません)。エージェントに再試行を依頼してください。"
|
||||
draining: "⏳ 再起動前に {count} 個のアクティブエージェントの終了を待っています..."
|
||||
goal_cleared: "✓ 目標をクリアしました。"
|
||||
no_active_goal: "アクティブな目標はありません。"
|
||||
config_read_failed: "⚠️ config.yaml を読み込めませんでした: {error}"
|
||||
config_save_failed: "⚠️ 設定を保存できませんでした: {error}"
|
||||
24
locales/zh.yaml
Normal file
24
locales/zh.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Hermes 静态消息目录 -- 中文(简体)
|
||||
# See locales/en.yaml for the source of truth; keep keys in sync.
|
||||
|
||||
approval:
|
||||
dangerous_header: "⚠️ 危险命令: {description}"
|
||||
choose_long: " [o]仅此一次 | [s]本次会话 | [a]永久允许 | [d]拒绝"
|
||||
choose_short: " [o]仅此一次 | [s]本次会话 | [d]拒绝"
|
||||
prompt_long: " 选择 [o/s/a/D]: "
|
||||
prompt_short: " 选择 [o/s/D]: "
|
||||
timeout: " ⏱ 超时 — 已拒绝命令"
|
||||
allowed_once: " ✓ 本次允许"
|
||||
allowed_session: " ✓ 本次会话内允许"
|
||||
allowed_always: " ✓ 已加入永久允许列表"
|
||||
denied: " ✗ 已拒绝"
|
||||
cancelled: " ✗ 已取消"
|
||||
blocklist_message: "此命令位于无条件拦截列表中,无法被批准。"
|
||||
|
||||
gateway:
|
||||
approval_expired: "⚠️ 批准已过期(代理不再等待)。请让代理重试。"
|
||||
draining: "⏳ 正在等待 {count} 个活跃代理结束后重启..."
|
||||
goal_cleared: "✓ 目标已清除。"
|
||||
no_active_goal: "当前没有活跃的目标。"
|
||||
config_read_failed: "⚠️ 无法读取 config.yaml:{error}"
|
||||
config_save_failed: "⚠️ 无法保存配置:{error}"
|
||||
55
nix/lib.nix
55
nix/lib.nix
|
|
@ -163,35 +163,42 @@
|
|||
for entry in "''${ENTRIES[@]}"; do
|
||||
IFS=":" read -r ATTR FOLDER NIX_FILE <<< "$entry"
|
||||
echo "==> .#$ATTR ($FOLDER -> $NIX_FILE)"
|
||||
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1)
|
||||
STATUS=$?
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
|
||||
# Compute the actual hash from the lockfile directly using
|
||||
# prefetch-npm-deps. This avoids false "ok" from nix build when
|
||||
# an old derivation is cached in a substituter (cachix/cache.nixos.org).
|
||||
LOCK_FILE="$FOLDER/package-lock.json"
|
||||
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
|
||||
if [ -z "$NEW_HASH" ]; then
|
||||
echo " prefetch-npm-deps failed, falling back to nix build" >&2
|
||||
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1)
|
||||
STATUS=$?
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
echo " ok (via nix build)"
|
||||
continue
|
||||
fi
|
||||
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
|
||||
if [ -z "$NEW_HASH" ]; then
|
||||
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
|
||||
echo " skipped (transient cache failure — see primary nix build for real status)" >&2
|
||||
echo "$OUTPUT" | tail -8 >&2
|
||||
continue
|
||||
fi
|
||||
echo " build failed with no hash mismatch:" >&2
|
||||
echo "$OUTPUT" | tail -40 >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \
|
||||
| sed -E 's/hash = "(.*)"/\1/')
|
||||
|
||||
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
|
||||
echo " ok"
|
||||
continue
|
||||
fi
|
||||
|
||||
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
|
||||
if [ -z "$NEW_HASH" ]; then
|
||||
# Magic-Nix-Cache occasionally returns HTTP 418 / cache-throttled
|
||||
# mid-run; nix then prints "outputs … not valid, so checking is
|
||||
# not possible" without a `got:` line. That's an infrastructure
|
||||
# blip, not a stale lockfile — warn + skip rather than failing
|
||||
# the lint. A real hash mismatch would still surface in the
|
||||
# primary `.#$ATTR` build, which is a separate CI job.
|
||||
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
|
||||
echo " skipped (transient cache failure — see primary nix build for real status)" >&2
|
||||
echo "$OUTPUT" | tail -8 >&2
|
||||
continue
|
||||
fi
|
||||
echo " build failed with no hash mismatch:" >&2
|
||||
echo "$OUTPUT" | tail -40 >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HASH_LINE=$(grep -n 'hash = "sha256-' "$NIX_FILE" | head -1 | cut -d: -f1)
|
||||
OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \
|
||||
| sed -E 's/hash = "(.*)"/\1/')
|
||||
LOCK_FILE="$FOLDER/package-lock.json"
|
||||
echo " stale: $NIX_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
|
||||
STALE=1
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ let
|
|||
src = ../ui-tui;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-a/HGI9OgVcTnZrMXA7xFMGnFoVxyHe95fulVz+WNYB0=";
|
||||
hash = "sha256-MLcLhjTF6dgdvNBtJWzo8Nh19eNh/ZitD2b07nm61Tc=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
|
|
|
|||
409
plugins/kanban/dashboard/dist/index.js
vendored
409
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -60,6 +60,51 @@
|
|||
blocked: "Mark this task as blocked? The worker's claim is released.",
|
||||
};
|
||||
|
||||
// Event kinds that indicate a hallucinated/phantom task-id reference
|
||||
// in a completion. ``completion_blocked_hallucination`` is emitted when
|
||||
// the kernel's ``created_cards`` gate rejects a completion; the task is
|
||||
// left in its prior state and the worker can retry. ``suspected_
|
||||
// hallucinated_references`` is the advisory prose-scan result — the
|
||||
// completion succeeded but the summary text references task ids that
|
||||
// do not resolve.
|
||||
const HALLUCINATION_EVENT_KINDS = [
|
||||
"completion_blocked_hallucination",
|
||||
"suspected_hallucinated_references",
|
||||
];
|
||||
const HALLUCINATION_EVENT_LABELS = {
|
||||
completion_blocked_hallucination: "Completion blocked — phantom card ids",
|
||||
suspected_hallucinated_references: "Prose referenced phantom card ids",
|
||||
};
|
||||
|
||||
function isHallucinationEvent(kind) {
|
||||
return HALLUCINATION_EVENT_KINDS.indexOf(kind) !== -1;
|
||||
}
|
||||
|
||||
function phantomIdsFromEvent(ev) {
|
||||
// Payload shapes:
|
||||
// completion_blocked_hallucination: {phantom_cards, verified_cards, summary_preview}
|
||||
// suspected_hallucinated_references: {phantom_refs, source}
|
||||
if (!ev || !ev.payload) return [];
|
||||
const p = ev.payload;
|
||||
return p.phantom_cards || p.phantom_refs || [];
|
||||
}
|
||||
|
||||
function withCompletionSummary(patch, count) {
|
||||
if (!patch || patch.status !== "done") return patch;
|
||||
const label = count && count > 1 ? `${count} selected task(s)` : "this task";
|
||||
const value = window.prompt(
|
||||
`Completion summary for ${label}. This is stored as the task result.`,
|
||||
"",
|
||||
);
|
||||
if (value === null) return null;
|
||||
const summary = value.trim();
|
||||
if (!summary) {
|
||||
window.alert("Completion summary is required before marking a task done.");
|
||||
return null;
|
||||
}
|
||||
return Object.assign({}, patch, { result: summary, summary });
|
||||
}
|
||||
|
||||
const API = "/api/plugins/kanban";
|
||||
const MIME_TASK = "text/x-hermes-task";
|
||||
|
||||
|
|
@ -480,6 +525,8 @@
|
|||
const moveTask = useCallback(function (taskId, newStatus) {
|
||||
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
const patch = withCompletionSummary({ status: newStatus }, 1);
|
||||
if (!patch) return;
|
||||
setBoardData(function (b) {
|
||||
if (!b) return b;
|
||||
let moved = null;
|
||||
|
|
@ -499,7 +546,7 @@
|
|||
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
body: JSON.stringify(patch),
|
||||
}).catch(function (err) {
|
||||
setError(`Move failed: ${err.message || err}`);
|
||||
loadBoard();
|
||||
|
|
@ -538,7 +585,9 @@
|
|||
const applyBulk = useCallback(function (patch, confirmMsg) {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
const body = Object.assign({ ids: Array.from(selectedIds) }, patch);
|
||||
const finalPatch = withCompletionSummary(patch, selectedIds.size);
|
||||
if (!finalPatch) return;
|
||||
const body = Object.assign({ ids: Array.from(selectedIds) }, finalPatch);
|
||||
SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -626,6 +675,10 @@
|
|||
return createNewBoard(payload).then(function () { setShowNewBoard(false); });
|
||||
},
|
||||
}) : null,
|
||||
h(AttentionStrip, {
|
||||
boardData,
|
||||
onOpen: setSelectedTaskId,
|
||||
}),
|
||||
h(BoardToolbar, {
|
||||
board: boardData,
|
||||
tenantFilter, setTenantFilter,
|
||||
|
|
@ -664,12 +717,303 @@
|
|||
onRefresh: loadBoard,
|
||||
renderMarkdown: renderMd,
|
||||
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
|
||||
assignees: (boardData && boardData.assignees) || [],
|
||||
eventTick: taskEventTick[selectedTaskId] || 0,
|
||||
}) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Attention strip — surfaces tasks with active hallucination warnings.
|
||||
// Renders a collapsed bar just below the board switcher; clicking expands
|
||||
// a list of affected tasks with an "Open" button each. Dismissible per
|
||||
// session via state flag; tasks re-appear on page reload if they still
|
||||
// have warnings.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function collectWarningTasks(boardData) {
|
||||
if (!boardData || !boardData.columns) return [];
|
||||
const out = [];
|
||||
for (const col of boardData.columns) {
|
||||
for (const t of col.tasks || []) {
|
||||
if (t.warnings && t.warnings.count > 0) out.push(t);
|
||||
}
|
||||
}
|
||||
// Sort: most recent warning first.
|
||||
out.sort(function (a, b) {
|
||||
return (b.warnings.latest_at || 0) - (a.warnings.latest_at || 0);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function AttentionStrip(props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const warnTasks = useMemo(
|
||||
function () { return collectWarningTasks(props.boardData); },
|
||||
[props.boardData]
|
||||
);
|
||||
if (dismissed || warnTasks.length === 0) return null;
|
||||
return h("div", { className: "hermes-kanban-attention" },
|
||||
h("div", { className: "hermes-kanban-attention-bar" },
|
||||
h("span", { className: "hermes-kanban-attention-icon" }, "⚠"),
|
||||
h("span", { className: "hermes-kanban-attention-text" },
|
||||
warnTasks.length === 1
|
||||
? "1 task with hallucination warnings"
|
||||
: `${warnTasks.length} tasks with hallucination warnings`,
|
||||
),
|
||||
h("button", {
|
||||
className: "hermes-kanban-attention-toggle",
|
||||
onClick: function () { setExpanded(function (x) { return !x; }); },
|
||||
type: "button",
|
||||
}, expanded ? "Hide" : "Show"),
|
||||
h("button", {
|
||||
className: "hermes-kanban-attention-dismiss",
|
||||
onClick: function () { setDismissed(true); },
|
||||
title: "Hide until next page reload",
|
||||
type: "button",
|
||||
}, "✕"),
|
||||
),
|
||||
expanded
|
||||
? h("div", { className: "hermes-kanban-attention-list" },
|
||||
warnTasks.map(function (t) {
|
||||
return h("div", { key: t.id, className: "hermes-kanban-attention-row" },
|
||||
h("span", { className: "hermes-kanban-attention-row-id" }, t.id),
|
||||
h("span", { className: "hermes-kanban-attention-row-title" },
|
||||
t.title || "(untitled)"),
|
||||
h("span", { className: "hermes-kanban-attention-row-meta" },
|
||||
t.assignee ? "@" + t.assignee : "unassigned",
|
||||
" · ",
|
||||
`${t.warnings.count} event${t.warnings.count === 1 ? "" : "s"}`,
|
||||
),
|
||||
h("button", {
|
||||
className: "hermes-kanban-attention-row-btn",
|
||||
onClick: function () { props.onOpen(t.id); },
|
||||
type: "button",
|
||||
}, "Open"),
|
||||
);
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Recovery popover — operator actions for a task flagged with
|
||||
// hallucination warnings. Three primary actions:
|
||||
// 1. Reclaim — release a running worker's claim; task back to ready.
|
||||
// 2. Reassign — switch the task to a different profile (with optional
|
||||
// reclaim-first toggle for currently-running tasks).
|
||||
// 3. Edit profile — copy the CLI hint for `hermes -p <name> model`
|
||||
// (the dashboard can't edit profile config from the
|
||||
// browser; it lives on the filesystem).
|
||||
// Rendered from inside TaskDetail via a toggle button.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function RecoveryPopover(props) {
|
||||
const t = props.task;
|
||||
const board = props.boardSlug;
|
||||
const assignees = props.assignees || [];
|
||||
const [reason, setReason] = useState("");
|
||||
const [newProfile, setNewProfile] = useState(t.assignee || "");
|
||||
const [reclaimFirst, setReclaimFirst] = useState(t.status === "running");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const act = function (kind) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
const urlBase = `${API}/tasks/${encodeURIComponent(t.id)}`;
|
||||
const url = kind === "reclaim"
|
||||
? withBoard(`${urlBase}/reclaim`, board)
|
||||
: withBoard(`${urlBase}/reassign`, board);
|
||||
const body = kind === "reclaim"
|
||||
? { reason: reason || null }
|
||||
: {
|
||||
profile: newProfile || null,
|
||||
reclaim_first: !!reclaimFirst,
|
||||
reason: reason || null,
|
||||
};
|
||||
SDK.fetchJSON(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).then(function () {
|
||||
setMsg({ ok: true, text:
|
||||
kind === "reclaim"
|
||||
? `Reclaimed ${t.id}. Task back to ready.`
|
||||
: `Reassigned ${t.id} to ${newProfile || "(unassigned)"}.`
|
||||
});
|
||||
if (props.onActionComplete) props.onActionComplete(kind);
|
||||
}).catch(function (err) {
|
||||
setMsg({ ok: false, text: `Failed: ${err.message || err}` });
|
||||
}).then(function () {
|
||||
setBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
const profileCmd = `hermes -p ${t.assignee || "<profile>"} model`;
|
||||
const copyCmd = function () {
|
||||
try {
|
||||
navigator.clipboard.writeText(profileCmd).then(function () {
|
||||
setCopied(true);
|
||||
setTimeout(function () { setCopied(false); }, 2000);
|
||||
});
|
||||
} catch (_) {
|
||||
window.prompt("Copy this command:", profileCmd);
|
||||
}
|
||||
};
|
||||
|
||||
return h("div", { className: "hermes-kanban-recovery" },
|
||||
h("div", { className: "hermes-kanban-recovery-title" },
|
||||
"Recovery actions"),
|
||||
h("div", { className: "hermes-kanban-recovery-hint" },
|
||||
"Use these when a worker is stuck (crash loop, repeated hallucination, ",
|
||||
"broken model). Events in this task's history are preserved as audit trail."),
|
||||
|
||||
// Reason input (shared across actions)
|
||||
h("div", { className: "hermes-kanban-recovery-section" },
|
||||
h("label", { className: "hermes-kanban-recovery-label" },
|
||||
"Reason (optional, logged on event)"),
|
||||
h("input", {
|
||||
type: "text",
|
||||
className: "hermes-kanban-recovery-input",
|
||||
value: reason,
|
||||
onChange: function (e) { setReason(e.target.value); },
|
||||
placeholder: "e.g. model hallucinating, switching to larger",
|
||||
}),
|
||||
),
|
||||
|
||||
// Action 1: Reclaim
|
||||
h("div", { className: "hermes-kanban-recovery-section" },
|
||||
h("div", { className: "hermes-kanban-recovery-action-row" },
|
||||
h("div", { className: "hermes-kanban-recovery-action-label" },
|
||||
"1. Reclaim"),
|
||||
h("div", { className: "hermes-kanban-recovery-action-desc" },
|
||||
t.status === "running"
|
||||
? "Abort the running worker and reset to ready."
|
||||
: "Task is not running — nothing to reclaim."),
|
||||
h("button", {
|
||||
className: "hermes-kanban-recovery-btn",
|
||||
disabled: busy || t.status !== "running",
|
||||
onClick: function () { act("reclaim"); },
|
||||
type: "button",
|
||||
}, "Reclaim"),
|
||||
),
|
||||
),
|
||||
|
||||
// Action 2: Reassign
|
||||
h("div", { className: "hermes-kanban-recovery-section" },
|
||||
h("div", { className: "hermes-kanban-recovery-action-row" },
|
||||
h("div", { className: "hermes-kanban-recovery-action-label" },
|
||||
"2. Reassign"),
|
||||
h("div", { className: "hermes-kanban-recovery-action-desc" },
|
||||
"Switch to a different worker profile and retry."),
|
||||
),
|
||||
h("div", { className: "hermes-kanban-recovery-reassign-row" },
|
||||
h("select", {
|
||||
className: "hermes-kanban-recovery-select",
|
||||
value: newProfile,
|
||||
onChange: function (e) { setNewProfile(e.target.value); },
|
||||
},
|
||||
h("option", { value: "" }, "(unassigned)"),
|
||||
assignees.map(function (a) {
|
||||
return h("option", { key: a, value: a }, a);
|
||||
}),
|
||||
),
|
||||
h("label", { className: "hermes-kanban-recovery-checkbox" },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: reclaimFirst,
|
||||
onChange: function (e) { setReclaimFirst(e.target.checked); },
|
||||
}),
|
||||
" Reclaim first",
|
||||
),
|
||||
h("button", {
|
||||
className: "hermes-kanban-recovery-btn",
|
||||
disabled: busy,
|
||||
onClick: function () { act("reassign"); },
|
||||
type: "button",
|
||||
}, "Reassign"),
|
||||
),
|
||||
),
|
||||
|
||||
// Action 3: Edit profile model (CLI hint)
|
||||
h("div", { className: "hermes-kanban-recovery-section" },
|
||||
h("div", { className: "hermes-kanban-recovery-action-row" },
|
||||
h("div", { className: "hermes-kanban-recovery-action-label" },
|
||||
"3. Change profile model"),
|
||||
h("div", { className: "hermes-kanban-recovery-action-desc" },
|
||||
"Profile config lives on disk — change it from a terminal, ",
|
||||
"then use Reclaim above to retry with the new model."),
|
||||
),
|
||||
h("div", { className: "hermes-kanban-recovery-cmd-row" },
|
||||
h("code", { className: "hermes-kanban-recovery-cmd" }, profileCmd),
|
||||
h("button", {
|
||||
className: "hermes-kanban-recovery-btn",
|
||||
onClick: copyCmd,
|
||||
type: "button",
|
||||
}, copied ? "Copied" : "Copy"),
|
||||
),
|
||||
),
|
||||
|
||||
msg
|
||||
? h("div", {
|
||||
className: cn(
|
||||
"hermes-kanban-recovery-msg",
|
||||
msg.ok ? "hermes-kanban-recovery-msg--ok" : "hermes-kanban-recovery-msg--err",
|
||||
),
|
||||
}, msg.text)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
// Thin wrapper that toggles the RecoveryPopover visibility inside a
|
||||
// task drawer. Auto-opens when the task has active hallucination
|
||||
// warnings; operators can still collapse it. Always available via a
|
||||
// header button for tasks without warnings, so reclaim/reassign is
|
||||
// accessible for other stuck-worker scenarios too.
|
||||
function RecoverySection(props) {
|
||||
const [open, setOpen] = useState(!!props.hasWarnings);
|
||||
// Re-open automatically if warnings appear while the drawer is open.
|
||||
useEffect(function () {
|
||||
if (props.hasWarnings) setOpen(true);
|
||||
}, [props.hasWarnings]);
|
||||
return h("div", { className: "hermes-kanban-section" },
|
||||
h("div", { className: "hermes-kanban-section-head-row" },
|
||||
h("span", { className: "hermes-kanban-section-head" },
|
||||
props.hasWarnings
|
||||
? h("span", { className: "hermes-kanban-section-head-warning" },
|
||||
"⚠ Recovery")
|
||||
: "Recovery",
|
||||
),
|
||||
h("button", {
|
||||
className: "hermes-kanban-section-toggle",
|
||||
onClick: function () { setOpen(function (x) { return !x; }); },
|
||||
type: "button",
|
||||
}, open ? "Hide" : "Show"),
|
||||
),
|
||||
open
|
||||
? h(RecoveryPopover, {
|
||||
// Keyed by task id so React tears the popover down and
|
||||
// remounts it when the drawer swaps to a different task —
|
||||
// otherwise reason / newProfile / success toast from the
|
||||
// previous task leak into the new one.
|
||||
key: props.task.id,
|
||||
task: props.task,
|
||||
boardSlug: props.boardSlug,
|
||||
assignees: props.assignees,
|
||||
onActionComplete: function () {
|
||||
if (props.onRefresh) props.onRefresh();
|
||||
},
|
||||
})
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Board switcher (multi-project)
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -1199,6 +1543,14 @@
|
|||
title: "Select for bulk actions",
|
||||
}),
|
||||
h("span", { className: "hermes-kanban-card-id" }, t.id),
|
||||
t.warnings && t.warnings.count > 0
|
||||
? h("span", {
|
||||
className: "hermes-kanban-warning-badge",
|
||||
title: `⚠ ${t.warnings.count} hallucination ` +
|
||||
`event(s) since last clean completion. ` +
|
||||
`Click to open for details.`,
|
||||
}, "⚠")
|
||||
: null,
|
||||
t.priority > 0
|
||||
? h(Badge, { className: "hermes-kanban-priority" }, `P${t.priority}`)
|
||||
: null,
|
||||
|
|
@ -1426,10 +1778,12 @@
|
|||
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const finalPatch = withCompletionSummary(patch, 1);
|
||||
if (!finalPatch) return Promise.resolve();
|
||||
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
body: JSON.stringify(finalPatch),
|
||||
}).then(function () { load(); props.onRefresh(); });
|
||||
};
|
||||
|
||||
|
|
@ -1519,6 +1873,7 @@
|
|||
data, editing, setEditing,
|
||||
renderMarkdown: props.renderMarkdown,
|
||||
allTasks: props.allTasks,
|
||||
assignees: props.assignees || [],
|
||||
boardSlug: boardSlug,
|
||||
onPatch: doPatch,
|
||||
onAddParent: addLink,
|
||||
|
|
@ -1528,6 +1883,7 @@
|
|||
homeChannels: homeChannels,
|
||||
homeBusy: homeBusy,
|
||||
onToggleHomeSub: toggleHomeSubscription,
|
||||
onRefresh: props.onRefresh,
|
||||
}) : null,
|
||||
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
|
||||
h(Input, {
|
||||
|
|
@ -1589,6 +1945,13 @@
|
|||
t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null,
|
||||
),
|
||||
h(StatusActions, { task: t, onPatch: props.onPatch }),
|
||||
h(RecoverySection, {
|
||||
task: t,
|
||||
boardSlug: props.boardSlug,
|
||||
assignees: props.assignees,
|
||||
hasWarnings: t.warnings && t.warnings.count > 0,
|
||||
onRefresh: props.onRefresh,
|
||||
}),
|
||||
h(HomeSubsSection, {
|
||||
homeChannels: props.homeChannels || [],
|
||||
homeBusy: props.homeBusy || {},
|
||||
|
|
@ -1629,11 +1992,41 @@
|
|||
h("div", { className: "hermes-kanban-section" },
|
||||
h("div", { className: "hermes-kanban-section-head" }, `Events (${events.length})`),
|
||||
events.slice().reverse().slice(0, 20).map(function (e) {
|
||||
return h("div", { key: e.id, className: "hermes-kanban-event" },
|
||||
h("span", { className: "hermes-kanban-event-kind" }, e.kind),
|
||||
h("span", { className: "hermes-kanban-event-ago" },
|
||||
timeAgo ? timeAgo(e.created_at) : ""),
|
||||
e.payload
|
||||
const isHall = isHallucinationEvent(e.kind);
|
||||
const phantoms = isHall ? phantomIdsFromEvent(e) : [];
|
||||
return h("div", {
|
||||
key: e.id,
|
||||
className: cn(
|
||||
"hermes-kanban-event",
|
||||
isHall ? "hermes-kanban-event--hallucination" : "",
|
||||
),
|
||||
},
|
||||
isHall
|
||||
? h("div", { className: "hermes-kanban-event-header" },
|
||||
h("span", { className: "hermes-kanban-event-warning-icon" }, "⚠"),
|
||||
h("span", { className: "hermes-kanban-event-warning-label" },
|
||||
HALLUCINATION_EVENT_LABELS[e.kind] || e.kind),
|
||||
h("span", { className: "hermes-kanban-event-ago" },
|
||||
timeAgo ? timeAgo(e.created_at) : ""),
|
||||
)
|
||||
: h("div", { className: "hermes-kanban-event-header-plain" },
|
||||
h("span", { className: "hermes-kanban-event-kind" }, e.kind),
|
||||
h("span", { className: "hermes-kanban-event-ago" },
|
||||
timeAgo ? timeAgo(e.created_at) : ""),
|
||||
),
|
||||
isHall && phantoms.length > 0
|
||||
? h("div", { className: "hermes-kanban-event-phantom-row" },
|
||||
h("span", { className: "hermes-kanban-event-phantom-label" },
|
||||
"Phantom ids:"),
|
||||
phantoms.map(function (pid) {
|
||||
return h("code", {
|
||||
key: pid,
|
||||
className: "hermes-kanban-event-phantom-chip",
|
||||
}, pid);
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
e.payload && !isHall
|
||||
? h("code", { className: "hermes-kanban-event-payload" },
|
||||
JSON.stringify(e.payload))
|
||||
: null,
|
||||
|
|
|
|||
253
plugins/kanban/dashboard/dist/style.css
vendored
253
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -847,3 +847,256 @@
|
|||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Hallucination warnings: per-card badge, events callout, attention */
|
||||
/* strip, recovery popover. Orange/red palette but muted so the board */
|
||||
/* doesn't scream on every render. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.hermes-kanban-warning-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
color: #ff9e3b;
|
||||
margin-left: 0.25rem;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* Attention strip — collapsed state is a thin bar. */
|
||||
.hermes-kanban-attention {
|
||||
border: 1px solid rgba(255, 158, 59, 0.35);
|
||||
background: rgba(255, 158, 59, 0.06);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hermes-kanban-attention-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.hermes-kanban-attention-icon { color: #ff9e3b; font-size: 1rem; }
|
||||
.hermes-kanban-attention-text { flex: 1; }
|
||||
.hermes-kanban-attention-toggle,
|
||||
.hermes-kanban-attention-dismiss,
|
||||
.hermes-kanban-attention-row-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(120, 120, 140, 0.3);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hermes-kanban-attention-toggle:hover,
|
||||
.hermes-kanban-attention-dismiss:hover,
|
||||
.hermes-kanban-attention-row-btn:hover {
|
||||
background: rgba(255, 158, 59, 0.12);
|
||||
}
|
||||
.hermes-kanban-attention-list {
|
||||
border-top: 1px solid rgba(255, 158, 59, 0.2);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.hermes-kanban-attention-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.hermes-kanban-attention-row:hover {
|
||||
background: rgba(255, 158, 59, 0.08);
|
||||
}
|
||||
.hermes-kanban-attention-row-id {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground, #888);
|
||||
min-width: 7rem;
|
||||
}
|
||||
.hermes-kanban-attention-row-title {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.hermes-kanban-attention-row-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground, #888);
|
||||
}
|
||||
|
||||
/* Events tab — callout style for hallucination events. */
|
||||
.hermes-kanban-event--hallucination {
|
||||
border-left: 3px solid #ff6b6b;
|
||||
background: rgba(255, 107, 107, 0.08);
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 0.35rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.hermes-kanban-event-header,
|
||||
.hermes-kanban-event-header-plain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.hermes-kanban-event-warning-icon { color: #ff6b6b; font-size: 1rem; }
|
||||
.hermes-kanban-event-warning-label {
|
||||
color: #ff6b6b;
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.hermes-kanban-event-phantom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.3rem;
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
.hermes-kanban-event-phantom-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground, #999);
|
||||
}
|
||||
.hermes-kanban-event-phantom-chip {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
/* Recovery section header — amber accent when the task has warnings. */
|
||||
.hermes-kanban-section-head-warning { color: #ff9e3b; }
|
||||
.hermes-kanban-section-head-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.hermes-kanban-section-toggle {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(120, 120, 140, 0.3);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Recovery popover body. */
|
||||
.hermes-kanban-recovery {
|
||||
border: 1px solid rgba(120, 120, 140, 0.25);
|
||||
background: rgba(255, 158, 59, 0.04);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.hermes-kanban-recovery-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.hermes-kanban-recovery-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground, #888);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.hermes-kanban-recovery-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.hermes-kanban-recovery-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground, #888);
|
||||
}
|
||||
.hermes-kanban-recovery-input,
|
||||
.hermes-kanban-recovery-select {
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.8125rem;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(120, 120, 140, 0.3);
|
||||
border-radius: 0.3rem;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.hermes-kanban-recovery-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hermes-kanban-recovery-action-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
min-width: 8rem;
|
||||
}
|
||||
.hermes-kanban-recovery-action-desc {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground, #888);
|
||||
}
|
||||
.hermes-kanban-recovery-btn {
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(255, 158, 59, 0.15);
|
||||
border: 1px solid rgba(255, 158, 59, 0.4);
|
||||
border-radius: 0.3rem;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hermes-kanban-recovery-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 158, 59, 0.25);
|
||||
}
|
||||
.hermes-kanban-recovery-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.hermes-kanban-recovery-reassign-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hermes-kanban-recovery-checkbox {
|
||||
font-size: 0.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.hermes-kanban-recovery-cmd-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hermes-kanban-recovery-cmd {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(120, 120, 140, 0.3);
|
||||
border-radius: 0.3rem;
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hermes-kanban-recovery-msg {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.hermes-kanban-recovery-msg--ok {
|
||||
background: rgba(120, 200, 120, 0.12);
|
||||
color: #6bc46b;
|
||||
border: 1px solid rgba(120, 200, 120, 0.3);
|
||||
}
|
||||
.hermes-kanban-recovery-msg--err {
|
||||
background: rgba(255, 107, 107, 0.12);
|
||||
color: #ff8b8b;
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,6 +176,74 @@ def _run_dict(r: kanban_db.Run) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
# Hallucination-warning event kinds — see complete_task() in kanban_db.py.
|
||||
# completion_blocked_hallucination: kernel rejected created_cards with
|
||||
# phantom ids; task stays in prior state.
|
||||
# suspected_hallucinated_references: prose scan found t_<hex> in summary
|
||||
# that doesn't resolve; completion succeeded, advisory only.
|
||||
_WARNING_EVENT_KINDS = (
|
||||
"completion_blocked_hallucination",
|
||||
"suspected_hallucinated_references",
|
||||
)
|
||||
|
||||
|
||||
def _compute_warnings_for_tasks(
|
||||
conn: sqlite3.Connection,
|
||||
task_ids: Optional[list[str]] = None,
|
||||
) -> dict[str, dict]:
|
||||
"""Return {task_id: {count, kinds, latest_at}} for tasks with
|
||||
hallucination warnings that occurred AFTER the most recent clean
|
||||
completion event (completed / edited). An empty dict means no tasks
|
||||
on the board have active warnings.
|
||||
|
||||
``task_ids`` narrows the query; pass ``None`` to scan the whole DB
|
||||
(matches board-level rollup). Used by both the /board aggregate and
|
||||
per-task /tasks/:id endpoints.
|
||||
"""
|
||||
params: tuple = ()
|
||||
if task_ids is not None:
|
||||
if not task_ids:
|
||||
return {}
|
||||
placeholders = ",".join(["?"] * len(task_ids))
|
||||
sql = (
|
||||
"SELECT task_id, kind, created_at FROM task_events "
|
||||
f"WHERE task_id IN ({placeholders}) AND kind IN "
|
||||
"('completion_blocked_hallucination', "
|
||||
" 'suspected_hallucinated_references', "
|
||||
" 'completed', 'edited') "
|
||||
"ORDER BY task_id, id"
|
||||
)
|
||||
params = tuple(task_ids)
|
||||
else:
|
||||
sql = (
|
||||
"SELECT task_id, kind, created_at FROM task_events "
|
||||
"WHERE kind IN "
|
||||
"('completion_blocked_hallucination', "
|
||||
" 'suspected_hallucinated_references', "
|
||||
" 'completed', 'edited') "
|
||||
"ORDER BY task_id, id"
|
||||
)
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
for row in conn.execute(sql, params).fetchall():
|
||||
tid = row["task_id"]
|
||||
kind = row["kind"]
|
||||
created_at = row["created_at"]
|
||||
if kind in ("completed", "edited"):
|
||||
# Clean event wipes prior warning counters; only events after
|
||||
# this timestamp count.
|
||||
out.pop(tid, None)
|
||||
continue
|
||||
bucket = out.setdefault(
|
||||
tid, {"count": 0, "kinds": {}, "latest_at": 0}
|
||||
)
|
||||
bucket["count"] += 1
|
||||
bucket["kinds"][kind] = bucket["kinds"].get(kind, 0) + 1
|
||||
if created_at > bucket["latest_at"]:
|
||||
bucket["latest_at"] = created_at
|
||||
return out
|
||||
|
||||
|
||||
def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]:
|
||||
"""Return {'parents': [...], 'children': [...]} for a task."""
|
||||
parents = [
|
||||
|
|
@ -253,6 +321,11 @@ def get_board(
|
|||
if row["cstatus"] == "done":
|
||||
p["done"] += 1
|
||||
|
||||
# Hallucination-warning rollup for this board (all tasks).
|
||||
# Delegated to _compute_warnings_for_tasks so the per-task
|
||||
# /tasks/:id endpoint can reuse the same rule.
|
||||
warnings_per_task = _compute_warnings_for_tasks(conn, task_ids=None)
|
||||
|
||||
latest_event_id = conn.execute(
|
||||
"SELECT COALESCE(MAX(id), 0) AS m FROM task_events"
|
||||
).fetchone()["m"]
|
||||
|
|
@ -266,6 +339,9 @@ def get_board(
|
|||
d["link_counts"] = link_counts.get(t.id, {"parents": 0, "children": 0})
|
||||
d["comment_count"] = comment_counts.get(t.id, 0)
|
||||
d["progress"] = progress.get(t.id) # None when the task has no children
|
||||
w = warnings_per_task.get(t.id)
|
||||
if w:
|
||||
d["warnings"] = w
|
||||
col = t.status if t.status in columns else "todo"
|
||||
columns[col].append(d)
|
||||
|
||||
|
|
@ -313,8 +389,14 @@ def get_task(task_id: str, board: Optional[str] = Query(None)):
|
|||
task = kanban_db.get_task(conn, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
task_d = _task_dict(task)
|
||||
# Attach warnings metadata so the drawer's Recovery section can
|
||||
# auto-open when a hallucination is unresolved.
|
||||
warnings = _compute_warnings_for_tasks(conn, task_ids=[task_id])
|
||||
if warnings.get(task_id):
|
||||
task_d["warnings"] = warnings[task_id]
|
||||
return {
|
||||
"task": _task_dict(task),
|
||||
"task": task_d,
|
||||
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
|
||||
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
|
||||
"links": _links_for(conn, task_id),
|
||||
|
|
@ -630,6 +712,9 @@ class BulkTaskBody(BaseModel):
|
|||
assignee: Optional[str] = None # "" or None = unassign
|
||||
priority: Optional[int] = None
|
||||
archive: bool = False
|
||||
result: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
|
||||
@router.post("/tasks/bulk")
|
||||
|
|
@ -660,7 +745,12 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
|
|||
if payload.status is not None and not payload.archive:
|
||||
s = payload.status
|
||||
if s == "done":
|
||||
ok = kanban_db.complete_task(conn, tid)
|
||||
ok = kanban_db.complete_task(
|
||||
conn, tid,
|
||||
result=payload.result,
|
||||
summary=payload.summary,
|
||||
metadata=payload.metadata,
|
||||
)
|
||||
elif s == "blocked":
|
||||
ok = kanban_db.block_task(conn, tid)
|
||||
elif s == "ready":
|
||||
|
|
@ -705,6 +795,85 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
|
|||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recovery actions — reclaim a running claim, reassign to a new profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ReclaimBody(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/reclaim")
|
||||
def reclaim_task_endpoint(
|
||||
task_id: str,
|
||||
payload: ReclaimBody,
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
"""Release an active worker claim on a running task.
|
||||
|
||||
Used by the dashboard recovery popover when an operator wants to
|
||||
abort a stuck worker (e.g. one that keeps hallucinating card ids)
|
||||
without waiting for the claim TTL. Maps 1:1 to
|
||||
``hermes kanban reclaim <task_id> --reason ...``.
|
||||
"""
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
ok = kanban_db.reclaim_task(conn, task_id, reason=payload.reason)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"cannot reclaim {task_id}: not in a claimable state "
|
||||
"(not running, or unknown id)"
|
||||
),
|
||||
)
|
||||
return {"ok": True, "task_id": task_id}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class ReassignBody(BaseModel):
|
||||
profile: Optional[str] = None # "" or None = unassign
|
||||
reclaim_first: bool = False
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/reassign")
|
||||
def reassign_task_endpoint(
|
||||
task_id: str,
|
||||
payload: ReassignBody,
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
"""Reassign a task to a different profile, optionally reclaiming first.
|
||||
|
||||
Used by the dashboard recovery popover when an operator wants to
|
||||
retry a task with a different worker profile (e.g. switch to a
|
||||
smarter model after the assigned profile keeps hallucinating).
|
||||
Maps 1:1 to ``hermes kanban reassign <task_id> <profile> [--reclaim]``.
|
||||
"""
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
ok = kanban_db.reassign_task(
|
||||
conn, task_id,
|
||||
payload.profile or None,
|
||||
reclaim_first=bool(payload.reclaim_first),
|
||||
reason=payload.reason,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"cannot reassign {task_id}: unknown id, or still "
|
||||
"running (pass reclaim_first=true to release the claim first)"
|
||||
),
|
||||
)
|
||||
return {"ok": True, "task_id": task_id, "assignee": payload.profile or None}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin config (read dashboard.kanban.* defaults from config.yaml)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -626,14 +626,15 @@ class HonchoSessionManager:
|
|||
Pre-fetch user and AI peer context from Honcho.
|
||||
|
||||
Fetches peer_representation and peer_card for both peers, plus the
|
||||
session summary when available. search_query is intentionally omitted
|
||||
— it would only affect additional excerpts that this code does not
|
||||
consume, and passing the raw message exposes conversation content in
|
||||
server access logs.
|
||||
session summary when available. When user_message is provided, it is
|
||||
passed as search_query to the peer context call so Honcho returns
|
||||
conclusions relevant to the session topic rather than the full
|
||||
observation dump.
|
||||
|
||||
Args:
|
||||
session_key: The session key to get context for.
|
||||
user_message: Unused; kept for call-site compatibility.
|
||||
user_message: Optional first user message used as search_query for
|
||||
topic-relevant context retrieval.
|
||||
|
||||
Returns:
|
||||
Dictionary with 'representation', 'card', 'ai_representation',
|
||||
|
|
@ -659,7 +660,7 @@ class HonchoSessionManager:
|
|||
logger.debug("Failed to fetch session summary from Honcho: %s", e)
|
||||
|
||||
try:
|
||||
user_ctx = self._fetch_peer_context(session.user_peer_id, target=session.user_peer_id)
|
||||
user_ctx = self._fetch_peer_context(session.user_peer_id, search_query=user_message or None, target=session.user_peer_id)
|
||||
result["representation"] = user_ctx["representation"]
|
||||
result["card"] = "\n".join(user_ctx["card"])
|
||||
except Exception as e:
|
||||
|
|
|
|||
153
run_agent.py
153
run_agent.py
|
|
@ -128,6 +128,7 @@ from tools.browser_tool import cleanup_browser
|
|||
|
||||
# Agent internals extracted to agent/ package for modularity
|
||||
from agent.memory_manager import StreamingContextScrubber, build_memory_context_block, sanitize_context
|
||||
from agent.think_scrubber import StreamingThinkScrubber
|
||||
from agent.retry_utils import jittered_backoff
|
||||
from agent.error_classifier import classify_api_error, FailoverReason
|
||||
from agent.prompt_builder import (
|
||||
|
|
@ -833,7 +834,9 @@ def _routermint_headers() -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _pool_may_recover_from_rate_limit(pool) -> bool:
|
||||
def _pool_may_recover_from_rate_limit(
|
||||
pool, *, provider: str | None = None, base_url: str | None = None
|
||||
) -> bool:
|
||||
"""Decide whether to wait for credential-pool rotation instead of falling back.
|
||||
|
||||
The existing pool-rotation path requires the pool to (1) exist and (2) have
|
||||
|
|
@ -846,15 +849,23 @@ def _pool_may_recover_from_rate_limit(pool) -> bool:
|
|||
cooldown to expire means retrying against the same exhausted quota — the
|
||||
daily-quota 429 will recur immediately, and the retry budget is burned.
|
||||
|
||||
In that case we must fall back to the configured ``fallback_model``
|
||||
Additionally, Google CloudCode / Gemini CLI rate limits are ACCOUNT-level
|
||||
throttles — even a multi-entry pool shares the same quota window, so
|
||||
rotation won't recover. Skip straight to the fallback for those (#13636).
|
||||
|
||||
In those cases we must fall back to the configured ``fallback_model``
|
||||
instead. Returns True only when rotation has somewhere to go.
|
||||
|
||||
See issue #11314.
|
||||
See issues #11314 and #13636.
|
||||
"""
|
||||
if pool is None:
|
||||
return False
|
||||
if not pool.has_available():
|
||||
return False
|
||||
# CloudCode / Gemini CLI quotas are account-wide — all pool entries share
|
||||
# the same throttle window, so rotation can't recover. Prefer fallback.
|
||||
if provider == "google-gemini-cli" or str(base_url or "").startswith("cloudcode-pa://"):
|
||||
return False
|
||||
return len(pool.entries()) > 1
|
||||
|
||||
|
||||
|
|
@ -1297,6 +1308,13 @@ class AIAgent:
|
|||
# deltas (#5719). sanitize_context() alone can't survive chunk
|
||||
# boundaries because the block regex needs both tags in one string.
|
||||
self._stream_context_scrubber = StreamingContextScrubber()
|
||||
# Stateful scrubber for reasoning/thinking tags in streamed deltas
|
||||
# (#17924). Replaces the per-delta _strip_think_blocks regex that
|
||||
# destroyed downstream state (e.g. MiniMax-M2.7 streaming
|
||||
# '<think>' as delta1 and 'Let me check' as delta2 — the regex
|
||||
# erased delta1, so downstream state machines never learned a
|
||||
# block was open and leaked delta2 as content).
|
||||
self._stream_think_scrubber = StreamingThinkScrubber()
|
||||
# Visible assistant text already delivered through live token callbacks
|
||||
# during the current model response. Used to avoid re-sending the same
|
||||
# commentary when the provider later returns it as a completed interim
|
||||
|
|
@ -2659,7 +2677,10 @@ class AIAgent:
|
|||
base_url=aux_base_url,
|
||||
api_key=aux_api_key,
|
||||
config_context_length=getattr(self, "_aux_compression_context_length_config", None),
|
||||
provider=getattr(self, "provider", ""),
|
||||
# Each model must be resolved with its own provider so that
|
||||
# provider-specific paths (e.g. Bedrock static table, OpenRouter API)
|
||||
# are invoked for the correct client, not inherited from the main model.
|
||||
provider=(_aux_cfg_provider if _aux_cfg_provider and _aux_cfg_provider != "auto" else getattr(self, "provider", "")),
|
||||
)
|
||||
|
||||
# Hard floor: the auxiliary compression model must have at least
|
||||
|
|
@ -6356,6 +6377,21 @@ class AIAgent:
|
|||
|
||||
return False, has_retried_429
|
||||
|
||||
def _credential_pool_may_recover_rate_limit(self) -> bool:
|
||||
"""Whether a rate-limit retry should wait for same-provider credentials."""
|
||||
pool = self._credential_pool
|
||||
if pool is None:
|
||||
return False
|
||||
if (
|
||||
self.provider == "google-gemini-cli"
|
||||
or str(getattr(self, "base_url", "")).startswith("cloudcode-pa://")
|
||||
):
|
||||
# CloudCode/Gemini quota windows are usually account-level throttles.
|
||||
# Prefer the configured fallback immediately instead of waiting out
|
||||
# Retry-After while a pooled OAuth credential may still appear usable.
|
||||
return False
|
||||
return pool.has_available()
|
||||
|
||||
def _anthropic_messages_create(self, api_kwargs: dict):
|
||||
if self.api_mode == "anthropic_messages":
|
||||
self._try_refresh_anthropic_client_credentials()
|
||||
|
|
@ -6543,6 +6579,29 @@ class AIAgent:
|
|||
|
||||
def _reset_stream_delivery_tracking(self) -> None:
|
||||
"""Reset tracking for text delivered during the current model response."""
|
||||
# Flush any benign partial-tag tail held by the think scrubber
|
||||
# first (#17924): an innocent '<' at the end of the stream that
|
||||
# turned out not to be a tag prefix should reach the UI. Then
|
||||
# flush the context scrubber. Order matters — the think
|
||||
# scrubber's output feeds into the context scrubber's state.
|
||||
think_scrubber = getattr(self, "_stream_think_scrubber", None)
|
||||
if think_scrubber is not None:
|
||||
think_tail = think_scrubber.flush()
|
||||
if think_tail:
|
||||
# Route the tail through the context scrubber too so a
|
||||
# memory-context span straddling the final boundary is
|
||||
# still caught.
|
||||
ctx_scrubber = getattr(self, "_stream_context_scrubber", None)
|
||||
if ctx_scrubber is not None:
|
||||
think_tail = ctx_scrubber.feed(think_tail)
|
||||
if think_tail:
|
||||
callbacks = [cb for cb in (self.stream_delta_callback, self._stream_callback) if cb is not None]
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(think_tail)
|
||||
except Exception:
|
||||
pass
|
||||
self._record_streamed_assistant_text(think_tail)
|
||||
# Flush any benign partial-tag tail held by the context scrubber so it
|
||||
# reaches the UI before we clear state for the next model call. If
|
||||
# the scrubber is mid-span, flush() drops the orphaned content.
|
||||
|
|
@ -6611,11 +6670,22 @@ class AIAgent:
|
|||
else:
|
||||
prepended_break = False
|
||||
if isinstance(text, str):
|
||||
# Strip <think> blocks first (per-delta is safe for closed pairs; the
|
||||
# unterminated-tag path is handled downstream by stream_consumer).
|
||||
# Suppress reasoning/thinking blocks via the stateful
|
||||
# scrubber (#17924). Earlier versions ran _strip_think_blocks
|
||||
# per-delta here, which destroyed downstream state machines
|
||||
# when a tag was split across deltas (e.g. MiniMax-M2.7
|
||||
# sends '<think>' and its content as separate deltas —
|
||||
# regex case 2 erased the first delta, so the CLI/gateway
|
||||
# state machine never saw the open tag and leaked the
|
||||
# reasoning content as regular response text).
|
||||
think_scrubber = getattr(self, "_stream_think_scrubber", None)
|
||||
if think_scrubber is not None:
|
||||
text = think_scrubber.feed(text or "")
|
||||
else:
|
||||
# Defensive: legacy callers without the scrubber attribute.
|
||||
text = self._strip_think_blocks(text or "")
|
||||
# Then feed through the stateful context scrubber so memory-context
|
||||
# spans split across chunks cannot leak to the UI (#5719).
|
||||
text = self._strip_think_blocks(text or "")
|
||||
scrubber = getattr(self, "_stream_context_scrubber", None)
|
||||
if scrubber is not None:
|
||||
text = scrubber.feed(text)
|
||||
|
|
@ -8540,6 +8610,7 @@ class AIAgent:
|
|||
"google/gemini-2",
|
||||
"qwen/qwen3",
|
||||
"tencent/hy3-preview",
|
||||
"xiaomi/",
|
||||
)
|
||||
return any(model.startswith(prefix) for prefix in reasoning_model_prefixes)
|
||||
|
||||
|
|
@ -10576,6 +10647,11 @@ class AIAgent:
|
|||
scrubber = getattr(self, "_stream_context_scrubber", None)
|
||||
if scrubber is not None:
|
||||
scrubber.reset()
|
||||
# Reset the think scrubber for the same reason — an interrupted
|
||||
# prior stream may have left us inside an unterminated block.
|
||||
think_scrubber = getattr(self, "_stream_think_scrubber", None)
|
||||
if think_scrubber is not None:
|
||||
think_scrubber.reset()
|
||||
|
||||
# Preserve the original user message (no nudge injection).
|
||||
original_user_message = persist_user_message if persist_user_message is not None else user_message
|
||||
|
|
@ -11116,6 +11192,7 @@ class AIAgent:
|
|||
thinking_sig_retry_attempted = False
|
||||
image_shrink_retry_attempted = False
|
||||
oauth_1m_beta_retry_attempted = False
|
||||
llama_cpp_grammar_retry_attempted = False
|
||||
has_retried_429 = False
|
||||
restart_with_compressed_messages = False
|
||||
restart_with_length_continuation = False
|
||||
|
|
@ -12206,6 +12283,49 @@ class AIAgent:
|
|||
)
|
||||
continue
|
||||
|
||||
# ── llama.cpp grammar-parse recovery ──────────────────
|
||||
# llama.cpp's ``json-schema-to-grammar`` converter rejects
|
||||
# regex escape classes (``\d``, ``\w``, ``\s``) and most
|
||||
# ``format`` values in tool schemas. MCP servers emit
|
||||
# these routinely for date/phone/email params. Recovery:
|
||||
# strip ``pattern``/``format`` from ``self.tools`` and
|
||||
# retry once. We keep the keywords by default so cloud
|
||||
# providers get the full prompting hints; this branch
|
||||
# fires only for users on llama.cpp's OAI server.
|
||||
if (
|
||||
classified.reason == FailoverReason.llama_cpp_grammar_pattern
|
||||
and not llama_cpp_grammar_retry_attempted
|
||||
):
|
||||
llama_cpp_grammar_retry_attempted = True
|
||||
try:
|
||||
from tools.schema_sanitizer import strip_pattern_and_format
|
||||
_, _stripped = strip_pattern_and_format(self.tools)
|
||||
except Exception as _strip_exc: # pragma: no cover — defensive
|
||||
logging.warning(
|
||||
"%sllama.cpp grammar recovery: strip helper failed: %s",
|
||||
self.log_prefix, _strip_exc,
|
||||
)
|
||||
_stripped = 0
|
||||
if _stripped:
|
||||
self._vprint(
|
||||
f"{self.log_prefix}⚠️ llama.cpp rejected tool schema grammar — "
|
||||
f"stripped {_stripped} pattern/format keyword(s), retrying...",
|
||||
force=True,
|
||||
)
|
||||
logging.warning(
|
||||
"%sllama.cpp grammar recovery: stripped %d "
|
||||
"pattern/format keyword(s) from tool schemas",
|
||||
self.log_prefix, _stripped,
|
||||
)
|
||||
continue
|
||||
# No keywords found to strip — fall through to normal
|
||||
# retry path rather than loop forever on the same error.
|
||||
logging.warning(
|
||||
"%sllama.cpp grammar error but no pattern/format "
|
||||
"keywords to strip — falling through to normal retry",
|
||||
self.log_prefix,
|
||||
)
|
||||
|
||||
retry_count += 1
|
||||
elapsed_time = time.time() - api_start_time
|
||||
self._touch_activity(
|
||||
|
|
@ -12352,9 +12472,12 @@ class AIAgent:
|
|||
if is_rate_limited and self._fallback_index < len(self._fallback_chain):
|
||||
# Don't eagerly fallback if credential pool rotation may
|
||||
# still recover. See _pool_may_recover_from_rate_limit
|
||||
# for the single-credential-pool exception. Fixes #11314.
|
||||
# for the single-credential-pool and CloudCode-quota
|
||||
# exceptions. Fixes #11314 and #13636.
|
||||
pool_may_recover = _pool_may_recover_from_rate_limit(
|
||||
self._credential_pool
|
||||
self._credential_pool,
|
||||
provider=self.provider,
|
||||
base_url=getattr(self, "base_url", None),
|
||||
)
|
||||
if not pool_may_recover:
|
||||
self._emit_status("⚠️ Rate limited — switching to fallback provider...")
|
||||
|
|
@ -13861,9 +13984,19 @@ class AIAgent:
|
|||
except Exception as exc:
|
||||
logger.warning("post_llm_call hook failed: %s", exc)
|
||||
|
||||
# Extract reasoning from the last assistant message (if any)
|
||||
# Extract reasoning from the CURRENT turn only. Walk backwards
|
||||
# but stop at the user message that started this turn — anything
|
||||
# earlier is from a prior turn and must not leak into the reasoning
|
||||
# box (confusing stale display; #17055). Within the current turn
|
||||
# we still want the *most recent* non-empty reasoning: many
|
||||
# providers (Claude thinking, DeepSeek v4, Codex Responses) emit
|
||||
# reasoning on the tool-call step and leave the final-answer step
|
||||
# with reasoning=None, so picking only the last assistant would
|
||||
# silently drop legitimate same-turn reasoning.
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break # turn boundary — don't cross into prior turns
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
|
|
|
|||
|
|
@ -176,9 +176,12 @@ def check_env_vars():
|
|||
|
||||
# Load .env
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
if ENV_FILE.exists():
|
||||
load_dotenv(ENV_FILE)
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
load_hermes_dotenv(
|
||||
hermes_home=ENV_FILE.parent,
|
||||
project_env=PROJECT_ROOT / ".env",
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,36 @@ AUTHOR_MAP = {
|
|||
"14046872+tmimmanuel@users.noreply.github.com": "tmimmanuel",
|
||||
"657290301@qq.com": "IMHaoyan",
|
||||
"revar@users.noreply.github.com": "revaraver",
|
||||
"dengtaoyuan@dengtaoyuandeMac-mini.local": "dengtaoyuan450-a11y",
|
||||
"ysfalweshcan@gmail.com": "Junass1",
|
||||
"bartokmagic@proton.me": "Bartok9",
|
||||
"25840394+Bongulielmi@users.noreply.github.com": "Bongulielmi",
|
||||
"jonathan.troyer@overmatch.com": "JTroyerOvermatch",
|
||||
"harryykyle1@gmail.com": "hharry11",
|
||||
"wysie@users.noreply.github.com": "wysie",
|
||||
"jkausel@gmail.com": "jkausel-ai",
|
||||
"e.silacandmr@gmail.com": "Es1la",
|
||||
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"zjtan1@gmail.com": "zeejaytan",
|
||||
"asslaenn5@gmail.com": "Aslaaen",
|
||||
"trae.anderson17@icloud.com": "Tkander1715",
|
||||
"beardthelion@users.noreply.github.com": "beardthelion",
|
||||
"tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc",
|
||||
"leon@agentlinker.ai": "agentlinker",
|
||||
"santoshhumagain1887@gmail.com": "npmisantosh",
|
||||
"novax635@gmail.com": "novax635",
|
||||
"krionex1@gmail.com": "Krionex",
|
||||
"rxdxxxx@users.noreply.github.com": "rxdxxxx",
|
||||
"ma.haohao2@xydigit.com": "MaHaoHao-ch",
|
||||
"29756950+revaraver@users.noreply.github.com": "revaraver",
|
||||
"nexus@eptic.me": "TheEpTic",
|
||||
"74554762+wmagev@users.noreply.github.com": "wmagev",
|
||||
"ashermorse@icloud.com": "ashermorse",
|
||||
"happy5318@users.noreply.github.com": "happy5318",
|
||||
"chengoak@users.noreply.github.com": "chengoak",
|
||||
"mrhanoi@outlook.com": "qxxaa",
|
||||
"emelyanenko.kirill@gmail.com": "EmelyanenkoK",
|
||||
"lazycat.manatee@gmail.com": "manateelazycat",
|
||||
# Matrix parity salvage batch (April 2026)
|
||||
"sr@samirusani": "samrusani",
|
||||
"angelclaw@AngelMacBook.local": "angel12",
|
||||
|
|
@ -81,6 +110,7 @@ AUTHOR_MAP = {
|
|||
# Curator fixes (Apr 30 2026)
|
||||
"yuxiangl490@gmail.com": "y0shua1ee",
|
||||
"manmit0x@gmail.com": "0xDevNinja",
|
||||
"stevekelly622@gmail.com": "steezkelly",
|
||||
"aamirjawaid@microsoft.com": "heyitsaamir",
|
||||
"johnnncenaaa77@gmail.com": "johnncenae",
|
||||
"thomasjhon6666@gmail.com": "ThomassJonax",
|
||||
|
|
@ -260,6 +290,7 @@ AUTHOR_MAP = {
|
|||
"hakanerten02@hotmail.com": "teyrebaz33",
|
||||
"linux2010@users.noreply.github.com": "Linux2010",
|
||||
"elmatadorgh@users.noreply.github.com": "elmatadorgh",
|
||||
"coktinbaran5@gmail.com": "elmatadorgh",
|
||||
"alexazzjjtt@163.com": "alexzhu0",
|
||||
"1180176+Swift42@users.noreply.github.com": "Swift42",
|
||||
"ruzzgarcn@gmail.com": "Ruzzgar",
|
||||
|
|
@ -533,6 +564,7 @@ AUTHOR_MAP = {
|
|||
"memosr_email@gmail.com": "memosr",
|
||||
"jperlow@gmail.com": "perlowja",
|
||||
"jasonpette1783@gmail.com": "web-dev0521",
|
||||
"bjianhang@gmail.com": "bjianhang",
|
||||
"tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc",
|
||||
"harryplusplus@gmail.com": "harryplusplus",
|
||||
"anthhub@163.com": "anthhub",
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ import express from 'express';
|
|||
import { Boom } from '@hapi/boom';
|
||||
import pino from 'pino';
|
||||
import path from 'path';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { execSync } from 'child_process';
|
||||
import { tmpdir } from 'os';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';
|
||||
|
||||
|
|
@ -505,8 +507,31 @@ app.post('/send-media', async (req, res) => {
|
|||
msgPayload = { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
|
||||
break;
|
||||
case 'audio': {
|
||||
const audioMime = (ext === 'ogg' || ext === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
|
||||
msgPayload = { audio: buffer, mimetype: audioMime, ptt: ext === 'ogg' || ext === 'opus' };
|
||||
// WhatsApp only renders a native voice bubble (ptt) when the file is ogg/opus.
|
||||
// If the caller passes mp3, wav, m4a etc. (e.g. from Edge TTS / NeuTTS),
|
||||
// silently convert to ogg/opus via ffmpeg so ptt is always honoured.
|
||||
let audioBuffer = buffer;
|
||||
let audioExt = ext;
|
||||
const needsConversion = !['ogg', 'opus'].includes(ext);
|
||||
let tmpPath = null;
|
||||
if (needsConversion) {
|
||||
tmpPath = path.join(tmpdir(), `hermes_voice_${randomBytes(6).toString('hex')}.ogg`);
|
||||
try {
|
||||
execSync(
|
||||
`ffmpeg -y -i ${JSON.stringify(filePath)} -ar 48000 -ac 1 -c:a libopus ${JSON.stringify(tmpPath)}`,
|
||||
{ timeout: 30000, stdio: 'pipe' }
|
||||
);
|
||||
audioBuffer = readFileSync(tmpPath);
|
||||
audioExt = 'ogg';
|
||||
} catch (convErr) {
|
||||
// ffmpeg not available or conversion failed — fall back to original format
|
||||
console.warn('[bridge] ffmpeg conversion failed, sending as file attachment:', convErr.message);
|
||||
} finally {
|
||||
try { if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath); } catch (_) {}
|
||||
}
|
||||
}
|
||||
const audioMime = (audioExt === 'ogg' || audioExt === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
|
||||
msgPayload = { audio: audioBuffer, mimetype: audioMime, ptt: audioExt === 'ogg' || audioExt === 'opus' };
|
||||
break;
|
||||
}
|
||||
case 'document':
|
||||
|
|
|
|||
|
|
@ -150,3 +150,13 @@ Tell them what you created in plain prose:
|
|||
**Don't pre-create the whole graph if the shape depends on intermediate findings.** If T3's structure depends on what T1 and T2 find, let T3 exist as a "synthesize findings" task whose own first step is to read parent handoffs and plan the rest. Orchestrators can spawn orchestrators.
|
||||
|
||||
**Tenant inheritance.** If `HERMES_TENANT` is set in your env, pass `tenant=os.environ.get("HERMES_TENANT")` on every `kanban_create` call so child tasks stay in the same namespace.
|
||||
|
||||
## Recovering stuck workers
|
||||
|
||||
When a worker profile keeps crashing, hallucinating, or getting blocked by its own mistakes (usually: wrong model, missing skill, broken credential), the kanban dashboard flags the task with a ⚠ badge and opens a **Recovery** section in the drawer. Three primary actions:
|
||||
|
||||
1. **Reclaim** (or `hermes kanban reclaim <task_id>`) — abort the running worker immediately and reset the task to `ready`. The existing claim TTL is ~15 min; this is the fast path out.
|
||||
2. **Reassign** (or `hermes kanban reassign <task_id> <new-profile> --reclaim`) — switch the task to a different profile and let the dispatcher pick it up with a fresh worker.
|
||||
3. **Change profile model** — the dashboard prints a copy-paste hint for `hermes -p <profile> model` since profile config lives on disk; edit it in a terminal, then Reclaim to retry with the new model.
|
||||
|
||||
Hallucination warnings appear on tasks where a worker's `kanban_complete(created_cards=[...])` claim included card ids that don't exist or weren't created by the worker's profile (the gate blocks the completion), or where the free-form summary references `t_<hex>` ids that don't resolve (advisory prose scan, non-blocking). Both produce audit events that persist even after recovery actions — the trail stays for debugging.
|
||||
|
|
|
|||
|
|
@ -75,6 +75,32 @@ kanban_complete(
|
|||
|
||||
Shape `metadata` so downstream parsers (reviewers, aggregators, schedulers) can use it without re-reading your prose.
|
||||
|
||||
## Claiming cards you actually created
|
||||
|
||||
If your run produced new kanban tasks (via `kanban_create`), pass the ids in `created_cards` on `kanban_complete`. The kernel verifies each id exists and was created by your profile; any phantom id blocks the completion with an error listing what went wrong, and the rejected attempt is permanently recorded on the task's event log. **Only list ids you captured from a successful `kanban_create` return value — never invent ids from prose, never paste ids from earlier runs, never claim cards another worker created.**
|
||||
|
||||
```python
|
||||
# GOOD — capture return values, then claim them.
|
||||
c1 = kanban_create(title="remediate SQL injection", assignee="security-worker")
|
||||
c2 = kanban_create(title="fix CSRF middleware", assignee="web-worker")
|
||||
|
||||
kanban_complete(
|
||||
summary="Review done; spawned remediations for both findings.",
|
||||
metadata={"pr_number": 123, "approved": False},
|
||||
created_cards=[c1["task_id"], c2["task_id"]],
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# BAD — claiming ids you don't have captured return values for.
|
||||
kanban_complete(
|
||||
summary="Created remediation cards t_a1b2c3d4, t_deadbeef", # hallucinated
|
||||
created_cards=["t_a1b2c3d4", "t_deadbeef"], # → gate rejects
|
||||
)
|
||||
```
|
||||
|
||||
If a `kanban_create` call fails (exception, tool_error), the card was NOT created — do not include a phantom id for it. Retry the create, or omit the id and mention the failure in your summary. The prose-scan pass also catches `t_<hex>` references in your free-form summary that don't resolve; these don't block the completion but show up as advisory warnings on the task in the dashboard.
|
||||
|
||||
## Block reasons that get answered fast
|
||||
|
||||
Bad: `"stuck"` — the human has no context.
|
||||
|
|
|
|||
|
|
@ -188,6 +188,31 @@ class TestListAndCleanup:
|
|||
manager.create_session(cwd="/empty")
|
||||
assert manager.list_sessions() == []
|
||||
|
||||
def test_save_session_preserves_existing_messages_on_encode_failure(self, manager):
|
||||
"""Regression for #13675: a bad message in state.history must not
|
||||
clobber the previously-persisted transcript. replace_messages()
|
||||
wraps DELETE + INSERT in a single rolled-back-on-exception txn.
|
||||
"""
|
||||
state = manager.create_session()
|
||||
state.history.append({"role": "user", "content": "original"})
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
# Now swap history with a message whose tool_calls is non-JSON-serializable.
|
||||
# _execute_write rolls back; the previously persisted "original" stays.
|
||||
state.history = [
|
||||
{"role": "user", "content": "replacement"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"bad": object()}],
|
||||
},
|
||||
]
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
db = manager._get_db()
|
||||
messages = db.get_messages_as_conversation(state.session_id)
|
||||
assert messages == [{"role": "user", "content": "original"}]
|
||||
|
||||
def test_cleanup_clears_all(self, manager):
|
||||
s1 = manager.create_session()
|
||||
s2 = manager.create_session()
|
||||
|
|
@ -455,6 +480,39 @@ class TestPersistence:
|
|||
assert restored.history[0].get("tool_calls") is not None
|
||||
assert restored.history[1].get("tool_call_id") == "tc_1"
|
||||
|
||||
def test_assistant_reasoning_fields_persisted(self, manager):
|
||||
"""ACP session restore should preserve assistant reasoning context."""
|
||||
state = manager.create_session()
|
||||
state.history.append({
|
||||
"role": "assistant",
|
||||
"content": "hello",
|
||||
"reasoning": "step-by-step",
|
||||
"reasoning_details": [
|
||||
{"type": "thinking", "thinking": "first thought"},
|
||||
],
|
||||
"codex_reasoning_items": [
|
||||
{"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob"},
|
||||
],
|
||||
})
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
with manager._lock:
|
||||
del manager._sessions[state.session_id]
|
||||
|
||||
restored = manager.get_session(state.session_id)
|
||||
assert restored is not None
|
||||
assert restored.history == [{
|
||||
"role": "assistant",
|
||||
"content": "hello",
|
||||
"reasoning": "step-by-step",
|
||||
"reasoning_details": [
|
||||
{"type": "thinking", "thinking": "first thought"},
|
||||
],
|
||||
"codex_reasoning_items": [
|
||||
{"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob"},
|
||||
],
|
||||
}]
|
||||
|
||||
def test_restore_preserves_persisted_provider_snapshot(self, tmp_path, monkeypatch):
|
||||
"""Restored ACP sessions should keep their original runtime provider."""
|
||||
runtime_choice = {"provider": "anthropic"}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from agent.auxiliary_client import (
|
|||
_read_codex_access_token,
|
||||
_get_provider_chain,
|
||||
_is_payment_error,
|
||||
_is_rate_limit_error,
|
||||
_normalize_aux_provider,
|
||||
_try_payment_fallback,
|
||||
_resolve_auto,
|
||||
|
|
@ -789,6 +790,65 @@ class TestIsPaymentError:
|
|||
assert _is_payment_error(exc) is False
|
||||
|
||||
|
||||
class TestIsRateLimitError:
|
||||
"""_is_rate_limit_error detects 429 rate-limit errors warranting fallback."""
|
||||
|
||||
def test_429_with_rate_limit_message(self):
|
||||
exc = Exception("Rate limit exceeded, try again in 2 seconds")
|
||||
exc.status_code = 429
|
||||
assert _is_rate_limit_error(exc) is True
|
||||
|
||||
def test_429_with_resets_in_message(self):
|
||||
"""Nous-style 429: 'resets in 3508s'."""
|
||||
exc = Exception("Hold up for a bit, you've exceeded the rate limit on your API key")
|
||||
exc.status_code = 429
|
||||
assert _is_rate_limit_error(exc) is True
|
||||
|
||||
def test_429_with_too_many_requests(self):
|
||||
exc = Exception("Too many requests")
|
||||
exc.status_code = 429
|
||||
assert _is_rate_limit_error(exc) is True
|
||||
|
||||
def test_429_without_billing_keywords_is_rate_limit(self):
|
||||
"""Generic 429 without billing keywords = likely a rate limit."""
|
||||
exc = Exception("Something went wrong")
|
||||
exc.status_code = 429
|
||||
assert _is_rate_limit_error(exc) is True
|
||||
|
||||
def test_429_with_credits_message_is_not_rate_limit(self):
|
||||
"""Billing-related 429 should NOT be classified as rate limit."""
|
||||
exc = Exception("insufficient credits remaining")
|
||||
exc.status_code = 429
|
||||
assert _is_rate_limit_error(exc) is False
|
||||
|
||||
def test_429_with_billing_message_is_not_rate_limit(self):
|
||||
exc = Exception("you can only afford 1000 tokens")
|
||||
exc.status_code = 429
|
||||
assert _is_rate_limit_error(exc) is False
|
||||
|
||||
def test_402_is_not_rate_limit(self):
|
||||
exc = Exception("Payment Required")
|
||||
exc.status_code = 402
|
||||
assert _is_rate_limit_error(exc) is False
|
||||
|
||||
def test_500_is_not_rate_limit(self):
|
||||
exc = Exception("Internal Server Error")
|
||||
exc.status_code = 500
|
||||
assert _is_rate_limit_error(exc) is False
|
||||
|
||||
def test_openai_ratelimiterror_classname(self):
|
||||
"""OpenAI SDK RateLimitError may omit .status_code — detect by class name."""
|
||||
class RateLimitError(Exception):
|
||||
pass
|
||||
exc = RateLimitError("rate limit exceeded")
|
||||
# No status_code set, but class name matches
|
||||
assert _is_rate_limit_error(exc) is True
|
||||
|
||||
def test_no_status_code_no_keywords_is_not_rate_limit(self):
|
||||
exc = Exception("connection reset")
|
||||
assert _is_rate_limit_error(exc) is False
|
||||
|
||||
|
||||
class TestGetProviderChain:
|
||||
"""_get_provider_chain() resolves functions at call time (testable)."""
|
||||
|
||||
|
|
@ -860,13 +920,18 @@ class TestTryPaymentFallback:
|
|||
|
||||
|
||||
class TestCallLlmPaymentFallback:
|
||||
"""call_llm() retries with a different provider on 402 / payment errors."""
|
||||
"""call_llm() retries with a different provider on 402 / payment / rate-limit errors."""
|
||||
|
||||
def _make_402_error(self, msg="Payment Required: insufficient credits"):
|
||||
exc = Exception(msg)
|
||||
exc.status_code = 402
|
||||
return exc
|
||||
|
||||
def _make_429_rate_limit_error(self, msg="Rate limit exceeded, try again in 60 seconds"):
|
||||
exc = Exception(msg)
|
||||
exc.status_code = 429
|
||||
return exc
|
||||
|
||||
def test_non_payment_error_not_caught(self, monkeypatch):
|
||||
"""Non-payment/non-connection errors (500) should NOT trigger fallback."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
|
@ -886,6 +951,32 @@ class TestCallLlmPaymentFallback:
|
|||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
def test_429_rate_limit_triggers_fallback(self, monkeypatch):
|
||||
"""429 rate-limit errors should trigger fallback to next provider."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
rate_err = self._make_429_rate_limit_error()
|
||||
primary_client.chat.completions.create.side_effect = rate_err
|
||||
|
||||
fallback_client = MagicMock()
|
||||
fallback_client.chat.completions.create.return_value = MagicMock(choices=[
|
||||
MagicMock(message=MagicMock(content="fallback response"))
|
||||
])
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "xiaomi/mimo-v2-pro")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", "xiaomi/mimo-v2-pro", None, None, None)), \
|
||||
patch("agent.auxiliary_client._try_payment_fallback",
|
||||
return_value=(fallback_client, "fallback-model", "openrouter")):
|
||||
result = call_llm(
|
||||
task="session_search",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
# Fallback client should have been used
|
||||
assert fallback_client.chat.completions.create.called
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate: _resolve_api_key_provider must skip anthropic when not configured
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1650,6 +1741,42 @@ class TestCodexAdapterReasoningTranslation:
|
|||
)
|
||||
assert "reasoning" not in captured
|
||||
|
||||
def test_reasoning_effort_null_falls_back_to_medium(self):
|
||||
"""Parity with agent/transports/codex.py::build_kwargs() — falsy
|
||||
``effort`` (None / empty / 0) keeps the default ``medium`` instead
|
||||
of being forwarded to Codex. Codex rejects ``{"effort": null}``
|
||||
with HTTP 400 (Invalid value for parameter `reasoning.effort`)."""
|
||||
adapter, captured = self._build_adapter()
|
||||
adapter.create(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
extra_body={"reasoning": {"effort": None}},
|
||||
)
|
||||
assert captured.get("reasoning") == {"effort": "medium", "summary": "auto"}
|
||||
assert captured.get("include") == ["reasoning.encrypted_content"]
|
||||
|
||||
def test_reasoning_effort_empty_string_falls_back_to_medium(self):
|
||||
"""Empty-string effort (e.g. ``effort: ""`` in YAML) is falsy in
|
||||
the main-agent path's truthy check; mirror that here so the same
|
||||
config produces the same result."""
|
||||
adapter, captured = self._build_adapter()
|
||||
adapter.create(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
extra_body={"reasoning": {"effort": ""}},
|
||||
)
|
||||
assert captured.get("reasoning") == {"effort": "medium", "summary": "auto"}
|
||||
assert captured.get("include") == ["reasoning.encrypted_content"]
|
||||
|
||||
def test_reasoning_effort_zero_falls_back_to_medium(self):
|
||||
"""Numeric ``0`` is also falsy — the docstring lists it explicitly,
|
||||
so cover the contract. Codex would reject ``{"effort": 0}`` the
|
||||
same way it rejects ``null``."""
|
||||
adapter, captured = self._build_adapter()
|
||||
adapter.create(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
extra_body={"reasoning": {"effort": 0}},
|
||||
)
|
||||
assert captured.get("reasoning") == {"effort": "medium", "summary": "auto"}
|
||||
assert captured.get("include") == ["reasoning.encrypted_content"]
|
||||
|
||||
|
||||
class TestVisionAutoSkipsKimiCoding:
|
||||
|
|
|
|||
|
|
@ -664,6 +664,44 @@ class TestCompressWithClient:
|
|||
"call_123"
|
||||
]
|
||||
|
||||
def test_user_role_summary_carries_end_marker(self):
|
||||
"""When the summary lands as standalone role='user' (e.g. head ends
|
||||
with assistant/tool), the message body must include the explicit
|
||||
'--- END OF CONTEXT SUMMARY ---' marker. Without it, weak models
|
||||
read the verbatim past user request quoted in '## Active Task' as
|
||||
fresh input (#11475, #14521).
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "summary text"
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
|
||||
|
||||
# head_last=assistant, tail_first=assistant (same shape as the
|
||||
# existing consecutive-user test) → role resolves to "user".
|
||||
msgs = [
|
||||
{"role": "user", "content": "msg 0"},
|
||||
{"role": "assistant", "content": "msg 1"},
|
||||
{"role": "user", "content": "msg 2"},
|
||||
{"role": "assistant", "content": "msg 3"},
|
||||
{"role": "user", "content": "msg 4"},
|
||||
{"role": "assistant", "content": "msg 5"},
|
||||
{"role": "user", "content": "msg 6"},
|
||||
{"role": "assistant", "content": "msg 7"},
|
||||
]
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
||||
result = c.compress(msgs)
|
||||
|
||||
summary_msg = next(
|
||||
m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX)
|
||||
)
|
||||
assert summary_msg["role"] == "user"
|
||||
assert "END OF CONTEXT SUMMARY" in summary_msg["content"]
|
||||
assert summary_msg["content"].rstrip().endswith(
|
||||
"respond to the message below, not the summary above ---"
|
||||
)
|
||||
|
||||
def test_summary_role_avoids_consecutive_user_messages(self):
|
||||
"""Summary role should alternate with the last head message to avoid consecutive same-role messages."""
|
||||
mock_client = MagicMock()
|
||||
|
|
|
|||
67
tests/agent/test_context_compressor_summary_continuity.py
Normal file
67
tests/agent/test_context_compressor_summary_continuity.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Regression tests for iterative context-summary continuity."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX
|
||||
|
||||
|
||||
def _compressor() -> ContextCompressor:
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
return ContextCompressor(
|
||||
model="test/model",
|
||||
threshold_percent=0.85,
|
||||
protect_first_n=1,
|
||||
protect_last_n=1,
|
||||
quiet_mode=True,
|
||||
)
|
||||
|
||||
|
||||
def _response(content: str):
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = content
|
||||
return mock_response
|
||||
|
||||
|
||||
def _messages_with_handoff(summary_body: str):
|
||||
return [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": f"{SUMMARY_PREFIX}\n{summary_body}"},
|
||||
{"role": "user", "content": "new user turn after resume"},
|
||||
{"role": "assistant", "content": "new assistant work after resume"},
|
||||
{"role": "user", "content": "more new work after resume"},
|
||||
{"role": "assistant", "content": "latest tail response"},
|
||||
]
|
||||
|
||||
|
||||
def test_existing_previous_summary_is_not_serialized_again_as_new_turn():
|
||||
"""Same-process iterative compression should not feed the old handoff twice."""
|
||||
compressor = _compressor()
|
||||
old_summary = "OLD-SUMMARY-BODY unique continuity facts"
|
||||
compressor._previous_summary = old_summary
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call:
|
||||
compressor.compress(_messages_with_handoff(old_summary))
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "PREVIOUS SUMMARY:" in prompt
|
||||
assert "NEW TURNS TO INCORPORATE:" in prompt
|
||||
assert prompt.count(old_summary) == 1
|
||||
assert f"[USER]: {SUMMARY_PREFIX}" not in prompt
|
||||
|
||||
|
||||
def test_resume_rehydrates_previous_summary_from_handoff_message():
|
||||
"""After restart/resume, the persisted handoff should regain summary identity."""
|
||||
compressor = _compressor()
|
||||
old_summary = "RESUMED-SUMMARY-BODY durable continuity facts"
|
||||
assert compressor._previous_summary is None
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call:
|
||||
compressor.compress(_messages_with_handoff(old_summary))
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "PREVIOUS SUMMARY:" in prompt
|
||||
assert "NEW TURNS TO INCORPORATE:" in prompt
|
||||
assert "TURNS TO SUMMARIZE:" not in prompt
|
||||
assert prompt.count(old_summary) == 1
|
||||
assert f"[USER]: {SUMMARY_PREFIX}" not in prompt
|
||||
|
|
@ -59,6 +59,7 @@ class TestFailoverReason:
|
|||
"provider_policy_blocked",
|
||||
"thinking_signature", "long_context_tier",
|
||||
"oauth_long_context_beta_forbidden",
|
||||
"llama_cpp_grammar_pattern",
|
||||
"unknown",
|
||||
}
|
||||
actual = {r.value for r in FailoverReason}
|
||||
|
|
@ -475,6 +476,43 @@ class TestClassifyApiError:
|
|||
# Without "thinking" in the message, it shouldn't be thinking_signature
|
||||
assert result.reason != FailoverReason.thinking_signature
|
||||
|
||||
# ── Provider-specific: llama.cpp grammar-parse ──
|
||||
|
||||
def test_llama_cpp_grammar_parse_error(self):
|
||||
"""llama.cpp rejects regex escapes in JSON Schema `pattern`."""
|
||||
e = MockAPIError(
|
||||
"parse: error parsing grammar: unknown escape at \\d",
|
||||
status_code=400,
|
||||
)
|
||||
result = classify_api_error(e, provider="openai-compatible")
|
||||
assert result.reason == FailoverReason.llama_cpp_grammar_pattern
|
||||
assert result.retryable is True
|
||||
assert result.should_compress is False
|
||||
|
||||
def test_llama_cpp_unable_to_generate_parser(self):
|
||||
"""Older llama.cpp builds surface the error as 'unable to generate parser'."""
|
||||
e = MockAPIError(
|
||||
"Unable to generate parser for this template",
|
||||
status_code=400,
|
||||
)
|
||||
result = classify_api_error(e, provider="openai-compatible")
|
||||
assert result.reason == FailoverReason.llama_cpp_grammar_pattern
|
||||
|
||||
def test_llama_cpp_json_schema_to_grammar_phrase(self):
|
||||
"""Some builds mention the module name explicitly."""
|
||||
e = MockAPIError(
|
||||
"json-schema-to-grammar failed to convert schema",
|
||||
status_code=400,
|
||||
)
|
||||
result = classify_api_error(e, provider="openai-compatible")
|
||||
assert result.reason == FailoverReason.llama_cpp_grammar_pattern
|
||||
|
||||
def test_llama_cpp_grammar_requires_400(self):
|
||||
"""A 500 with the same phrase isn't the llama.cpp grammar case."""
|
||||
e = MockAPIError("error parsing grammar", status_code=500)
|
||||
result = classify_api_error(e, provider="openai-compatible")
|
||||
assert result.reason != FailoverReason.llama_cpp_grammar_pattern
|
||||
|
||||
# ── Provider-specific: Anthropic long-context tier ──
|
||||
|
||||
def test_anthropic_long_context_tier(self):
|
||||
|
|
|
|||
62
tests/agent/test_gemini_fast_fallback.py
Normal file
62
tests/agent/test_gemini_fast_fallback.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Regression tests for #13636 — CloudCode / Gemini CLI rate-limit fallback.
|
||||
|
||||
_pool_may_recover_from_rate_limit() is the hinge between credential-pool
|
||||
rotation and fallback-provider activation. For CloudCode (Gemini CLI /
|
||||
Gemini OAuth) the 429 is an account-wide throttle, so waiting for pool
|
||||
rotation is pointless — prefer fallback immediately.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from run_agent import _pool_may_recover_from_rate_limit
|
||||
|
||||
|
||||
def _pool(entries: int = 2):
|
||||
p = MagicMock()
|
||||
p.has_available.return_value = True
|
||||
p.entries.return_value = list(range(entries))
|
||||
return p
|
||||
|
||||
|
||||
def test_cloudcode_provider_skips_pool_rotation():
|
||||
assert _pool_may_recover_from_rate_limit(
|
||||
_pool(entries=3),
|
||||
provider="google-gemini-cli",
|
||||
base_url="cloudcode-pa://google",
|
||||
) is False
|
||||
|
||||
|
||||
def test_cloudcode_base_url_skips_pool_rotation_even_on_alias_provider():
|
||||
# Even if the provider label is something else, a cloudcode-pa:// URL
|
||||
# signals the account-wide quota regime.
|
||||
assert _pool_may_recover_from_rate_limit(
|
||||
_pool(entries=3),
|
||||
provider="custom-provider",
|
||||
base_url="cloudcode-pa://google",
|
||||
) is False
|
||||
|
||||
|
||||
def test_non_cloudcode_multi_entry_pool_still_recovers():
|
||||
assert _pool_may_recover_from_rate_limit(
|
||||
_pool(entries=3),
|
||||
provider="openrouter",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
) is True
|
||||
|
||||
|
||||
def test_single_entry_pool_skips_rotation_regardless_of_provider():
|
||||
# Pre-existing single-entry-pool exception (#11314) still holds.
|
||||
assert _pool_may_recover_from_rate_limit(
|
||||
_pool(entries=1),
|
||||
provider="openrouter",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
) is False
|
||||
|
||||
|
||||
def test_exhausted_pool_skips_rotation():
|
||||
p = MagicMock()
|
||||
p.has_available.return_value = False
|
||||
assert _pool_may_recover_from_rate_limit(p) is False
|
||||
|
||||
|
||||
def test_no_pool_skips_rotation():
|
||||
assert _pool_may_recover_from_rate_limit(None) is False
|
||||
156
tests/agent/test_i18n.py
Normal file
156
tests/agent/test_i18n.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""Tests for agent.i18n -- catalog parity, fallback, language resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from agent import i18n
|
||||
|
||||
|
||||
LOCALES_DIR = Path(__file__).resolve().parents[2] / "locales"
|
||||
|
||||
|
||||
def _load_raw(lang: str) -> dict:
|
||||
with (LOCALES_DIR / f"{lang}.yaml").open("r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def _flatten(d, prefix="") -> dict:
|
||||
flat = {}
|
||||
for k, v in (d or {}).items():
|
||||
key = f"{prefix}.{k}" if prefix else k
|
||||
if isinstance(v, dict):
|
||||
flat.update(_flatten(v, key))
|
||||
else:
|
||||
flat[key] = v
|
||||
return flat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog completeness -- this is the key invariant test. If someone adds a
|
||||
# new key to en.yaml they MUST add it to every other locale, else runtime
|
||||
# falls back to English for those users and defeats the feature.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_all_locales_exist():
|
||||
"""Every supported language must have a catalog file on disk."""
|
||||
for lang in i18n.SUPPORTED_LANGUAGES:
|
||||
assert (LOCALES_DIR / f"{lang}.yaml").is_file(), f"missing locales/{lang}.yaml"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang", [l for l in i18n.SUPPORTED_LANGUAGES if l != "en"])
|
||||
def test_catalog_keys_match_english(lang: str):
|
||||
"""Every non-English catalog must have exactly the same key set as English."""
|
||||
en_keys = set(_flatten(_load_raw("en")).keys())
|
||||
lang_keys = set(_flatten(_load_raw(lang)).keys())
|
||||
missing = en_keys - lang_keys
|
||||
extra = lang_keys - en_keys
|
||||
assert not missing, f"{lang}.yaml missing keys: {sorted(missing)}"
|
||||
assert not extra, f"{lang}.yaml has keys not in en.yaml: {sorted(extra)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang", list(i18n.SUPPORTED_LANGUAGES))
|
||||
def test_catalog_placeholders_match_english(lang: str):
|
||||
"""Every translated value must use the same {placeholder} tokens as English.
|
||||
|
||||
A mistranslated placeholder (e.g. ``{description}`` typoed as ``{descricao}``)
|
||||
would either raise KeyError at runtime or silently drop the interpolated
|
||||
value. Pin parity at the test layer.
|
||||
"""
|
||||
import re
|
||||
placeholder_re = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
|
||||
en_flat = _flatten(_load_raw("en"))
|
||||
lang_flat = _flatten(_load_raw(lang))
|
||||
for key, en_value in en_flat.items():
|
||||
en_placeholders = set(placeholder_re.findall(en_value))
|
||||
lang_value = lang_flat.get(key, "")
|
||||
lang_placeholders = set(placeholder_re.findall(lang_value))
|
||||
assert en_placeholders == lang_placeholders, (
|
||||
f"{lang}.yaml key={key!r}: placeholders {lang_placeholders} "
|
||||
f"don't match English {en_placeholders}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Language resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_normalize_lang_accepts_supported():
|
||||
assert i18n._normalize_lang("zh") == "zh"
|
||||
assert i18n._normalize_lang("EN") == "en"
|
||||
|
||||
|
||||
def test_normalize_lang_accepts_aliases():
|
||||
assert i18n._normalize_lang("chinese") == "zh"
|
||||
assert i18n._normalize_lang("zh-CN") == "zh"
|
||||
assert i18n._normalize_lang("Deutsch") == "de"
|
||||
assert i18n._normalize_lang("español") == "es"
|
||||
assert i18n._normalize_lang("jp") == "ja"
|
||||
|
||||
|
||||
def test_normalize_lang_unknown_falls_back():
|
||||
assert i18n._normalize_lang("klingon") == "en"
|
||||
assert i18n._normalize_lang("") == "en"
|
||||
assert i18n._normalize_lang(None) == "en"
|
||||
|
||||
|
||||
def test_env_var_override(monkeypatch):
|
||||
"""HERMES_LANGUAGE wins over config."""
|
||||
i18n.reset_language_cache()
|
||||
monkeypatch.setenv("HERMES_LANGUAGE", "ja")
|
||||
assert i18n.get_language() == "ja"
|
||||
|
||||
|
||||
def test_env_var_normalized(monkeypatch):
|
||||
i18n.reset_language_cache()
|
||||
monkeypatch.setenv("HERMES_LANGUAGE", "Chinese")
|
||||
assert i18n.get_language() == "zh"
|
||||
|
||||
|
||||
def test_default_when_nothing_set(monkeypatch):
|
||||
"""With no env var and no config override, falls back to English."""
|
||||
monkeypatch.delenv("HERMES_LANGUAGE", raising=False)
|
||||
# Force config lookup to return None -- patch the cached reader.
|
||||
i18n.reset_language_cache()
|
||||
monkeypatch.setattr(i18n, "_config_language_cached", lambda: None)
|
||||
assert i18n.get_language() == "en"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# t() semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_t_explicit_lang():
|
||||
assert i18n.t("approval.denied", lang="en").endswith("Denied")
|
||||
assert i18n.t("approval.denied", lang="zh").endswith("已拒绝")
|
||||
|
||||
|
||||
def test_t_formats_placeholders():
|
||||
msg = i18n.t("gateway.draining", lang="en", count=3)
|
||||
assert "3" in msg
|
||||
|
||||
|
||||
def test_t_missing_key_returns_key():
|
||||
"""A missing key returns its own path -- ugly but never crashes."""
|
||||
result = i18n.t("nonexistent.key.path", lang="en")
|
||||
assert result == "nonexistent.key.path"
|
||||
|
||||
|
||||
def test_t_missing_key_in_non_english_falls_back_to_english(tmp_path, monkeypatch):
|
||||
"""If a key exists in English but not in the target locale, fall back."""
|
||||
# Stand up a fake incomplete locale under a temp locales dir.
|
||||
fake_locales = tmp_path / "locales"
|
||||
fake_locales.mkdir()
|
||||
(fake_locales / "en.yaml").write_text("foo: English Foo\n", encoding="utf-8")
|
||||
(fake_locales / "zh.yaml").write_text("# intentionally empty\n", encoding="utf-8")
|
||||
monkeypatch.setattr(i18n, "_locales_dir", lambda: fake_locales)
|
||||
i18n.reset_language_cache()
|
||||
assert i18n.t("foo", lang="zh") == "English Foo"
|
||||
|
||||
|
||||
def test_t_unknown_language_uses_english():
|
||||
"""Unknown lang codes normalize to English, not to a key-path fallback."""
|
||||
assert i18n.t("approval.denied", lang="klingon") == i18n.t("approval.denied", lang="en")
|
||||
|
|
@ -19,7 +19,7 @@ class TestBuildOrHeaders:
|
|||
|
||||
headers = build_or_headers(or_config={"response_cache": False})
|
||||
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
|
||||
assert headers["X-OpenRouter-Title"] == "Hermes Agent"
|
||||
assert headers["X-Title"] == "Hermes Agent"
|
||||
assert headers["X-OpenRouter-Categories"] == "productivity,cli-agent"
|
||||
|
||||
def test_cache_enabled(self):
|
||||
|
|
|
|||
|
|
@ -788,6 +788,7 @@ class TestPromptBuilderConstants:
|
|||
assert "discord" in PLATFORM_HINTS
|
||||
assert "cron" in PLATFORM_HINTS
|
||||
assert "cli" in PLATFORM_HINTS
|
||||
assert "api_server" in PLATFORM_HINTS
|
||||
|
||||
def test_cli_hint_does_not_suggest_media_tags(self):
|
||||
# Regression: MEDIA:/path tags are intercepted only by messaging
|
||||
|
|
|
|||
229
tests/agent/test_think_scrubber.py
Normal file
229
tests/agent/test_think_scrubber.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Tests for StreamingThinkScrubber.
|
||||
|
||||
These tests lock in the contract the scrubber must satisfy so downstream
|
||||
consumers (ACP, api_server, TTS, CLI, gateway) never see reasoning
|
||||
blocks leaking through the stream_delta_callback. The scenarios map
|
||||
directly to the MiniMax-M2.7 / DeepSeek / Qwen3 streaming patterns that
|
||||
break the older per-delta regex strip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.think_scrubber import StreamingThinkScrubber
|
||||
|
||||
|
||||
def _drive(scrubber: StreamingThinkScrubber, deltas: list[str]) -> str:
|
||||
"""Feed a sequence of deltas and return the concatenated visible output."""
|
||||
out = [scrubber.feed(d) for d in deltas]
|
||||
out.append(scrubber.flush())
|
||||
return "".join(out)
|
||||
|
||||
|
||||
class TestClosedPairs:
|
||||
"""Closed <tag>...</tag> pairs are always stripped, regardless of boundary."""
|
||||
|
||||
def test_closed_pair_single_delta(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
assert _drive(s, ["<think>reasoning</think>Hello world"]) == "Hello world"
|
||||
|
||||
def test_closed_pair_surrounded_by_content(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
assert _drive(s, ["Hello <think>note</think> world"]) == "Hello world"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag",
|
||||
["think", "thinking", "reasoning", "thought", "REASONING_SCRATCHPAD"],
|
||||
)
|
||||
def test_all_tag_variants(self, tag: str) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
delta = f"<{tag}>x</{tag}>Hello"
|
||||
assert _drive(s, [delta]) == "Hello"
|
||||
|
||||
def test_case_insensitive_pair(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
assert _drive(s, ["<THINK>x</Think>Hello"]) == "Hello"
|
||||
|
||||
|
||||
class TestUnterminatedOpen:
|
||||
"""Unterminated open tag discards all subsequent content to end of stream."""
|
||||
|
||||
def test_open_at_stream_start(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
assert _drive(s, ["<think>reasoning text with no close"]) == ""
|
||||
|
||||
def test_open_after_newline(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
# 'Hello\n' is a block boundary for the <think> that follows
|
||||
assert _drive(s, ["Hello\n<think>reasoning"]) == "Hello\n"
|
||||
|
||||
def test_open_after_newline_then_whitespace(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
assert _drive(s, ["Hello\n <think>reasoning"]) == "Hello\n "
|
||||
|
||||
def test_prose_mentioning_tag_not_stripped(self) -> None:
|
||||
"""Mid-line '<think>' in prose is preserved (no boundary)."""
|
||||
s = StreamingThinkScrubber()
|
||||
text = "Use the <think> element for reasoning"
|
||||
assert _drive(s, [text]) == text
|
||||
|
||||
|
||||
class TestOrphanClose:
|
||||
"""Orphan close tags (no prior open) are stripped without boundary check."""
|
||||
|
||||
def test_orphan_close_alone(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
assert _drive(s, ["Hello</think>world"]) == "Helloworld"
|
||||
|
||||
def test_orphan_close_with_trailing_space_consumed(self) -> None:
|
||||
"""Matches _strip_think_blocks case 3 \\s* behaviour."""
|
||||
s = StreamingThinkScrubber()
|
||||
assert _drive(s, ["Hello</think> world"]) == "Helloworld"
|
||||
|
||||
def test_multiple_orphan_closes(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
assert _drive(s, ["A</think>B</thinking>C"]) == "ABC"
|
||||
|
||||
|
||||
class TestPartialTagsAcrossDeltas:
|
||||
"""Partial tags at delta boundaries must be held back, not emitted raw."""
|
||||
|
||||
def test_split_open_tag_held_back(self) -> None:
|
||||
"""'<' arrives alone, 'think>' completes it on next delta."""
|
||||
s = StreamingThinkScrubber()
|
||||
# At stream start, last_emitted_ended_newline=True, so <think> at 0 is boundary
|
||||
assert (
|
||||
_drive(s, ["<", "think>reasoning</think>done"])
|
||||
== "done"
|
||||
)
|
||||
|
||||
def test_split_open_tag_not_at_boundary(self) -> None:
|
||||
"""Mid-line split '<' + 'think>X</think>' is a closed pair.
|
||||
|
||||
Closed pairs are always stripped (matching
|
||||
``_strip_think_blocks`` case 1), even without a block
|
||||
boundary — a closed pair is an intentional bounded construct.
|
||||
"""
|
||||
s = StreamingThinkScrubber()
|
||||
out = _drive(s, ["word<", "think>prose</think>more"])
|
||||
assert out == "wordmore"
|
||||
|
||||
def test_split_close_tag_held_back(self) -> None:
|
||||
"""Close tag split across deltas still closes the block."""
|
||||
s = StreamingThinkScrubber()
|
||||
assert (
|
||||
_drive(s, ["<think>reasoning<", "/think>after"])
|
||||
== "after"
|
||||
)
|
||||
|
||||
def test_split_close_tag_deep(self) -> None:
|
||||
"""Close tag can be split anywhere."""
|
||||
s = StreamingThinkScrubber()
|
||||
assert (
|
||||
_drive(s, ["<think>reasoning</th", "ink>after"])
|
||||
== "after"
|
||||
)
|
||||
|
||||
|
||||
class TestTheMiniMaxScenario:
|
||||
"""The exact pattern run_agent per-delta regex strip breaks."""
|
||||
|
||||
def test_minimax_split_open(self) -> None:
|
||||
"""delta1='<think>', delta2='Let me check', delta3='</think>done'."""
|
||||
s = StreamingThinkScrubber()
|
||||
out = _drive(s, ["<think>", "Let me check their config", "</think>", "done"])
|
||||
assert out == "done"
|
||||
|
||||
def test_minimax_split_open_with_trailing_content(self) -> None:
|
||||
"""Reasoning then closes and hands off to final content."""
|
||||
s = StreamingThinkScrubber()
|
||||
out = _drive(
|
||||
s,
|
||||
[
|
||||
"<think>",
|
||||
"The user wants to know if thinking is on",
|
||||
"</think>",
|
||||
"\n\nshow_reasoning: false — thinking is OFF.",
|
||||
],
|
||||
)
|
||||
assert out == "\n\nshow_reasoning: false — thinking is OFF."
|
||||
|
||||
def test_minimax_unterminated_reasoning_at_end(self) -> None:
|
||||
"""Unclosed reasoning at stream end is dropped entirely."""
|
||||
s = StreamingThinkScrubber()
|
||||
out = _drive(s, ["<think>", "The user wants", " to know something"])
|
||||
assert out == ""
|
||||
|
||||
|
||||
class TestResetAndReentry:
|
||||
def test_reset_clears_in_block_state(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
s.feed("<think>hanging")
|
||||
assert s._in_block is True
|
||||
s.reset()
|
||||
assert s._in_block is False
|
||||
# After reset, a new turn works cleanly
|
||||
assert _drive(s, ["Hello world"]) == "Hello world"
|
||||
|
||||
def test_reset_clears_buffered_partial_tag(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
s.feed("word<")
|
||||
assert s._buf == "<"
|
||||
s.reset()
|
||||
assert s._buf == ""
|
||||
assert _drive(s, ["fresh content"]) == "fresh content"
|
||||
|
||||
|
||||
class TestFlushBehaviour:
|
||||
def test_flush_drops_unterminated_block(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
assert s.feed("<think>reasoning with no close") == ""
|
||||
assert s.flush() == ""
|
||||
|
||||
def test_flush_emits_innocent_partial_tag_tail(self) -> None:
|
||||
"""If held-back tail turned out not to be a real tag, emit it."""
|
||||
s = StreamingThinkScrubber()
|
||||
s.feed("word<") # '<' could be a tag prefix
|
||||
# Stream ends with only '<' held back — emit it as prose.
|
||||
assert s.flush() == "<"
|
||||
|
||||
def test_flush_on_empty_scrubber(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
assert s.flush() == ""
|
||||
|
||||
|
||||
class TestRealisticStreaming:
|
||||
"""Character-by-character streaming must work as well as larger chunks."""
|
||||
|
||||
def test_char_by_char_closed_pair(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
deltas = list("<think>x</think>Hello world")
|
||||
assert _drive(s, deltas) == "Hello world"
|
||||
|
||||
def test_char_by_char_orphan_close(self) -> None:
|
||||
s = StreamingThinkScrubber()
|
||||
deltas = list("Hello</think>world")
|
||||
assert _drive(s, deltas) == "Helloworld"
|
||||
|
||||
def test_reasoning_then_real_response_first_word_preserved(self) -> None:
|
||||
"""Regression: the first word of the final response must NOT be eaten.
|
||||
|
||||
Stefan's screenshot bug — 'Let me check' was being rendered as
|
||||
' me check'. The scrubber must not consume any character of
|
||||
post-close content.
|
||||
"""
|
||||
s = StreamingThinkScrubber()
|
||||
deltas = [
|
||||
"<think>",
|
||||
"User wants to know things",
|
||||
"</think>",
|
||||
"Let me check their config.",
|
||||
]
|
||||
assert _drive(s, deltas) == "Let me check their config."
|
||||
|
||||
def test_no_tag_passthrough_is_identical(self) -> None:
|
||||
"""Streams without any reasoning tags pass through byte-for-byte."""
|
||||
s = StreamingThinkScrubber()
|
||||
deltas = ["Hello ", "world ", "how ", "are ", "you?"]
|
||||
assert _drive(s, deltas) == "Hello world how are you?"
|
||||
|
|
@ -75,6 +75,11 @@ class TestMaxTurnsResolution:
|
|||
cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "42"})
|
||||
assert cli_obj.max_turns == 42
|
||||
|
||||
def test_invalid_env_var_max_turns_falls_back_to_default(self):
|
||||
"""Invalid env values should not crash CLI init."""
|
||||
cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "not-a-number"})
|
||||
assert cli_obj.max_turns == 90
|
||||
|
||||
def test_legacy_root_max_turns_is_used_when_agent_key_exists_without_value(self):
|
||||
cli_obj = _make_cli(config_overrides={"agent": {}, "max_turns": 77})
|
||||
assert cli_obj.max_turns == 77
|
||||
|
|
|
|||
|
|
@ -111,6 +111,57 @@ def test_manual_compress_syncs_session_id_after_split():
|
|||
assert shell._pending_title is None
|
||||
|
||||
|
||||
def test_manual_compress_flushes_compressed_history_to_child_session_db():
|
||||
"""Manual /compress must persist the handoff in the continuation DB.
|
||||
|
||||
_compress_context rotates the agent to a new child session and returns a
|
||||
compressed transcript whose first messages include the handoff summary. The
|
||||
CLI then replaces its in-memory conversation_history with that transcript.
|
||||
Because the child DB starts empty, the flush must start from offset 0 rather
|
||||
than treating the compressed history as already persisted.
|
||||
"""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
old_id = shell.session_id
|
||||
new_child_id = "20260101_000000_child1"
|
||||
compressed = [
|
||||
{"role": "user", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] compacted"},
|
||||
history[-1],
|
||||
]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = old_id
|
||||
|
||||
def _fake_compress(*args, **kwargs):
|
||||
shell.agent.session_id = new_child_id
|
||||
return (compressed, "")
|
||||
|
||||
shell.agent._compress_context.side_effect = _fake_compress
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress()
|
||||
|
||||
shell.agent._flush_messages_to_session_db.assert_called_once_with(compressed, None)
|
||||
|
||||
|
||||
def test_manual_compress_does_not_flush_full_history_when_session_id_unchanged():
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = shell.session_id
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress()
|
||||
|
||||
shell.agent._flush_messages_to_session_db.assert_not_called()
|
||||
|
||||
|
||||
def test_manual_compress_no_sync_when_session_id_unchanged():
|
||||
"""If compression is a no-op (agent.session_id didn't change), the CLI
|
||||
must NOT clear _pending_title or otherwise disturb session state.
|
||||
|
|
|
|||
|
|
@ -178,6 +178,8 @@ class TestLastReasoningInResult(unittest.TestCase):
|
|||
messages = self._build_messages(reasoning="Let me think...")
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
|
|
@ -187,6 +189,8 @@ class TestLastReasoningInResult(unittest.TestCase):
|
|||
messages = self._build_messages(reasoning=None)
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
|
|
@ -201,6 +205,8 @@ class TestLastReasoningInResult(unittest.TestCase):
|
|||
]
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
|
|
@ -210,6 +216,8 @@ class TestLastReasoningInResult(unittest.TestCase):
|
|||
messages = self._build_messages(reasoning="")
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
|
|
@ -584,6 +592,8 @@ class TestEndToEndPipeline(unittest.TestCase):
|
|||
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
|
|
|
|||
|
|
@ -2055,8 +2055,8 @@ class TestParallelTick:
|
|||
"""Point the tick file lock at a per-test temp dir to avoid xdist contention."""
|
||||
lock_dir = tmp_path / "cron"
|
||||
lock_dir.mkdir()
|
||||
with patch("cron.scheduler._LOCK_DIR", lock_dir), \
|
||||
patch("cron.scheduler._LOCK_FILE", lock_dir / ".tick.lock"):
|
||||
lock_file = lock_dir / ".tick.lock"
|
||||
with patch("cron.scheduler._get_lock_paths", return_value=(lock_dir, lock_file)):
|
||||
yield
|
||||
|
||||
def test_parallel_jobs_run_concurrently(self):
|
||||
|
|
|
|||
|
|
@ -395,7 +395,12 @@ class TestAgentExecution:
|
|||
session_id="session-123",
|
||||
)
|
||||
|
||||
assert result == {"final_response": "ok"}
|
||||
# _run_agent annotates result with the effective agent.session_id
|
||||
# when it's a real string, so the response-header writer can track
|
||||
# compression-triggered session rotations (#16938). The mock agent
|
||||
# here doesn't set an explicit session_id string so the guard skips
|
||||
# the annotation — header will fall back to the provided session_id.
|
||||
assert result["final_response"] == "ok"
|
||||
assert usage == {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}
|
||||
mock_agent.run_conversation.assert_called_once_with(
|
||||
user_message="hello",
|
||||
|
|
@ -2563,3 +2568,185 @@ class TestSessionIdHeader:
|
|||
call_kwargs = mock_run.call_args.kwargs
|
||||
assert call_kwargs["conversation_history"] == []
|
||||
assert call_kwargs["session_id"] == "some-session"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# X-Hermes-Session-Key header (long-term memory scoping)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSessionKeyHeader:
|
||||
"""The session key is a stable per-channel identifier that scopes
|
||||
long-term memory (e.g. Honcho) independently of the transcript-scoped
|
||||
session_id. A third-party Web UI passes one stable key per assistant
|
||||
channel and rotates session_id on /new, matching the native
|
||||
gateway's session_key / session_id split.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_passed_to_agent_and_echoed(self, auth_adapter):
|
||||
"""X-Hermes-Session-Key reaches _run_agent as gateway_session_key and is echoed back."""
|
||||
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={
|
||||
"X-Hermes-Session-Key": "webui:user-42",
|
||||
"Authorization": "Bearer sk-secret",
|
||||
},
|
||||
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert resp.headers.get("X-Hermes-Session-Key") == "webui:user-42"
|
||||
call_kwargs = mock_run.call_args.kwargs
|
||||
assert call_kwargs["gateway_session_key"] == "webui:user-42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_independent_of_session_id(self, auth_adapter):
|
||||
"""Both headers coexist: key scopes memory, id scopes transcript."""
|
||||
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
|
||||
mock_db = MagicMock()
|
||||
mock_db.get_messages_as_conversation.return_value = []
|
||||
auth_adapter._session_db = mock_db
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={
|
||||
"X-Hermes-Session-Key": "channel-abc",
|
||||
"X-Hermes-Session-Id": "transcript-xyz",
|
||||
"Authorization": "Bearer sk-secret",
|
||||
},
|
||||
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert resp.headers.get("X-Hermes-Session-Key") == "channel-abc"
|
||||
assert resp.headers.get("X-Hermes-Session-Id") == "transcript-xyz"
|
||||
call_kwargs = mock_run.call_args.kwargs
|
||||
assert call_kwargs["gateway_session_key"] == "channel-abc"
|
||||
assert call_kwargs["session_id"] == "transcript-xyz"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_absent_yields_none(self, auth_adapter):
|
||||
"""Omitting the header passes gateway_session_key=None and doesn't echo."""
|
||||
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"Authorization": "Bearer sk-secret"},
|
||||
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert "X-Hermes-Session-Key" not in resp.headers
|
||||
call_kwargs = mock_run.call_args.kwargs
|
||||
assert call_kwargs["gateway_session_key"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_rejected_without_api_key(self, adapter):
|
||||
"""Without API_SERVER_KEY, accepting a caller-supplied memory scope is unsafe — reject with 403."""
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"X-Hermes-Session-Key": "whatever"},
|
||||
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
|
||||
)
|
||||
assert resp.status == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_rejects_control_chars(self, auth_adapter):
|
||||
"""Header injection via \\r\\n must be rejected by the server-side validator.
|
||||
|
||||
Note: aiohttp client refuses to SEND a header containing CR/LF
|
||||
(that check fires before the request leaves the client), so we
|
||||
can't reach this code path through TestClient. Test the helper
|
||||
directly instead with a raw request that bypasses client-side
|
||||
validation.
|
||||
"""
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"X-Hermes-Session-Key": "bad\rvalue"}
|
||||
key, err = auth_adapter._parse_session_key_header(mock_request)
|
||||
assert key is None
|
||||
assert err is not None
|
||||
assert err.status == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_rejects_oversized(self, auth_adapter):
|
||||
"""Session keys longer than the cap are rejected."""
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"X-Hermes-Session-Key": "x" * 1000, "Authorization": "Bearer sk-secret"},
|
||||
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_threads_into_create_agent(self, auth_adapter):
|
||||
"""End-to-end: verify AIAgent(gateway_session_key=...) receives the key via _create_agent."""
|
||||
captured_kwargs = {}
|
||||
|
||||
def _fake_create_agent(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok", "messages": []}
|
||||
mock_agent.session_prompt_tokens = 0
|
||||
mock_agent.session_completion_tokens = 0
|
||||
mock_agent.session_total_tokens = 0
|
||||
return mock_agent
|
||||
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(auth_adapter, "_create_agent", side_effect=_fake_create_agent):
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={
|
||||
"X-Hermes-Session-Key": "agent:main:webui:dm:user-7",
|
||||
"Authorization": "Bearer sk-secret",
|
||||
},
|
||||
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
|
||||
)
|
||||
assert resp.status == 200
|
||||
# _create_agent must be called with gateway_session_key threaded through
|
||||
assert captured_kwargs.get("gateway_session_key") == "agent:main:webui:dm:user-7"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_responses_endpoint_accepts_session_key(self, auth_adapter):
|
||||
"""Responses API honors the same X-Hermes-Session-Key contract."""
|
||||
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
resp = await cli.post(
|
||||
"/v1/responses",
|
||||
headers={
|
||||
"X-Hermes-Session-Key": "webui:chan-1",
|
||||
"Authorization": "Bearer sk-secret",
|
||||
},
|
||||
json={"model": "hermes-agent", "input": "hello", "store": False},
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert resp.headers.get("X-Hermes-Session-Key") == "webui:chan-1"
|
||||
call_kwargs = mock_run.call_args.kwargs
|
||||
assert call_kwargs["gateway_session_key"] == "webui:chan-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capabilities_advertises_session_key_header(self, adapter):
|
||||
"""GET /v1/capabilities should advertise the new header so clients can feature-detect."""
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/v1/capabilities")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["features"]["session_key_header"] == "X-Hermes-Session-Key"
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from unittest.mock import MagicMock, AsyncMock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides
|
||||
from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides, load_gateway_config
|
||||
|
||||
|
||||
def _ensure_discord_mock():
|
||||
|
|
@ -396,3 +396,67 @@ class TestReplyToText:
|
|||
event = reply_text_adapter.handle_message.await_args.args[0]
|
||||
assert event.reply_to_message_id == "555"
|
||||
assert event.reply_to_text is None
|
||||
|
||||
|
||||
class TestYamlConfigLoading:
|
||||
"""Tests for reply_to_mode loaded from config.yaml discord section."""
|
||||
|
||||
def _write_config(self, tmp_path, content: str):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(content, encoding="utf-8")
|
||||
return hermes_home
|
||||
|
||||
def test_top_level_reply_to_mode_off(self, tmp_path, monkeypatch):
|
||||
"""YAML 1.1 parses bare 'off' as boolean False — must map back to 'off'."""
|
||||
hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: off\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "off"
|
||||
|
||||
def test_top_level_reply_to_mode_all(self, tmp_path, monkeypatch):
|
||||
hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: all\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "all"
|
||||
|
||||
def test_extra_reply_to_mode_off(self, tmp_path, monkeypatch):
|
||||
"""discord.extra.reply_to_mode is also honoured."""
|
||||
hermes_home = self._write_config(
|
||||
tmp_path, "discord:\n extra:\n reply_to_mode: \"off\"\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "off"
|
||||
|
||||
def test_env_var_takes_precedence_over_yaml(self, tmp_path, monkeypatch):
|
||||
"""Existing DISCORD_REPLY_TO_MODE env var is not overwritten by YAML."""
|
||||
hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: all\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("DISCORD_REPLY_TO_MODE", "first")
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "first"
|
||||
|
||||
def test_top_level_takes_precedence_over_extra(self, tmp_path, monkeypatch):
|
||||
"""discord.reply_to_mode wins over discord.extra.reply_to_mode."""
|
||||
hermes_home = self._write_config(
|
||||
tmp_path,
|
||||
"discord:\n reply_to_mode: all\n extra:\n reply_to_mode: \"off\"\n",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "all"
|
||||
|
|
|
|||
|
|
@ -67,6 +67,21 @@ class TestDiscordThreadPersistence:
|
|||
|
||||
saved = json.loads((tmp_path / "discord_threads.json").read_text())
|
||||
assert len(saved) == 5
|
||||
assert saved == ["5", "6", "7", "8", "9"]
|
||||
|
||||
def test_capacity_keeps_newest_thread_when_existing_state_is_full(self, tmp_path):
|
||||
"""A newly joined thread must not be evicted by unordered set iteration."""
|
||||
state_file = tmp_path / "discord_threads.json"
|
||||
state_file.write_text(json.dumps(["0", "1", "2", "3", "4"]), encoding="utf-8")
|
||||
adapter = self._make_adapter(tmp_path)
|
||||
adapter._threads._max_tracked = 5
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
adapter._threads.mark("newest")
|
||||
|
||||
saved = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert saved == ["1", "2", "3", "4", "newest"]
|
||||
assert "newest" in adapter._threads
|
||||
|
||||
def test_corrupted_state_file_falls_back_to_empty(self, tmp_path):
|
||||
state_file = tmp_path / "discord_threads.json"
|
||||
|
|
|
|||
|
|
@ -2817,20 +2817,32 @@ class TestHydrateBotIdentity(unittest.TestCase):
|
|||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_hydration_skipped_when_env_vars_supply_both_fields(self):
|
||||
def test_hydration_refreshes_env_values_when_bot_info_available(self):
|
||||
adapter = self._make_adapter()
|
||||
adapter._client = Mock()
|
||||
adapter._client.request = Mock()
|
||||
payload = json.dumps(
|
||||
{
|
||||
"code": 0,
|
||||
"bot": {
|
||||
"bot_name": "Hydrated Hermes",
|
||||
"open_id": "ou_hydrated",
|
||||
},
|
||||
}
|
||||
).encode("utf-8")
|
||||
adapter._client.request = Mock(return_value=SimpleNamespace(raw=SimpleNamespace(content=payload)))
|
||||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
adapter._client.request.assert_not_called()
|
||||
self.assertEqual(adapter._bot_open_id, "ou_env")
|
||||
self.assertEqual(adapter._bot_name, "Env Hermes")
|
||||
# PR #16993 semantics: /bot/v3/info probe runs unconditionally
|
||||
# and hydrated values win over env vars so a stale FEISHU_BOT_*
|
||||
# from an old app registration doesn't break @mention gating.
|
||||
adapter._client.request.assert_called_once()
|
||||
self.assertEqual(adapter._bot_open_id, "ou_hydrated")
|
||||
self.assertEqual(adapter._bot_name, "Hydrated Hermes")
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_BOT_OPEN_ID": "ou_env"}, clear=True)
|
||||
def test_hydration_fills_only_missing_fields(self):
|
||||
"""Env-var open_id must NOT be overwritten by a different probe value."""
|
||||
def test_hydration_overwrites_stale_env_open_id(self):
|
||||
"""A stale env open_id should not break group mention gating after app migration."""
|
||||
adapter = self._make_adapter()
|
||||
adapter._client = Mock()
|
||||
payload = json.dumps(
|
||||
|
|
@ -2846,9 +2858,27 @@ class TestHydrateBotIdentity(unittest.TestCase):
|
|||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
self.assertEqual(adapter._bot_open_id, "ou_env") # preserved
|
||||
self.assertEqual(adapter._bot_open_id, "ou_probe_DIFFERENT")
|
||||
self.assertEqual(adapter._bot_name, "Hermes Bot") # filled in
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FEISHU_BOT_OPEN_ID": "ou_env",
|
||||
"FEISHU_BOT_NAME": "Env Hermes",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_hydration_preserves_env_values_when_bot_info_probe_fails(self):
|
||||
adapter = self._make_adapter()
|
||||
adapter._client = Mock()
|
||||
adapter._client.request = Mock(side_effect=RuntimeError("network down"))
|
||||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
self.assertEqual(adapter._bot_open_id, "ou_env")
|
||||
self.assertEqual(adapter._bot_name, "Env Hermes")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_hydration_tolerates_probe_failure_and_falls_back_to_app_info(self):
|
||||
adapter = self._make_adapter()
|
||||
|
|
@ -3167,6 +3197,37 @@ class TestDedupTTL(unittest.TestCase):
|
|||
with patch.object(adapter, "_persist_seen_message_ids"):
|
||||
self.assertFalse(adapter._is_duplicate("om_old"))
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_load_tolerates_malformed_timestamp_values(self):
|
||||
"""Regression #13632 — a non-numeric timestamp in the persisted
|
||||
dedup state must not crash adapter startup. The bad key is
|
||||
skipped; the rest of the state loads.
|
||||
"""
|
||||
import tempfile
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_home:
|
||||
with patch.dict(os.environ, {"HERMES_HOME": temp_home}, clear=True):
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._dedup_state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
adapter._dedup_state_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"message_ids": {
|
||||
"om_good": time.time(),
|
||||
"om_bad_str": "not-a-timestamp",
|
||||
"om_bad_null": None,
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
adapter._load_seen_message_ids()
|
||||
assert "om_good" in adapter._seen_message_ids
|
||||
assert "om_bad_str" not in adapter._seen_message_ids
|
||||
assert "om_bad_null" not in adapter._seen_message_ids
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_persist_saves_timestamps_as_dict(self):
|
||||
from gateway.config import PlatformConfig
|
||||
|
|
|
|||
|
|
@ -492,6 +492,16 @@ class TestGetHumanDelay:
|
|||
delay = BasePlatformAdapter._get_human_delay()
|
||||
assert 0.8 <= delay <= 2.5
|
||||
|
||||
def test_natural_mode_ignores_malformed_custom_env_vars(self):
|
||||
env = {
|
||||
"HERMES_HUMAN_DELAY_MODE": "natural",
|
||||
"HERMES_HUMAN_DELAY_MIN_MS": "oops",
|
||||
"HERMES_HUMAN_DELAY_MAX_MS": "still-bad",
|
||||
}
|
||||
with patch.dict(os.environ, env):
|
||||
delay = BasePlatformAdapter._get_human_delay()
|
||||
assert 0.8 <= delay <= 2.5
|
||||
|
||||
def test_custom_mode_uses_env_vars(self):
|
||||
env = {
|
||||
"HERMES_HUMAN_DELAY_MODE": "custom",
|
||||
|
|
@ -502,6 +512,17 @@ class TestGetHumanDelay:
|
|||
delay = BasePlatformAdapter._get_human_delay()
|
||||
assert 0.1 <= delay <= 0.2
|
||||
|
||||
def test_custom_mode_tolerates_malformed_env_vars(self):
|
||||
env = {
|
||||
"HERMES_HUMAN_DELAY_MODE": "custom",
|
||||
"HERMES_HUMAN_DELAY_MIN_MS": "oops",
|
||||
"HERMES_HUMAN_DELAY_MAX_MS": "still-bad",
|
||||
}
|
||||
with patch.dict(os.environ, env):
|
||||
# falls back to the custom-mode defaults instead of crashing
|
||||
delay = BasePlatformAdapter._get_human_delay()
|
||||
assert 0.8 <= delay <= 2.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# utf16_len / _prefix_within_utf16_limit / truncate_message with len_fn
|
||||
|
|
|
|||
|
|
@ -1,412 +0,0 @@
|
|||
"""Tests for the gateway stale-code self-check (Issue #17648).
|
||||
|
||||
A gateway that survives ``hermes update`` keeps pre-update modules cached
|
||||
in ``sys.modules``. Later imports of names added post-update (e.g.
|
||||
``cfg_get`` from PR #17304) raise ImportError against the stale module
|
||||
object.
|
||||
|
||||
The self-check compares the git HEAD SHA at boot to the current SHA on
|
||||
disk. ``hermes update`` always moves HEAD forward via ``git pull``;
|
||||
agent-driven file edits (Hermes editing ``run_agent.py`` / ``gateway/run.py``
|
||||
during a self-dev session) never move HEAD — so the SHA signal is free of
|
||||
the false-positive class that the earlier mtime-based check suffered from.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.run import (
|
||||
GatewayRunner,
|
||||
_compute_repo_mtime,
|
||||
_read_git_head_sha,
|
||||
_STALE_CODE_SENTINELS,
|
||||
_GIT_SHA_CACHE_TTL_SECS,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_tmp_repo(tmp_path: Path) -> Path:
|
||||
"""Create a fake repo with all stale-code sentinel files."""
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
p = tmp_path / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text("# test sentinel\n")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _make_git_repo(tmp_path: Path, sha: str = "a" * 40, branch: str = "main") -> Path:
|
||||
"""Stamp a minimal .git directory so _read_git_head_sha can resolve a SHA.
|
||||
|
||||
We don't run real git — just lay down the files the reader walks
|
||||
(.git/HEAD pointing at refs/heads/<branch>, refs/heads/<branch>
|
||||
containing the SHA).
|
||||
"""
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir(parents=True, exist_ok=True)
|
||||
(git_dir / "HEAD").write_text(f"ref: refs/heads/{branch}\n")
|
||||
refs_dir = git_dir / "refs" / "heads"
|
||||
refs_dir.mkdir(parents=True, exist_ok=True)
|
||||
(refs_dir / branch).write_text(f"{sha}\n")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _set_head_sha(repo_root: Path, sha: str, branch: str = "main") -> None:
|
||||
"""Rewrite the current branch ref to a new SHA (simulates git pull)."""
|
||||
(repo_root / ".git" / "refs" / "heads" / branch).write_text(f"{sha}\n")
|
||||
|
||||
|
||||
def _make_runner(
|
||||
repo_root: Path,
|
||||
*,
|
||||
boot_sha: str | None,
|
||||
boot_wall: float = None,
|
||||
boot_mtime: float = 0.0,
|
||||
):
|
||||
"""Bare GatewayRunner with just the stale-check attributes set."""
|
||||
if boot_wall is None:
|
||||
boot_wall = time.time()
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._repo_root_for_staleness = repo_root
|
||||
runner._boot_wall_time = boot_wall
|
||||
runner._boot_git_sha = boot_sha
|
||||
runner._boot_repo_mtime = boot_mtime
|
||||
runner._stale_code_notified = set()
|
||||
runner._stale_code_restart_triggered = False
|
||||
runner._cached_current_sha = boot_sha
|
||||
runner._cached_current_sha_at = boot_wall
|
||||
return runner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_git_head_sha — raw SHA reader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_read_git_head_sha_branch_ref(tmp_path):
|
||||
"""Resolves ref: refs/heads/<branch> → SHA from refs/heads/<branch>."""
|
||||
sha = "b" * 40
|
||||
_make_git_repo(tmp_path, sha=sha, branch="main")
|
||||
assert _read_git_head_sha(tmp_path) == sha
|
||||
|
||||
|
||||
def test_read_git_head_sha_detached_head(tmp_path):
|
||||
"""Detached HEAD: .git/HEAD contains the SHA directly."""
|
||||
sha = "c" * 40
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
(git_dir / "HEAD").write_text(f"{sha}\n")
|
||||
assert _read_git_head_sha(tmp_path) == sha
|
||||
|
||||
|
||||
def test_read_git_head_sha_packed_refs(tmp_path):
|
||||
"""Falls back to packed-refs when refs/heads/<branch> is missing."""
|
||||
sha = "d" * 40
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
(git_dir / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
# No refs/heads/main file — only packed-refs
|
||||
(git_dir / "packed-refs").write_text(
|
||||
f"# pack-refs with: peeled fully-peeled sorted\n"
|
||||
f"{sha} refs/heads/main\n"
|
||||
)
|
||||
assert _read_git_head_sha(tmp_path) == sha
|
||||
|
||||
|
||||
def test_read_git_head_sha_worktree_gitdir_file(tmp_path):
|
||||
"""Worktree: .git is a file with `gitdir: <path>` pointing to the real git dir.
|
||||
|
||||
Real git worktrees store shared refs (refs/heads/*) in the main
|
||||
checkout's .git/ and write a ``commondir`` pointer into the
|
||||
worktree-gitdir. The reader must follow commondir to resolve the
|
||||
branch ref — this is the layout Hermes dev sessions actually use.
|
||||
"""
|
||||
sha = "e" * 40
|
||||
# Main repo layout
|
||||
main_repo = tmp_path / "main-repo"
|
||||
main_git = main_repo / ".git"
|
||||
(main_git / "refs" / "heads").mkdir(parents=True)
|
||||
(main_git / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
(main_git / "refs" / "heads" / "main").write_text("0" * 40 + "\n")
|
||||
|
||||
# Worktree lives in main-repo/.git/worktrees/<name>/
|
||||
worktree_git_dir = main_git / "worktrees" / "feature"
|
||||
worktree_git_dir.mkdir(parents=True)
|
||||
(worktree_git_dir / "HEAD").write_text("ref: refs/heads/feature\n")
|
||||
# commondir points back at the main .git (relative path, "../..")
|
||||
(worktree_git_dir / "commondir").write_text("../..\n")
|
||||
# Feature branch ref lives in the shared refs/heads
|
||||
(main_git / "refs" / "heads" / "feature").write_text(f"{sha}\n")
|
||||
|
||||
# Worktree checkout with .git file pointing at worktree_git_dir
|
||||
worktree = tmp_path / "wt"
|
||||
worktree.mkdir()
|
||||
(worktree / ".git").write_text(f"gitdir: {worktree_git_dir}\n")
|
||||
|
||||
assert _read_git_head_sha(worktree) == sha
|
||||
|
||||
|
||||
def test_read_git_head_sha_worktree_packed_refs_in_common(tmp_path):
|
||||
"""Worktree + packed-refs in common dir: fallback still resolves."""
|
||||
sha = "f" * 40
|
||||
main_repo = tmp_path / "main-repo"
|
||||
main_git = main_repo / ".git"
|
||||
main_git.mkdir(parents=True)
|
||||
(main_git / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
# packed-refs in the common (main) .git
|
||||
(main_git / "packed-refs").write_text(
|
||||
f"# pack-refs with: peeled fully-peeled sorted\n"
|
||||
f"{sha} refs/heads/feature\n"
|
||||
)
|
||||
|
||||
worktree_git_dir = main_git / "worktrees" / "feature"
|
||||
worktree_git_dir.mkdir(parents=True)
|
||||
(worktree_git_dir / "HEAD").write_text("ref: refs/heads/feature\n")
|
||||
(worktree_git_dir / "commondir").write_text("../..\n")
|
||||
|
||||
worktree = tmp_path / "wt"
|
||||
worktree.mkdir()
|
||||
(worktree / ".git").write_text(f"gitdir: {worktree_git_dir}\n")
|
||||
|
||||
assert _read_git_head_sha(worktree) == sha
|
||||
|
||||
|
||||
def test_read_git_head_sha_no_git_returns_none(tmp_path):
|
||||
"""No .git dir → None (non-git install, safely disables the check)."""
|
||||
assert _read_git_head_sha(tmp_path) is None
|
||||
|
||||
|
||||
def test_read_git_head_sha_malformed_head_returns_none(tmp_path):
|
||||
"""Empty HEAD file → None (don't loop on corrupt repos)."""
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
(git_dir / "HEAD").write_text("")
|
||||
assert _read_git_head_sha(tmp_path) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_stale_code — the main regression guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_detect_stale_code_false_when_sha_unchanged(tmp_path):
|
||||
"""Boot SHA == current SHA → not stale (no restart)."""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
# Force fresh read by expiring the cache
|
||||
runner._cached_current_sha_at = 0.0
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
def test_detect_stale_code_true_after_git_pull(tmp_path):
|
||||
"""Boot SHA != current SHA → stale (hermes update happened)."""
|
||||
boot_sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=boot_sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=boot_sha)
|
||||
# Simulate git pull moving HEAD forward
|
||||
_set_head_sha(tmp_path, "b" * 40)
|
||||
runner._cached_current_sha_at = 0.0 # expire cache
|
||||
assert runner._detect_stale_code() is True
|
||||
|
||||
|
||||
def test_detect_stale_code_ignores_agent_file_edits(tmp_path):
|
||||
"""THE CORE REGRESSION: agent edits to source files do NOT trigger restart.
|
||||
|
||||
This is the motivating incident for the SHA-based check. Under the
|
||||
previous mtime-based scheme, any ``patch`` / ``write_file`` call
|
||||
against run_agent.py / gateway/run.py / hermes_cli/config.py would
|
||||
flip the stale-check to True and force a gateway restart on the
|
||||
next message — even though no update actually happened. SHA
|
||||
comparison decouples the two: git HEAD only moves on ``git pull``,
|
||||
never on file writes.
|
||||
"""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
_make_tmp_repo(tmp_path) # lay down sentinel files too
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
|
||||
# Simulate the agent editing run_agent.py and gateway/run.py with
|
||||
# mtimes far into the future — exactly the scenario that used to
|
||||
# false-positive the old mtime check.
|
||||
future = time.time() + 10_000
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
p = tmp_path / rel
|
||||
if p.is_file():
|
||||
p.write_text("# agent just edited this\n")
|
||||
os.utime(p, (future, future))
|
||||
|
||||
# HEAD SHA has NOT moved — check must stay False.
|
||||
runner._cached_current_sha_at = 0.0 # expire cache
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
def test_detect_stale_code_false_for_non_git_install(tmp_path):
|
||||
"""Non-git install (no .git dir) → check disabled, never fires."""
|
||||
# No .git dir at all; runner's boot_sha is None
|
||||
runner = _make_runner(tmp_path, boot_sha=None)
|
||||
# Even if we pretended the current SHA differed, the check should
|
||||
# short-circuit on boot_sha=None and return False.
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
def test_detect_stale_code_false_when_no_boot_wall_time(tmp_path):
|
||||
"""No boot snapshot at all → can't tell → not stale (no restart loop)."""
|
||||
runner = _make_runner(tmp_path, boot_sha="a" * 40, boot_wall=0.0)
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
def test_detect_stale_code_handles_disappearing_git_dir(tmp_path):
|
||||
""".git vanishes mid-run → current_sha = None → not stale (don't loop)."""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
# Nuke the git dir after boot
|
||||
import shutil
|
||||
shutil.rmtree(tmp_path / ".git")
|
||||
runner._cached_current_sha_at = 0.0 # expire cache
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SHA cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_current_sha_cache_collapses_bursts(tmp_path, monkeypatch):
|
||||
"""Consecutive calls inside the TTL window reuse the cached SHA."""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
|
||||
read_calls = {"n": 0}
|
||||
real_reader = _read_git_head_sha
|
||||
|
||||
def counting_reader(repo_root):
|
||||
read_calls["n"] += 1
|
||||
return real_reader(repo_root)
|
||||
|
||||
from gateway import run as run_mod
|
||||
monkeypatch.setattr(run_mod, "_read_git_head_sha", counting_reader)
|
||||
|
||||
# Force cache expiry so the first call definitely reads
|
||||
runner._cached_current_sha_at = 0.0
|
||||
runner._current_git_sha_cached()
|
||||
first_count = read_calls["n"]
|
||||
|
||||
# Immediate second/third calls should hit cache (no new read)
|
||||
runner._current_git_sha_cached()
|
||||
runner._current_git_sha_cached()
|
||||
assert read_calls["n"] == first_count
|
||||
|
||||
|
||||
def test_current_sha_cache_expires_after_ttl(tmp_path, monkeypatch):
|
||||
"""After _GIT_SHA_CACHE_TTL_SECS elapses, a fresh read happens."""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
|
||||
read_calls = {"n": 0}
|
||||
real_reader = _read_git_head_sha
|
||||
|
||||
def counting_reader(repo_root):
|
||||
read_calls["n"] += 1
|
||||
return real_reader(repo_root)
|
||||
|
||||
from gateway import run as run_mod
|
||||
monkeypatch.setattr(run_mod, "_read_git_head_sha", counting_reader)
|
||||
|
||||
runner._cached_current_sha_at = 0.0
|
||||
runner._current_git_sha_cached()
|
||||
first = read_calls["n"]
|
||||
|
||||
# Age the cache past the TTL
|
||||
runner._cached_current_sha_at = time.time() - (_GIT_SHA_CACHE_TTL_SECS + 1.0)
|
||||
runner._current_git_sha_cached()
|
||||
assert read_calls["n"] == first + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _trigger_stale_code_restart — idempotency preserved
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_trigger_stale_code_restart_is_idempotent(tmp_path):
|
||||
"""Calling _trigger_stale_code_restart twice only requests restart once."""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_request_restart(*, detached=False, via_service=False):
|
||||
calls.append((detached, via_service))
|
||||
return True
|
||||
|
||||
runner.request_restart = fake_request_restart
|
||||
|
||||
runner._trigger_stale_code_restart()
|
||||
runner._trigger_stale_code_restart()
|
||||
runner._trigger_stale_code_restart()
|
||||
|
||||
assert len(calls) == 1
|
||||
assert runner._stale_code_restart_triggered is True
|
||||
|
||||
|
||||
def test_trigger_stale_code_restart_survives_request_failure(tmp_path):
|
||||
"""If request_restart raises, we swallow and mark as triggered anyway."""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
|
||||
def boom(*, detached=False, via_service=False):
|
||||
raise RuntimeError("no event loop")
|
||||
|
||||
runner.request_restart = boom
|
||||
|
||||
# Should not raise
|
||||
runner._trigger_stale_code_restart()
|
||||
|
||||
# Marked triggered so we don't retry on every subsequent message
|
||||
assert runner._stale_code_restart_triggered is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Class-level defaults — tests that build bare runners via object.__new__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_class_level_defaults_prevent_uninitialized_access():
|
||||
"""Partial construction via object.__new__ must not crash _detect_stale_code."""
|
||||
runner = object.__new__(GatewayRunner)
|
||||
# Don't set any instance attrs — class-level defaults should kick in
|
||||
runner._repo_root_for_staleness = Path(".")
|
||||
# _boot_wall_time / _boot_git_sha fall through to class defaults
|
||||
# (0.0 and None respectively)
|
||||
assert runner._detect_stale_code() is False
|
||||
# _stale_code_restart_triggered falls through to class default (False)
|
||||
assert runner._stale_code_restart_triggered is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy mtime reader kept for compatibility — light sanity check only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_compute_repo_mtime_still_returns_newest(tmp_path):
|
||||
"""_compute_repo_mtime remains available for any legacy callers."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
|
||||
baseline = time.time() - 100
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
|
||||
newer = time.time()
|
||||
os.utime(repo / "hermes_cli/config.py", (newer, newer))
|
||||
|
||||
result = _compute_repo_mtime(repo)
|
||||
assert abs(result - newer) < 1.0
|
||||
|
||||
|
||||
def test_compute_repo_mtime_missing_files_returns_zero(tmp_path):
|
||||
"""Legacy sanity: missing sentinels → 0.0."""
|
||||
assert _compute_repo_mtime(tmp_path) == 0.0
|
||||
|
|
@ -534,15 +534,20 @@ class TestDiscoverFallbackIps:
|
|||
assert "149.154.167.221" in ips
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_dns_ip_excluded(self, monkeypatch):
|
||||
"""The IP from system DNS is the one that doesn't work — exclude it."""
|
||||
async def test_system_dns_ip_kept_when_doh_confirms(self, monkeypatch):
|
||||
"""DoH-confirmed IPs are kept even when they match system DNS (#14520).
|
||||
|
||||
The system-DNS IP is often the most reliable path; including it as a
|
||||
fallback lets the IP-rewrite retry recover from transient primary-path
|
||||
failures instead of jumping straight to the hardcoded seed list.
|
||||
"""
|
||||
self._patch_doh(monkeypatch, {
|
||||
"https://dns.google": (200, _doh_answer("149.154.166.110", "149.154.167.220")),
|
||||
"https://cloudflare-dns.com": (200, _doh_answer("149.154.166.110")),
|
||||
}, system_dns_ips=["149.154.166.110"])
|
||||
|
||||
ips = await tnet.discover_fallback_ips()
|
||||
assert ips == ["149.154.167.220"]
|
||||
assert ips == ["149.154.166.110", "149.154.167.220"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doh_results_deduplicated(self, monkeypatch):
|
||||
|
|
@ -607,15 +612,21 @@ class TestDiscoverFallbackIps:
|
|||
assert "149.154.167.220" in ips
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_doh_ips_same_as_system_dns_uses_seed(self, monkeypatch):
|
||||
"""DoH returns only the same blocked IP — seed list is the fallback."""
|
||||
async def test_all_doh_ips_same_as_system_dns_kept(self, monkeypatch):
|
||||
"""DoH agrees with system DNS — keep that IP instead of seed list (#14520).
|
||||
|
||||
Previous behavior fell through to ``_SEED_FALLBACK_IPS`` here, but the
|
||||
seed addresses are not routable on every network. When DoH confirms
|
||||
the system IP, that IP is the best candidate we have and should be
|
||||
used as the fallback target.
|
||||
"""
|
||||
self._patch_doh(monkeypatch, {
|
||||
"https://dns.google": (200, _doh_answer("149.154.166.110")),
|
||||
"https://cloudflare-dns.com": (200, _doh_answer("149.154.166.110")),
|
||||
}, system_dns_ips=["149.154.166.110"])
|
||||
|
||||
ips = await tnet.discover_fallback_ips()
|
||||
assert ips == tnet._SEED_FALLBACK_IPS
|
||||
assert ips == ["149.154.166.110"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloudflare_gets_accept_header(self, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from unittest.mock import MagicMock, AsyncMock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides
|
||||
from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides, load_gateway_config
|
||||
|
||||
|
||||
def _ensure_telegram_mock():
|
||||
|
|
@ -240,3 +240,67 @@ class TestEnvVarOverride:
|
|||
with patch.dict(os.environ, {"TELEGRAM_REPLY_TO_MODE": ""}, clear=False):
|
||||
_apply_env_overrides(config)
|
||||
assert config.platforms[Platform.TELEGRAM].reply_to_mode == "first"
|
||||
|
||||
|
||||
class TestTelegramYamlConfigLoading:
|
||||
"""Tests for reply_to_mode loaded from config.yaml telegram section."""
|
||||
|
||||
def _write_config(self, tmp_path, content: str):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(content, encoding="utf-8")
|
||||
return hermes_home
|
||||
|
||||
def test_top_level_reply_to_mode_off(self, tmp_path, monkeypatch):
|
||||
"""YAML 1.1 parses bare 'off' as boolean False — must map back to 'off'."""
|
||||
hermes_home = self._write_config(tmp_path, "telegram:\n reply_to_mode: off\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False)
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "off"
|
||||
|
||||
def test_top_level_reply_to_mode_all(self, tmp_path, monkeypatch):
|
||||
hermes_home = self._write_config(tmp_path, "telegram:\n reply_to_mode: all\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False)
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "all"
|
||||
|
||||
def test_extra_reply_to_mode_off(self, tmp_path, monkeypatch):
|
||||
"""telegram.extra.reply_to_mode is also honoured."""
|
||||
hermes_home = self._write_config(
|
||||
tmp_path, "telegram:\n extra:\n reply_to_mode: \"off\"\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False)
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "off"
|
||||
|
||||
def test_env_var_takes_precedence_over_yaml(self, tmp_path, monkeypatch):
|
||||
"""Existing TELEGRAM_REPLY_TO_MODE env var is not overwritten by YAML."""
|
||||
hermes_home = self._write_config(tmp_path, "telegram:\n reply_to_mode: all\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("TELEGRAM_REPLY_TO_MODE", "first")
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "first"
|
||||
|
||||
def test_top_level_takes_precedence_over_extra(self, tmp_path, monkeypatch):
|
||||
"""telegram.reply_to_mode wins over telegram.extra.reply_to_mode."""
|
||||
hermes_home = self._write_config(
|
||||
tmp_path,
|
||||
"telegram:\n reply_to_mode: all\n extra:\n reply_to_mode: \"off\"\n",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False)
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "all"
|
||||
|
|
|
|||
|
|
@ -459,8 +459,9 @@ class TestWatchUpdateProgress:
|
|||
async def test_prompt_forwarded_only_once(self, tmp_path):
|
||||
"""Regression: prompt must not be re-sent on every poll cycle.
|
||||
|
||||
Before the fix, the watcher never deleted .update_prompt.json after
|
||||
forwarding, causing the same prompt to be sent every poll_interval.
|
||||
The in-memory pending flag should suppress duplicate sends within a
|
||||
single watcher process even when the prompt marker stays on disk for
|
||||
restart recovery.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
|
|
@ -505,6 +506,75 @@ class TestWatchUpdateProgress:
|
|||
f"All sends: {all_sent}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_is_recovered_after_watcher_restart(self, tmp_path):
|
||||
"""A forwarded prompt stays on disk until answered so a new watcher can recover it."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending = {
|
||||
"platform": "telegram",
|
||||
"chat_id": "111",
|
||||
"user_id": "222",
|
||||
"session_key": "agent:main:telegram:dm:111",
|
||||
}
|
||||
prompt = {
|
||||
"prompt": "Restore local changes? [Y/n]",
|
||||
"default": "y",
|
||||
"id": "restart-recover",
|
||||
}
|
||||
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
||||
(hermes_home / ".update_output.txt").write_text("")
|
||||
(hermes_home / ".update_prompt.json").write_text(json.dumps(prompt))
|
||||
|
||||
runner1 = _make_runner()
|
||||
adapter1 = AsyncMock()
|
||||
runner1.adapters = {Platform.TELEGRAM: adapter1}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
watch1 = asyncio.create_task(
|
||||
runner1._watch_update_progress(
|
||||
poll_interval=0.05,
|
||||
stream_interval=0.1,
|
||||
timeout=10.0,
|
||||
)
|
||||
)
|
||||
for _ in range(40):
|
||||
if adapter1.send.call_count:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert adapter1.send.call_count == 1
|
||||
assert (hermes_home / ".update_prompt.json").exists()
|
||||
|
||||
watch1.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await watch1
|
||||
|
||||
runner2 = _make_runner()
|
||||
adapter2 = AsyncMock()
|
||||
runner2.adapters = {Platform.TELEGRAM: adapter2}
|
||||
|
||||
async def respond_and_finish():
|
||||
await asyncio.sleep(0.2)
|
||||
(hermes_home / ".update_response").write_text("y")
|
||||
await asyncio.sleep(0.2)
|
||||
(hermes_home / ".update_exit_code").write_text("0")
|
||||
|
||||
finisher = asyncio.create_task(respond_and_finish())
|
||||
await runner2._watch_update_progress(
|
||||
poll_interval=0.05,
|
||||
stream_interval=0.1,
|
||||
timeout=10.0,
|
||||
)
|
||||
await finisher
|
||||
|
||||
prompt_sends = [
|
||||
str(call) for call in adapter2.send.call_args_list
|
||||
if "Restore local changes" in str(call)
|
||||
]
|
||||
assert len(prompt_sends) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message interception for update prompts
|
||||
|
|
@ -525,6 +595,7 @@ class TestUpdatePromptInterception:
|
|||
# The session key uses the full format from build_session_key
|
||||
session_key = "agent:main:telegram:dm:67890"
|
||||
runner._update_prompt_pending[session_key] = True
|
||||
(hermes_home / ".update_prompt.json").write_text(json.dumps({"prompt": "test"}))
|
||||
|
||||
# Mock authorization and _session_key_for_source
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
|
|
@ -538,6 +609,7 @@ class TestUpdatePromptInterception:
|
|||
response_path = hermes_home / ".update_response"
|
||||
assert response_path.exists()
|
||||
assert response_path.read_text() == "y"
|
||||
assert not (hermes_home / ".update_prompt.json").exists()
|
||||
# Should clear the pending flag
|
||||
assert session_key not in runner._update_prompt_pending
|
||||
|
||||
|
|
@ -560,6 +632,7 @@ class TestUpdatePromptInterception:
|
|||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
runner._session_key_for_source = MagicMock(return_value=session_key)
|
||||
runner._handle_reset_command = AsyncMock(return_value="reset ok")
|
||||
(hermes_home / ".update_prompt.json").write_text(json.dumps({"prompt": "test"}))
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
result = await runner._handle_message(event)
|
||||
|
|
@ -572,6 +645,7 @@ class TestUpdatePromptInterception:
|
|||
response_path = hermes_home / ".update_response"
|
||||
assert response_path.exists()
|
||||
assert response_path.read_text() == ""
|
||||
assert not (hermes_home / ".update_prompt.json").exists()
|
||||
# Pending flag is cleared so stray future input won't be
|
||||
# re-intercepted for a prompt that is no longer outstanding.
|
||||
assert session_key not in runner._update_prompt_pending
|
||||
|
|
@ -588,6 +662,7 @@ class TestUpdatePromptInterception:
|
|||
runner._update_prompt_pending[session_key] = True
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
runner._session_key_for_source = MagicMock(return_value=session_key)
|
||||
(hermes_home / ".update_prompt.json").write_text(json.dumps({"prompt": "test"}))
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
result = await runner._handle_message(event)
|
||||
|
|
@ -595,6 +670,7 @@ class TestUpdatePromptInterception:
|
|||
response_path = hermes_home / ".update_response"
|
||||
assert response_path.exists()
|
||||
assert response_path.read_text() == "/foobarbaz"
|
||||
assert not (hermes_home / ".update_prompt.json").exists()
|
||||
assert "Sent" in (result or "")
|
||||
assert session_key not in runner._update_prompt_pending
|
||||
|
||||
|
|
|
|||
19
tests/hermes_cli/conftest.py
Normal file
19
tests/hermes_cli/conftest.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Fixtures shared across hermes_cli kanban tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def all_assignees_spawnable(monkeypatch):
|
||||
"""Pretend every assignee maps to a real Hermes profile.
|
||||
|
||||
Most dispatcher tests use synthetic assignees ("alice", "bob") that
|
||||
don't correspond to actual profile directories on disk. Without this
|
||||
patch, the dispatcher's profile-exists guard (PR #20105) routes
|
||||
those tasks into ``skipped_nonspawnable`` instead of spawning, which
|
||||
would break tests that assert spawn behavior.
|
||||
"""
|
||||
from hermes_cli import profiles
|
||||
monkeypatch.setattr(profiles, "profile_exists", lambda name: True)
|
||||
269
tests/hermes_cli/test_curator_archive_prune.py
Normal file
269
tests/hermes_cli/test_curator_archive_prune.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""Tests for `hermes curator archive` and `hermes curator prune`.
|
||||
|
||||
Covers:
|
||||
- archive refuses pinned skills with an `unpin` hint
|
||||
- archive returns 0/1 based on archive_skill() success
|
||||
- prune filters pinned and already-archived, applies --days threshold
|
||||
- prune falls back to created_at when last_activity_at is null
|
||||
- prune --dry-run makes no state changes
|
||||
- prune --yes skips confirmation
|
||||
- prune --days validation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _ns(**kwargs):
|
||||
return SimpleNamespace(**kwargs)
|
||||
|
||||
|
||||
# ─── archive ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_archive_refuses_pinned(monkeypatch, capsys):
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": True})
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
skill_usage, "archive_skill",
|
||||
lambda name: called.append(name) or (True, "should not get here"),
|
||||
)
|
||||
|
||||
rc = curator_cli._cmd_archive(_ns(skill="pinned-skill"))
|
||||
assert rc == 1
|
||||
assert called == []
|
||||
out = capsys.readouterr().out
|
||||
assert "pinned" in out.lower()
|
||||
assert "hermes curator unpin" in out
|
||||
|
||||
|
||||
def test_archive_calls_archive_skill(monkeypatch, capsys):
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False})
|
||||
monkeypatch.setattr(
|
||||
skill_usage, "archive_skill",
|
||||
lambda name: (True, f"archived to .archive/{name}"),
|
||||
)
|
||||
rc = curator_cli._cmd_archive(_ns(skill="my-skill"))
|
||||
assert rc == 0
|
||||
assert "archived to .archive/my-skill" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_archive_reports_failure(monkeypatch, capsys):
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False})
|
||||
monkeypatch.setattr(
|
||||
skill_usage, "archive_skill",
|
||||
lambda name: (False, f"skill '{name}' is bundled or hub-installed; never archive"),
|
||||
)
|
||||
rc = curator_cli._cmd_archive(_ns(skill="hub-slug"))
|
||||
assert rc == 1
|
||||
assert "bundled or hub-installed" in capsys.readouterr().out
|
||||
|
||||
|
||||
# ─── prune ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mk_record(name, *, idle_days=0, pinned=False, state="active", created_idle_days=None):
|
||||
import datetime as _dt
|
||||
now = _dt.datetime.now(_dt.timezone.utc)
|
||||
last_activity = (now - _dt.timedelta(days=idle_days)).isoformat() if idle_days else None
|
||||
created_delta = created_idle_days if created_idle_days is not None else idle_days
|
||||
created = (now - _dt.timedelta(days=created_delta)).isoformat()
|
||||
return {
|
||||
"name": name,
|
||||
"state": state,
|
||||
"pinned": pinned,
|
||||
"last_activity_at": last_activity,
|
||||
"created_at": created,
|
||||
"activity_count": 0 if idle_days == 0 and last_activity is None else 1,
|
||||
}
|
||||
|
||||
|
||||
def test_prune_days_validation(monkeypatch, capsys):
|
||||
import hermes_cli.curator as curator_cli
|
||||
rc = curator_cli._cmd_prune(_ns(days=0, yes=True, dry_run=False))
|
||||
assert rc == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--days must be >= 1" in err
|
||||
|
||||
|
||||
def test_prune_nothing_to_do(monkeypatch, capsys):
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: [])
|
||||
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
|
||||
assert rc == 0
|
||||
assert "nothing to prune" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_prune_filters_pinned_and_archived(monkeypatch, capsys):
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
rows = [
|
||||
_mk_record("old-pinned", idle_days=200, pinned=True),
|
||||
_mk_record("old-archived", idle_days=200, state="archived"),
|
||||
_mk_record("recent", idle_days=10),
|
||||
_mk_record("old-active", idle_days=200),
|
||||
]
|
||||
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
|
||||
archived = []
|
||||
monkeypatch.setattr(
|
||||
skill_usage, "archive_skill",
|
||||
lambda name: archived.append(name) or (True, f"archived {name}"),
|
||||
)
|
||||
|
||||
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
|
||||
assert rc == 0
|
||||
assert archived == ["old-active"]
|
||||
out = capsys.readouterr().out
|
||||
assert "old-active" in out
|
||||
assert "old-pinned" not in out
|
||||
assert "old-archived" not in out
|
||||
assert "recent" not in out
|
||||
assert "archived 1/1" in out
|
||||
|
||||
|
||||
def test_prune_falls_back_to_created_at_when_never_used(monkeypatch, capsys):
|
||||
"""Never-used skills must be prunable via created_at — otherwise immortal."""
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
rows = [_mk_record("never-used", idle_days=0, created_idle_days=200)]
|
||||
# Force last_activity_at to None explicitly
|
||||
rows[0]["last_activity_at"] = None
|
||||
|
||||
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
|
||||
archived = []
|
||||
monkeypatch.setattr(
|
||||
skill_usage, "archive_skill",
|
||||
lambda name: archived.append(name) or (True, "ok"),
|
||||
)
|
||||
rc = curator_cli._cmd_prune(_ns(days=90, yes=True, dry_run=False))
|
||||
assert rc == 0
|
||||
assert archived == ["never-used"]
|
||||
|
||||
|
||||
def test_prune_dry_run_makes_no_changes(monkeypatch, capsys):
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
rows = [_mk_record("old-skill", idle_days=200)]
|
||||
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
|
||||
archived = []
|
||||
monkeypatch.setattr(
|
||||
skill_usage, "archive_skill",
|
||||
lambda name: archived.append(name) or (True, "ok"),
|
||||
)
|
||||
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=True))
|
||||
assert rc == 0
|
||||
assert archived == []
|
||||
out = capsys.readouterr().out
|
||||
assert "old-skill" in out
|
||||
assert "dry run" in out
|
||||
|
||||
|
||||
def test_prune_prompts_without_yes(monkeypatch, capsys):
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
rows = [_mk_record("old-skill", idle_days=200)]
|
||||
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
|
||||
archived = []
|
||||
monkeypatch.setattr(
|
||||
skill_usage, "archive_skill",
|
||||
lambda name: archived.append(name) or (True, "ok"),
|
||||
)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt: "n")
|
||||
rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False))
|
||||
assert rc == 1
|
||||
assert archived == []
|
||||
assert "aborted" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_prune_confirms_with_y(monkeypatch, capsys):
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
rows = [_mk_record("old-skill", idle_days=200)]
|
||||
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
|
||||
archived = []
|
||||
monkeypatch.setattr(
|
||||
skill_usage, "archive_skill",
|
||||
lambda name: archived.append(name) or (True, "ok"),
|
||||
)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt: "y")
|
||||
rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False))
|
||||
assert rc == 0
|
||||
assert archived == ["old-skill"]
|
||||
|
||||
|
||||
def test_prune_reports_partial_failure(monkeypatch, capsys):
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
rows = [
|
||||
_mk_record("ok-skill", idle_days=200),
|
||||
_mk_record("bad-skill", idle_days=200),
|
||||
]
|
||||
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
|
||||
|
||||
def fake_archive(name):
|
||||
if name == "bad-skill":
|
||||
return False, "disk full"
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setattr(skill_usage, "archive_skill", fake_archive)
|
||||
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "archived 1/2" in out
|
||||
assert "bad-skill: disk full" in out
|
||||
|
||||
|
||||
# ─── argparse wiring ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_archive_and_prune_registered():
|
||||
import argparse
|
||||
import hermes_cli.curator as curator_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes curator")
|
||||
curator_cli.register_cli(parser)
|
||||
|
||||
args = parser.parse_args(["archive", "my-skill"])
|
||||
assert args.skill == "my-skill"
|
||||
assert args.func.__name__ == "_cmd_archive"
|
||||
|
||||
args = parser.parse_args(["prune", "--days", "45", "--yes", "--dry-run"])
|
||||
assert args.days == 45
|
||||
assert args.yes is True
|
||||
assert args.dry_run is True
|
||||
assert args.func.__name__ == "_cmd_prune"
|
||||
|
||||
|
||||
def test_prune_defaults():
|
||||
import argparse
|
||||
import hermes_cli.curator as curator_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes curator")
|
||||
curator_cli.register_cli(parser)
|
||||
args = parser.parse_args(["prune"])
|
||||
assert args.days == 90
|
||||
assert args.yes is False
|
||||
assert args.dry_run is False
|
||||
|
|
@ -160,6 +160,15 @@ class TestCurrentBoard:
|
|||
kb.set_current_board("filepick")
|
||||
assert kb.get_current_board() == "filepick"
|
||||
|
||||
def test_stale_file_pointer_falls_back_to_default(self, fresh_home):
|
||||
current = fresh_home / "kanban" / "current"
|
||||
current.parent.mkdir(parents=True, exist_ok=True)
|
||||
current.write_text("missing-board\n", encoding="utf-8")
|
||||
|
||||
assert kb.get_current_board() == "default"
|
||||
assert not kb.board_exists("missing-board")
|
||||
assert [b["slug"] for b in kb.list_boards()] == ["default"]
|
||||
|
||||
def test_env_beats_file(self, fresh_home, monkeypatch):
|
||||
kb.create_board("a")
|
||||
kb.create_board("b")
|
||||
|
|
|
|||
|
|
@ -208,3 +208,81 @@ def test_kanban_not_gateway_only():
|
|||
cmd = next(c for c in COMMAND_REGISTRY if c.name == "kanban")
|
||||
assert not cmd.cli_only
|
||||
assert not cmd.gateway_only
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reclaim + reassign CLI smoke tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_run_slash_reclaim_running_task(kanban_home):
|
||||
import re
|
||||
import time
|
||||
import secrets
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
out1 = kc.run_slash("create 'stuck worker task' --assignee broken-model")
|
||||
m = re.search(r"(t_[a-f0-9]+)", out1)
|
||||
assert m
|
||||
tid = m.group(1)
|
||||
|
||||
# Simulate a running claim outside TTL.
|
||||
conn = kb.connect()
|
||||
try:
|
||||
lock = secrets.token_hex(4)
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
|
||||
"worker_pid=? WHERE id=?",
|
||||
(lock, int(time.time()) + 3600, 4242, tid),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
|
||||
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
|
||||
(tid, lock, int(time.time()) + 3600, 4242, int(time.time())),
|
||||
)
|
||||
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (rid, tid))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = kc.run_slash(f"reclaim {tid} --reason 'test'")
|
||||
assert "Reclaimed" in out, out
|
||||
# Status back to ready.
|
||||
out2 = kc.run_slash(f"show {tid}")
|
||||
assert "ready" in out2.lower()
|
||||
|
||||
|
||||
def test_run_slash_reassign_with_reclaim_flag(kanban_home):
|
||||
import re
|
||||
import time
|
||||
import secrets
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
out1 = kc.run_slash("create 'switch model' --assignee orig")
|
||||
m = re.search(r"(t_[a-f0-9]+)", out1)
|
||||
tid = m.group(1)
|
||||
|
||||
# Simulate a running claim.
|
||||
conn = kb.connect()
|
||||
try:
|
||||
lock = secrets.token_hex(4)
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
|
||||
"worker_pid=? WHERE id=?",
|
||||
(lock, int(time.time()) + 3600, 4242, tid),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
|
||||
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
|
||||
(tid, lock, int(time.time()) + 3600, 4242, int(time.time())),
|
||||
)
|
||||
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (rid, tid))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = kc.run_slash(f"reassign {tid} newbie --reclaim --reason 'switch'")
|
||||
assert "Reassigned" in out, out
|
||||
out2 = kc.run_slash(f"show {tid}")
|
||||
assert "newbie" in out2
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
|
@ -80,7 +82,7 @@ def test_no_idempotency_key_never_collides(kanban_home):
|
|||
# Spawn-failure circuit breaker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_spawn_failure_auto_blocks_after_limit(kanban_home):
|
||||
def test_spawn_failure_auto_blocks_after_limit(kanban_home, all_assignees_spawnable):
|
||||
"""N consecutive spawn failures on the same task → auto_blocked."""
|
||||
def _bad_spawn(task, ws):
|
||||
raise RuntimeError("no PATH")
|
||||
|
|
@ -109,7 +111,7 @@ def test_spawn_failure_auto_blocks_after_limit(kanban_home):
|
|||
conn.close()
|
||||
|
||||
|
||||
def test_successful_spawn_resets_failure_counter(kanban_home):
|
||||
def test_successful_spawn_resets_failure_counter(kanban_home, all_assignees_spawnable):
|
||||
"""A successful spawn clears the counter so past failures don't count
|
||||
against future retries of the same task."""
|
||||
calls = [0]
|
||||
|
|
@ -138,7 +140,7 @@ def test_successful_spawn_resets_failure_counter(kanban_home):
|
|||
conn.close()
|
||||
|
||||
|
||||
def test_workspace_resolution_failure_also_counts(kanban_home):
|
||||
def test_workspace_resolution_failure_also_counts(kanban_home, all_assignees_spawnable):
|
||||
"""`dir:` workspace with no path should fail workspace resolution AND
|
||||
count against the failure budget — not just crash the tick."""
|
||||
conn = kb.connect()
|
||||
|
|
@ -183,6 +185,20 @@ def test_pid_alive_helper():
|
|||
assert not kb._pid_alive(2 ** 30)
|
||||
|
||||
|
||||
def test_pid_alive_detects_darwin_zombie(monkeypatch):
|
||||
monkeypatch.setattr(kb.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(kb.os, "kill", lambda pid, sig: None)
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
assert args == ["ps", "-o", "stat=", "-p", "123"]
|
||||
assert kwargs["stdout"] is subprocess.PIPE
|
||||
return SimpleNamespace(returncode=0, stdout="Z+\n")
|
||||
|
||||
monkeypatch.setattr(kb.subprocess, "run", fake_run)
|
||||
|
||||
assert kb._pid_alive(123) is False
|
||||
|
||||
|
||||
def test_detect_crashed_workers_reclaims(kanban_home):
|
||||
"""A running task whose pid vanished gets dropped to ready with a
|
||||
``crashed`` event, independent of the claim TTL."""
|
||||
|
|
@ -824,7 +840,7 @@ def test_recompute_ready_emits_promoted_not_ready(kanban_home):
|
|||
conn.close()
|
||||
|
||||
|
||||
def test_spawn_failure_circuit_breaker_emits_gave_up(kanban_home):
|
||||
def test_spawn_failure_circuit_breaker_emits_gave_up(kanban_home, all_assignees_spawnable):
|
||||
def _bad(task, ws):
|
||||
raise RuntimeError("nope")
|
||||
conn = kb.connect()
|
||||
|
|
@ -840,7 +856,7 @@ def test_spawn_failure_circuit_breaker_emits_gave_up(kanban_home):
|
|||
conn.close()
|
||||
|
||||
|
||||
def test_spawned_event_emitted_with_pid(kanban_home):
|
||||
def test_spawned_event_emitted_with_pid(kanban_home, all_assignees_spawnable):
|
||||
"""Successful spawn must append a ``spawned`` event with the pid in
|
||||
the payload so humans tailing events see pid tracking."""
|
||||
def _spawn_returns_pid(task, ws):
|
||||
|
|
@ -899,8 +915,8 @@ def test_migration_renames_legacy_event_kinds(tmp_path, monkeypatch):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_profiles_on_disk(tmp_path, monkeypatch):
|
||||
"""list_profiles_on_disk returns directories under ~/.hermes/profiles/
|
||||
that contain a config.yaml."""
|
||||
"""list_profiles_on_disk returns the implicit default profile plus
|
||||
named profiles under ~/.hermes/profiles/ that contain a config.yaml."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
profiles = tmp_path / ".hermes" / "profiles"
|
||||
|
|
@ -914,7 +930,7 @@ def test_list_profiles_on_disk(tmp_path, monkeypatch):
|
|||
(profiles / "stray.txt").write_text("noise")
|
||||
|
||||
names = kb.list_profiles_on_disk()
|
||||
assert names == ["researcher", "writer"]
|
||||
assert names == ["default", "researcher", "writer"]
|
||||
|
||||
|
||||
def test_list_profiles_on_disk_custom_root(tmp_path, monkeypatch):
|
||||
|
|
@ -928,7 +944,7 @@ def test_list_profiles_on_disk_custom_root(tmp_path, monkeypatch):
|
|||
(d / "config.yaml").write_text("model: {}\n")
|
||||
|
||||
names = kb.list_profiles_on_disk()
|
||||
assert names == ["researcher", "writer"]
|
||||
assert names == ["default", "researcher", "writer"]
|
||||
|
||||
|
||||
def test_known_assignees_merges_disk_and_board(tmp_path, monkeypatch):
|
||||
|
|
@ -955,6 +971,8 @@ def test_known_assignees_merges_disk_and_board(tmp_path, monkeypatch):
|
|||
conn.close()
|
||||
|
||||
by_name = {d["name"]: d for d in data}
|
||||
assert by_name["default"]["on_disk"] is True
|
||||
assert by_name["default"]["counts"] == {}
|
||||
assert by_name["researcher"]["on_disk"] is True
|
||||
assert by_name["researcher"]["counts"] == {}
|
||||
assert by_name["writer"]["on_disk"] is True
|
||||
|
|
@ -1154,7 +1172,7 @@ def test_run_on_block_with_reason(kanban_home):
|
|||
conn.close()
|
||||
|
||||
|
||||
def test_run_on_spawn_failure_records_failed_runs(kanban_home):
|
||||
def test_run_on_spawn_failure_records_failed_runs(kanban_home, all_assignees_spawnable):
|
||||
"""Each spawn_failed event closes a run with outcome='spawn_failed',
|
||||
and the Nth failure closes a run with outcome='gave_up'."""
|
||||
def _bad(task, ws):
|
||||
|
|
@ -1371,6 +1389,48 @@ def test_cli_complete_with_summary_and_metadata(kanban_home):
|
|||
assert r.metadata == {"files": 3}
|
||||
|
||||
|
||||
def test_cli_edit_backfills_result_on_done_task(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="x", assignee="worker")
|
||||
kb.complete_task(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
meta = '{"source": "dashboard-recovery"}'
|
||||
out = run_slash(
|
||||
"edit " + tid
|
||||
+ " --result \"DECIDED: done\""
|
||||
+ " --summary \"DECIDED: done\""
|
||||
+ " --metadata '" + meta + "'"
|
||||
)
|
||||
|
||||
assert "Edited" in out
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task = kb.get_task(conn, tid)
|
||||
run = kb.latest_run(conn, tid)
|
||||
events = kb.list_events(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
assert task.result == "DECIDED: done"
|
||||
assert run.summary == "DECIDED: done"
|
||||
assert run.metadata == {"source": "dashboard-recovery"}
|
||||
assert events[-1].kind == "edited"
|
||||
|
||||
|
||||
def test_cli_edit_rejects_non_done_task(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="x", assignee="worker")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = run_slash(f"edit {tid} --result nope")
|
||||
|
||||
assert "not done" in out
|
||||
|
||||
|
||||
def test_cli_complete_bad_metadata_exits_nonzero(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
|
|
@ -2726,3 +2786,269 @@ def test_gateway_dispatcher_watcher_env_truthy_uses_config(monkeypatch):
|
|||
timeout=3.0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hallucination gate (created_cards verify + prose scan)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_complete_with_created_cards_all_verified_records_manifest(kanban_home):
|
||||
"""A completion with created_cards that all exist + belong to this
|
||||
worker records them on the ``completed`` event payload."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
parent = kb.create_task(conn, title="parent", assignee="alice")
|
||||
c1 = kb.create_task(conn, title="c1", assignee="x", created_by="alice")
|
||||
c2 = kb.create_task(conn, title="c2", assignee="y", created_by="alice")
|
||||
ok = kb.complete_task(
|
||||
conn, parent,
|
||||
summary="done, created c1+c2",
|
||||
created_cards=[c1, c2],
|
||||
)
|
||||
assert ok is True
|
||||
evs = list(conn.execute(
|
||||
"SELECT kind, payload FROM task_events WHERE task_id=? ORDER BY id",
|
||||
(parent,),
|
||||
))
|
||||
completed = [e for e in evs if e["kind"] == "completed"]
|
||||
assert len(completed) == 1
|
||||
import json as _json
|
||||
payload = _json.loads(completed[0]["payload"])
|
||||
assert payload.get("verified_cards") == [c1, c2]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_complete_with_phantom_created_cards_raises_and_audits(kanban_home):
|
||||
"""A completion claiming a card id that doesn't exist raises
|
||||
HallucinatedCardsError, leaves the task in its prior state, and
|
||||
records a ``completion_blocked_hallucination`` event for auditing."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
parent = kb.create_task(conn, title="parent", assignee="alice")
|
||||
real = kb.create_task(conn, title="real", assignee="x", created_by="alice")
|
||||
phantom_id = "t_deadbeefcafe"
|
||||
|
||||
with pytest.raises(kb.HallucinatedCardsError) as excinfo:
|
||||
kb.complete_task(
|
||||
conn, parent,
|
||||
summary="claimed phantom",
|
||||
created_cards=[real, phantom_id],
|
||||
)
|
||||
assert excinfo.value.phantom == [phantom_id]
|
||||
|
||||
# Task still in prior state (ready, not done).
|
||||
row = conn.execute(
|
||||
"SELECT status FROM tasks WHERE id=?", (parent,),
|
||||
).fetchone()
|
||||
assert row["status"] == "ready"
|
||||
|
||||
# Audit event landed.
|
||||
kinds = [
|
||||
r["kind"] for r in conn.execute(
|
||||
"SELECT kind FROM task_events WHERE task_id=? ORDER BY id",
|
||||
(parent,),
|
||||
)
|
||||
]
|
||||
assert "completion_blocked_hallucination" in kinds
|
||||
assert "completed" not in kinds
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_complete_with_cross_worker_card_is_rejected(kanban_home):
|
||||
"""A card that exists but was created by a different worker profile
|
||||
is treated as phantom (hallucinated attribution)."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
parent = kb.create_task(conn, title="parent", assignee="alice")
|
||||
other = kb.create_task(conn, title="other", assignee="x", created_by="bob")
|
||||
|
||||
with pytest.raises(kb.HallucinatedCardsError) as excinfo:
|
||||
kb.complete_task(
|
||||
conn, parent,
|
||||
summary="claiming someone else's card",
|
||||
created_cards=[other],
|
||||
)
|
||||
assert excinfo.value.phantom == [other]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_complete_prose_scan_flags_nonexistent_ids(kanban_home):
|
||||
"""Successful completion whose summary references a ``t_<hex>`` id
|
||||
that doesn't resolve emits a ``suspected_hallucinated_references``
|
||||
event. Does not block the completion."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
parent = kb.create_task(conn, title="parent", assignee="x")
|
||||
ok = kb.complete_task(
|
||||
conn, parent,
|
||||
summary="also saw t_abcd1234ffff failing in CI",
|
||||
)
|
||||
assert ok is True
|
||||
kinds_and_payloads = list(conn.execute(
|
||||
"SELECT kind, payload FROM task_events WHERE task_id=? ORDER BY id",
|
||||
(parent,),
|
||||
))
|
||||
kinds = [r["kind"] for r in kinds_and_payloads]
|
||||
assert "suspected_hallucinated_references" in kinds
|
||||
import json as _json
|
||||
susp = [
|
||||
_json.loads(r["payload"])
|
||||
for r in kinds_and_payloads
|
||||
if r["kind"] == "suspected_hallucinated_references"
|
||||
][0]
|
||||
assert "t_abcd1234ffff" in susp["phantom_refs"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_complete_prose_scan_ignores_existing_ids(kanban_home):
|
||||
"""Summaries referencing real task ids don't emit a warning."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
other = kb.create_task(conn, title="other", assignee="x")
|
||||
parent = kb.create_task(conn, title="parent", assignee="x")
|
||||
ok = kb.complete_task(
|
||||
conn, parent,
|
||||
summary=f"depended on {other}, now done",
|
||||
)
|
||||
assert ok is True
|
||||
kinds = [
|
||||
r["kind"] for r in conn.execute(
|
||||
"SELECT kind FROM task_events WHERE task_id=? ORDER BY id",
|
||||
(parent,),
|
||||
)
|
||||
]
|
||||
assert "suspected_hallucinated_references" not in kinds
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recovery helpers (reclaim + reassign)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_reclaim_task_resets_running_to_ready(kanban_home):
|
||||
"""Manual reclaim releases the claim, resets status, and emits a
|
||||
``reclaimed`` event even when claim_expires has not passed."""
|
||||
import time
|
||||
import secrets
|
||||
conn = kb.connect()
|
||||
try:
|
||||
t = kb.create_task(conn, title="stuck", assignee="broken")
|
||||
# Simulate a live claim (not expired).
|
||||
lock = secrets.token_hex(8)
|
||||
future = int(time.time()) + 3600
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
|
||||
"worker_pid=? WHERE id=?",
|
||||
(lock, future, 12345, t),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
|
||||
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
|
||||
(t, lock, future, 12345, int(time.time())),
|
||||
)
|
||||
run_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (run_id, t))
|
||||
conn.commit()
|
||||
|
||||
# release_stale_claims should NOT reclaim (not expired).
|
||||
assert kb.release_stale_claims(conn) == 0
|
||||
|
||||
# reclaim_task should work immediately.
|
||||
assert kb.reclaim_task(conn, t, reason="test reason") is True
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id=?",
|
||||
(t,),
|
||||
).fetchone()
|
||||
assert row["status"] == "ready"
|
||||
assert row["claim_lock"] is None
|
||||
assert row["worker_pid"] is None
|
||||
|
||||
import json as _json
|
||||
reclaim_evs = [
|
||||
_json.loads(r["payload"])
|
||||
for r in conn.execute(
|
||||
"SELECT payload FROM task_events WHERE task_id=? AND kind='reclaimed'",
|
||||
(t,),
|
||||
)
|
||||
]
|
||||
assert len(reclaim_evs) == 1
|
||||
assert reclaim_evs[0].get("manual") is True
|
||||
assert reclaim_evs[0].get("reason") == "test reason"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_reclaim_task_returns_false_for_already_ready(kanban_home):
|
||||
"""Reclaiming a task that's not running returns False (no-op)."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
t = kb.create_task(conn, title="ready task", assignee="x")
|
||||
assert kb.reclaim_task(conn, t) is False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_reassign_task_refuses_running_without_reclaim_first(kanban_home):
|
||||
"""Without ``reclaim_first=True``, reassigning a running task is a
|
||||
no-op returning False (matches assign_task's RuntimeError via
|
||||
internal catch)."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
t = kb.create_task(conn, title="running", assignee="orig")
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='running', claim_lock=? WHERE id=?",
|
||||
("live", t),
|
||||
)
|
||||
conn.commit()
|
||||
assert kb.reassign_task(conn, t, "new") is False
|
||||
# Assignee unchanged.
|
||||
row = conn.execute(
|
||||
"SELECT assignee FROM tasks WHERE id=?", (t,),
|
||||
).fetchone()
|
||||
assert row["assignee"] == "orig"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_reassign_task_with_reclaim_first_switches_profile(kanban_home):
|
||||
"""With ``reclaim_first=True``, a running task is reclaimed and
|
||||
reassigned in one operation."""
|
||||
import time
|
||||
import secrets
|
||||
conn = kb.connect()
|
||||
try:
|
||||
t = kb.create_task(conn, title="switch me", assignee="orig")
|
||||
lock = secrets.token_hex(8)
|
||||
future = int(time.time()) + 3600
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
|
||||
"worker_pid=? WHERE id=?",
|
||||
(lock, future, 99999, t),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
|
||||
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
|
||||
(t, lock, future, 99999, int(time.time())),
|
||||
)
|
||||
run_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (run_id, t))
|
||||
conn.commit()
|
||||
|
||||
assert kb.reassign_task(
|
||||
conn, t, "new-profile",
|
||||
reclaim_first=True, reason="switch model",
|
||||
) is True
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT assignee, status FROM tasks WHERE id=?", (t,),
|
||||
).fetchone()
|
||||
assert row["assignee"] == "new-profile"
|
||||
assert row["status"] == "ready"
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ def test_worker_context_includes_parent_results_and_comments(kanban_home):
|
|||
# Dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_dispatch_dry_run_does_not_claim(kanban_home):
|
||||
def test_dispatch_dry_run_does_not_claim(kanban_home, all_assignees_spawnable):
|
||||
with kb.connect() as conn:
|
||||
t1 = kb.create_task(conn, title="a", assignee="alice")
|
||||
t2 = kb.create_task(conn, title="b", assignee="bob")
|
||||
|
|
@ -344,10 +344,58 @@ def test_dispatch_skips_unassigned(kanban_home):
|
|||
t = kb.create_task(conn, title="floater")
|
||||
res = kb.dispatch_once(conn, dry_run=True)
|
||||
assert t in res.skipped_unassigned
|
||||
assert t not in res.skipped_nonspawnable
|
||||
assert not res.spawned
|
||||
|
||||
|
||||
def test_dispatch_promotes_ready_and_spawns(kanban_home):
|
||||
def test_dispatch_skips_nonspawnable_into_separate_bucket(kanban_home, monkeypatch):
|
||||
"""Tasks whose assignee fails profile_exists() must NOT land in
|
||||
``skipped_unassigned`` (which is operator-actionable) — they go in
|
||||
the dedicated ``skipped_nonspawnable`` bucket so health telemetry
|
||||
can suppress false-positive "stuck" warnings."""
|
||||
from hermes_cli import profiles
|
||||
monkeypatch.setattr(profiles, "profile_exists", lambda name: False)
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="for-terminal", assignee="orion-cc")
|
||||
res = kb.dispatch_once(conn, dry_run=True)
|
||||
assert t in res.skipped_nonspawnable
|
||||
assert t not in res.skipped_unassigned
|
||||
assert not res.spawned
|
||||
|
||||
|
||||
def test_has_spawnable_ready_false_when_only_terminal_lanes(kanban_home, monkeypatch):
|
||||
"""``has_spawnable_ready`` returns False when every ready task is
|
||||
assigned to a control-plane lane — used by gateway/CLI dispatchers
|
||||
to silence the stuck-warn while terminals still have queued work."""
|
||||
from hermes_cli import profiles
|
||||
monkeypatch.setattr(profiles, "profile_exists", lambda name: False)
|
||||
with kb.connect() as conn:
|
||||
kb.create_task(conn, title="t1", assignee="orion-cc")
|
||||
kb.create_task(conn, title="t2", assignee="orion-research")
|
||||
assert kb.has_spawnable_ready(conn) is False
|
||||
|
||||
|
||||
def test_has_spawnable_ready_true_when_real_profile_present(kanban_home, monkeypatch):
|
||||
"""``has_spawnable_ready`` returns True as soon as ANY ready task
|
||||
has an assignee that maps to a real Hermes profile — preserves the
|
||||
real "stuck" signal when a daily/agent task is queued."""
|
||||
from hermes_cli import profiles
|
||||
monkeypatch.setattr(
|
||||
profiles, "profile_exists", lambda name: name == "daily"
|
||||
)
|
||||
with kb.connect() as conn:
|
||||
kb.create_task(conn, title="terminal-task", assignee="orion-cc")
|
||||
kb.create_task(conn, title="hermes-task", assignee="daily")
|
||||
assert kb.has_spawnable_ready(conn) is True
|
||||
|
||||
|
||||
def test_has_spawnable_ready_false_on_empty_queue(kanban_home):
|
||||
"""Empty queue is the trivial false case — no ready tasks at all."""
|
||||
with kb.connect() as conn:
|
||||
assert kb.has_spawnable_ready(conn) is False
|
||||
|
||||
|
||||
def test_dispatch_promotes_ready_and_spawns(kanban_home, all_assignees_spawnable):
|
||||
spawns = []
|
||||
|
||||
def fake_spawn(task, workspace):
|
||||
|
|
@ -368,7 +416,7 @@ def test_dispatch_promotes_ready_and_spawns(kanban_home):
|
|||
assert kb.get_task(conn, c).status == "running"
|
||||
|
||||
|
||||
def test_dispatch_spawn_failure_releases_claim(kanban_home):
|
||||
def test_dispatch_spawn_failure_releases_claim(kanban_home, all_assignees_spawnable):
|
||||
def boom(task, workspace):
|
||||
raise RuntimeError("spawn failed")
|
||||
|
||||
|
|
|
|||
216
tests/hermes_cli/test_list_picker_providers.py
Normal file
216
tests/hermes_cli/test_list_picker_providers.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"""Tests for ``list_picker_providers`` — the /model picker filter.
|
||||
|
||||
``list_picker_providers`` wraps ``list_authenticated_providers`` and
|
||||
post-processes the result for interactive pickers (Telegram, Discord):
|
||||
|
||||
- OpenRouter's ``models`` are replaced with the live-filtered output of
|
||||
``fetch_openrouter_models``, so IDs the live catalog no longer carries
|
||||
drop out.
|
||||
- Provider rows with an empty ``models`` list are dropped, except custom
|
||||
endpoints (``is_user_defined=True`` with an ``api_url``) where the user
|
||||
may supply their own model set through config.
|
||||
|
||||
These tests exercise the filter in isolation by mocking
|
||||
``list_authenticated_providers`` and ``fetch_openrouter_models`` so no
|
||||
network or auth state is required.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from hermes_cli import model_switch
|
||||
|
||||
|
||||
def _make_provider(slug, name=None, models=None, *, is_current=False,
|
||||
is_user_defined=False, source="built-in", api_url=None):
|
||||
"""Build a dict shaped like ``list_authenticated_providers`` output."""
|
||||
entry = {
|
||||
"slug": slug,
|
||||
"name": name or slug.title(),
|
||||
"is_current": is_current,
|
||||
"is_user_defined": is_user_defined,
|
||||
"models": list(models or []),
|
||||
"total_models": len(models or []),
|
||||
"source": source,
|
||||
}
|
||||
if api_url is not None:
|
||||
entry["api_url"] = api_url
|
||||
return entry
|
||||
|
||||
|
||||
def test_openrouter_models_replaced_with_live_catalog(monkeypatch):
|
||||
"""OpenRouter row's ``models`` should come from fetch_openrouter_models."""
|
||||
base = [
|
||||
_make_provider("openrouter", models=["openai/gpt-stale", "old/model"]),
|
||||
]
|
||||
live = [("openai/gpt-5.4", "recommended"), ("moonshotai/kimi-k2.6", "")]
|
||||
|
||||
monkeypatch.setattr(model_switch, "list_authenticated_providers",
|
||||
lambda **kw: list(base))
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
|
||||
lambda *a, **kw: list(live))
|
||||
|
||||
result = model_switch.list_picker_providers(max_models=50)
|
||||
|
||||
assert len(result) == 1
|
||||
openrouter = result[0]
|
||||
assert openrouter["slug"] == "openrouter"
|
||||
assert openrouter["models"] == ["openai/gpt-5.4", "moonshotai/kimi-k2.6"]
|
||||
assert openrouter["total_models"] == 2
|
||||
|
||||
|
||||
def test_openrouter_falls_back_to_base_models_on_fetch_failure(monkeypatch):
|
||||
"""If the live catalog fetch raises, keep whatever base provided."""
|
||||
fallback_models = ["openai/gpt-5.4", "moonshotai/kimi-k2.6"]
|
||||
base = [_make_provider("openrouter", models=fallback_models)]
|
||||
|
||||
def _raise(*_a, **_kw):
|
||||
raise RuntimeError("network down")
|
||||
|
||||
monkeypatch.setattr(model_switch, "list_authenticated_providers",
|
||||
lambda **kw: list(base))
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models", _raise)
|
||||
|
||||
result = model_switch.list_picker_providers(max_models=50)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["models"] == fallback_models
|
||||
|
||||
|
||||
def test_openrouter_empty_live_catalog_drops_row(monkeypatch):
|
||||
"""If the live catalog returns nothing for OpenRouter, drop the row."""
|
||||
base = [_make_provider("openrouter", models=["something/stale"])]
|
||||
|
||||
monkeypatch.setattr(model_switch, "list_authenticated_providers",
|
||||
lambda **kw: list(base))
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
|
||||
lambda *a, **kw: [])
|
||||
|
||||
result = model_switch.list_picker_providers(max_models=50)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_non_openrouter_rows_passed_through_unchanged(monkeypatch):
|
||||
"""Non-OpenRouter providers keep their curated ``models`` as-is."""
|
||||
base = [
|
||||
_make_provider("anthropic", models=["claude-sonnet-4-6", "claude-opus-4-7"]),
|
||||
_make_provider("gemini", models=["gemini-3-flash-preview"]),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(model_switch, "list_authenticated_providers",
|
||||
lambda **kw: list(base))
|
||||
# fetch_openrouter_models must not be consulted when there's no openrouter row
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
|
||||
lambda *a, **kw: pytest.fail("should not be called"))
|
||||
|
||||
result = model_switch.list_picker_providers(max_models=50)
|
||||
|
||||
assert [p["slug"] for p in result] == ["anthropic", "gemini"]
|
||||
assert result[0]["models"] == ["claude-sonnet-4-6", "claude-opus-4-7"]
|
||||
assert result[1]["models"] == ["gemini-3-flash-preview"]
|
||||
|
||||
|
||||
def test_empty_models_row_dropped(monkeypatch):
|
||||
"""Built-in provider with an empty ``models`` list is dropped."""
|
||||
base = [
|
||||
_make_provider("anthropic", models=[]), # drop
|
||||
_make_provider("openrouter", models=["anything"]), # replaced by live
|
||||
]
|
||||
|
||||
monkeypatch.setattr(model_switch, "list_authenticated_providers",
|
||||
lambda **kw: list(base))
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
|
||||
lambda *a, **kw: [("openai/gpt-5.4", "recommended")])
|
||||
|
||||
result = model_switch.list_picker_providers(max_models=50)
|
||||
|
||||
assert [p["slug"] for p in result] == ["openrouter"]
|
||||
|
||||
|
||||
def test_custom_endpoint_with_api_url_kept_when_models_empty(monkeypatch):
|
||||
"""User-defined endpoints with an ``api_url`` survive even if models empty.
|
||||
|
||||
Rationale: custom endpoints may accept any model id the user types --
|
||||
the picker still shows the row so the user can enter one manually.
|
||||
"""
|
||||
base = [
|
||||
_make_provider("local-ollama", is_user_defined=True,
|
||||
api_url="http://localhost:11434/v1", models=[],
|
||||
source="user-config"),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(model_switch, "list_authenticated_providers",
|
||||
lambda **kw: list(base))
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
|
||||
lambda *a, **kw: [])
|
||||
|
||||
result = model_switch.list_picker_providers(max_models=50)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["slug"] == "local-ollama"
|
||||
assert result[0]["models"] == []
|
||||
|
||||
|
||||
def test_user_defined_without_api_url_and_empty_models_dropped(monkeypatch):
|
||||
"""An is_user_defined row WITHOUT api_url and no models is still dropped.
|
||||
|
||||
The exemption is specifically for custom endpoints that can accept
|
||||
arbitrary model ids; without an api_url there's nothing to point at.
|
||||
"""
|
||||
base = [
|
||||
_make_provider("orphan", is_user_defined=True, api_url=None, models=[]),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(model_switch, "list_authenticated_providers",
|
||||
lambda **kw: list(base))
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
|
||||
lambda *a, **kw: [])
|
||||
|
||||
result = model_switch.list_picker_providers(max_models=50)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_max_models_caps_openrouter_live_output(monkeypatch):
|
||||
"""``max_models`` caps how many OpenRouter IDs land in the row."""
|
||||
live = [(f"vendor/model-{i}", "") for i in range(20)]
|
||||
base = [_make_provider("openrouter", models=["placeholder"])]
|
||||
|
||||
monkeypatch.setattr(model_switch, "list_authenticated_providers",
|
||||
lambda **kw: list(base))
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
|
||||
lambda *a, **kw: list(live))
|
||||
|
||||
result = model_switch.list_picker_providers(max_models=5)
|
||||
|
||||
assert len(result) == 1
|
||||
assert len(result[0]["models"]) == 5
|
||||
assert result[0]["models"] == [mid for mid, _ in live[:5]]
|
||||
# total_models reflects the full live catalog, not the capped slice.
|
||||
assert result[0]["total_models"] == 20
|
||||
|
||||
|
||||
def test_passthrough_kwargs_to_base(monkeypatch):
|
||||
"""All kwargs (current_provider, user_providers, custom_providers, max_models)
|
||||
must be forwarded to ``list_authenticated_providers`` unchanged.
|
||||
"""
|
||||
captured = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(model_switch, "list_authenticated_providers", _capture)
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
|
||||
lambda *a, **kw: [])
|
||||
|
||||
model_switch.list_picker_providers(
|
||||
current_provider="openrouter",
|
||||
user_providers={"foo": {"api": "http://x"}},
|
||||
custom_providers=[{"name": "bar", "base_url": "http://y"}],
|
||||
max_models=12,
|
||||
)
|
||||
|
||||
assert captured["current_provider"] == "openrouter"
|
||||
assert captured["user_providers"] == {"foo": {"api": "http://x"}}
|
||||
assert captured["custom_providers"] == [{"name": "bar", "base_url": "http://y"}]
|
||||
assert captured["max_models"] == 12
|
||||
75
tests/hermes_cli/test_pin_kanban_board_env.py
Normal file
75
tests/hermes_cli/test_pin_kanban_board_env.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Tests for `_pin_kanban_board_env` helper invoked by `cmd_chat`.
|
||||
|
||||
Regression coverage for #20074: a chat session must export the active kanban
|
||||
board into `HERMES_KANBAN_BOARD` at boot so subprocess shell-outs (e.g.
|
||||
`hermes kanban …`) inherit the same board the in-process kanban tools resolve.
|
||||
Without this, a concurrent `hermes kanban boards switch` from another session
|
||||
can flip the global current-board file mid-turn and silently divert the
|
||||
shell calls to a different DB.
|
||||
"""
|
||||
import importlib
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_kanban_board_env():
|
||||
"""Snapshot `HERMES_KANBAN_BOARD` and restore it after the test.
|
||||
|
||||
`_pin_kanban_board_env()` writes to ``os.environ`` directly, bypassing
|
||||
any ``monkeypatch.setenv`` tracking. Without this fixture the mutation
|
||||
leaks into subsequent tests and breaks anything that resolves a kanban
|
||||
path from the env (e.g. ``TestSharedBoardPaths`` in test_kanban_db.py).
|
||||
"""
|
||||
prev = os.environ.get("HERMES_KANBAN_BOARD")
|
||||
os.environ.pop("HERMES_KANBAN_BOARD", None)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if prev is None:
|
||||
os.environ.pop("HERMES_KANBAN_BOARD", None)
|
||||
else:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = prev
|
||||
|
||||
|
||||
def test_pin_writes_resolved_board_when_env_unset(monkeypatch):
|
||||
main_mod = importlib.import_module("hermes_cli.main")
|
||||
|
||||
import hermes_cli.kanban_db as kdb
|
||||
monkeypatch.setattr(kdb, "get_current_board", lambda: "space")
|
||||
|
||||
main_mod._pin_kanban_board_env()
|
||||
|
||||
assert main_mod.os.environ.get("HERMES_KANBAN_BOARD") == "space"
|
||||
|
||||
|
||||
def test_pin_does_not_overwrite_existing_env(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_KANBAN_BOARD", "preset")
|
||||
main_mod = importlib.import_module("hermes_cli.main")
|
||||
|
||||
import hermes_cli.kanban_db as kdb
|
||||
|
||||
def _explode():
|
||||
raise AssertionError("get_current_board must not be called when env is set")
|
||||
|
||||
monkeypatch.setattr(kdb, "get_current_board", _explode)
|
||||
|
||||
main_mod._pin_kanban_board_env()
|
||||
|
||||
assert main_mod.os.environ.get("HERMES_KANBAN_BOARD") == "preset"
|
||||
|
||||
|
||||
def test_pin_swallows_resolution_failures(monkeypatch):
|
||||
main_mod = importlib.import_module("hermes_cli.main")
|
||||
|
||||
import hermes_cli.kanban_db as kdb
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("disk gone")
|
||||
|
||||
monkeypatch.setattr(kdb, "get_current_board", _boom)
|
||||
|
||||
main_mod._pin_kanban_board_env()
|
||||
|
||||
assert "HERMES_KANBAN_BOARD" not in main_mod.os.environ
|
||||
157
tests/hermes_cli/test_prompt_api_key.py
Normal file
157
tests/hermes_cli/test_prompt_api_key.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Tests for ``_prompt_api_key`` — the shared Keep/Replace/Clear menu used by
|
||||
``hermes setup`` / ``hermes model`` when an API key already exists in ``.env``.
|
||||
|
||||
Regression coverage for #16394: the wizard used to silently skip the key prompt
|
||||
when any value was present (even malformed junk), leaving users stuck.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profile_env(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
(home / ".env").write_text("")
|
||||
return home
|
||||
|
||||
|
||||
def _pconfig(name="deepseek"):
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
return PROVIDER_REGISTRY[name]
|
||||
|
||||
|
||||
def _run_prompt(existing_key, choice, new_key="", provider_id="", pconfig_name="deepseek"):
|
||||
"""Invoke _prompt_api_key with mocked input()/getpass() responses."""
|
||||
from hermes_cli import main as m
|
||||
|
||||
pconfig = _pconfig(pconfig_name)
|
||||
with patch("builtins.input", return_value=choice), \
|
||||
patch("getpass.getpass", return_value=new_key):
|
||||
return m._prompt_api_key(pconfig, existing_key, provider_id=provider_id)
|
||||
|
||||
|
||||
# First-time entry ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_first_time_save_new_key(profile_env):
|
||||
from hermes_cli.config import get_env_value
|
||||
|
||||
key, abort = _run_prompt(existing_key="", choice="", new_key="sk-abcdef")
|
||||
assert key == "sk-abcdef"
|
||||
assert abort is False
|
||||
assert get_env_value("DEEPSEEK_API_KEY") == "sk-abcdef"
|
||||
|
||||
|
||||
def test_first_time_cancelled(profile_env):
|
||||
key, abort = _run_prompt(existing_key="", choice="", new_key="")
|
||||
assert key == ""
|
||||
assert abort is True
|
||||
|
||||
|
||||
# Already configured — K / R / C ───────────────────────────────────────────────
|
||||
|
||||
def test_keep_default_empty_input(profile_env):
|
||||
from hermes_cli.config import save_env_value
|
||||
save_env_value("DEEPSEEK_API_KEY", "sk-existing")
|
||||
|
||||
key, abort = _run_prompt(existing_key="sk-existing", choice="")
|
||||
assert key == "sk-existing"
|
||||
assert abort is False
|
||||
|
||||
|
||||
def test_keep_letter_k(profile_env):
|
||||
key, abort = _run_prompt(existing_key="sk-existing", choice="k")
|
||||
assert key == "sk-existing"
|
||||
assert abort is False
|
||||
|
||||
|
||||
def test_keep_on_unrecognised_input(profile_env):
|
||||
"""Garbage input falls through to keep — never destroys the user's key."""
|
||||
key, abort = _run_prompt(existing_key="sk-existing", choice="xyz")
|
||||
assert key == "sk-existing"
|
||||
assert abort is False
|
||||
|
||||
|
||||
def test_replace_saves_new_key(profile_env):
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
save_env_value("DEEPSEEK_API_KEY", "sk-malformed-junk")
|
||||
|
||||
key, abort = _run_prompt(
|
||||
existing_key="sk-malformed-junk", choice="r", new_key="sk-fresh"
|
||||
)
|
||||
assert key == "sk-fresh"
|
||||
assert abort is False
|
||||
assert get_env_value("DEEPSEEK_API_KEY") == "sk-fresh"
|
||||
|
||||
|
||||
def test_replace_cancelled_preserves_key(profile_env):
|
||||
"""Empty entry to the Replace prompt means cancel — keeps the old key intact."""
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
save_env_value("DEEPSEEK_API_KEY", "sk-existing")
|
||||
|
||||
key, abort = _run_prompt(
|
||||
existing_key="sk-existing", choice="r", new_key=""
|
||||
)
|
||||
assert key == "sk-existing"
|
||||
assert abort is False
|
||||
assert get_env_value("DEEPSEEK_API_KEY") == "sk-existing"
|
||||
|
||||
|
||||
def test_clear_wipes_env_and_aborts(profile_env):
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
save_env_value("DEEPSEEK_API_KEY", "sk-existing")
|
||||
save_env_value("OTHER_VAR", "keep-me")
|
||||
|
||||
key, abort = _run_prompt(existing_key="sk-existing", choice="c")
|
||||
assert key == ""
|
||||
assert abort is True
|
||||
# Cleared, but sibling entries untouched.
|
||||
assert not get_env_value("DEEPSEEK_API_KEY")
|
||||
assert get_env_value("OTHER_VAR") == "keep-me"
|
||||
|
||||
|
||||
def test_ctrl_c_at_choice_prompt_keeps(profile_env):
|
||||
from hermes_cli import main as m
|
||||
|
||||
pconfig = _pconfig("deepseek")
|
||||
with patch("builtins.input", side_effect=KeyboardInterrupt):
|
||||
key, abort = m._prompt_api_key(pconfig, "sk-existing")
|
||||
assert key == "sk-existing"
|
||||
assert abort is False
|
||||
|
||||
|
||||
# LM Studio no-auth placeholder ────────────────────────────────────────────────
|
||||
|
||||
def test_lmstudio_first_time_empty_uses_placeholder(profile_env):
|
||||
from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
|
||||
from hermes_cli.config import get_env_value
|
||||
|
||||
key, abort = _run_prompt(
|
||||
existing_key="", choice="", new_key="",
|
||||
provider_id="lmstudio", pconfig_name="lmstudio",
|
||||
)
|
||||
assert key == LMSTUDIO_NOAUTH_PLACEHOLDER
|
||||
assert abort is False
|
||||
assert get_env_value("LM_API_KEY") == LMSTUDIO_NOAUTH_PLACEHOLDER
|
||||
|
||||
|
||||
def test_lmstudio_replace_empty_does_not_overwrite_with_placeholder(profile_env):
|
||||
"""On REPLACE with empty input, preserve the user's existing key — do NOT
|
||||
silently substitute the placeholder. The placeholder path only fires for
|
||||
first-time configuration where the user has made no explicit choice yet."""
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
save_env_value("LM_API_KEY", "my-real-lmstudio-key")
|
||||
|
||||
key, abort = _run_prompt(
|
||||
existing_key="my-real-lmstudio-key", choice="r", new_key="",
|
||||
provider_id="lmstudio", pconfig_name="lmstudio",
|
||||
)
|
||||
assert key == "my-real-lmstudio-key"
|
||||
assert abort is False
|
||||
assert get_env_value("LM_API_KEY") == "my-real-lmstudio-key"
|
||||
|
|
@ -1,6 +1,28 @@
|
|||
from hermes_cli import setup as setup_mod
|
||||
|
||||
|
||||
def test_prompt_strips_bracketed_paste_markers(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"builtins.input",
|
||||
lambda _prompt="": "\x1b[200~sk-ant-api-key\x1b[201~",
|
||||
)
|
||||
|
||||
value = setup_mod.prompt("API key")
|
||||
|
||||
assert value == "sk-ant-api-key"
|
||||
|
||||
|
||||
def test_password_prompt_strips_bracketed_paste_markers(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"getpass.getpass",
|
||||
lambda _prompt="": "\x1b[200~secret-token\x1b[201~",
|
||||
)
|
||||
|
||||
value = setup_mod.prompt("API key", password=True)
|
||||
|
||||
assert value == "secret-token"
|
||||
|
||||
|
||||
def test_prompt_choice_uses_curses_helper(monkeypatch):
|
||||
monkeypatch.setattr(setup_mod, "_curses_prompt_choice", lambda question, choices, default=0, description=None: 1)
|
||||
|
||||
|
|
|
|||
|
|
@ -561,6 +561,49 @@ def test_bulk_status_ready(client):
|
|||
assert {a["id"], b["id"], c2["id"]}.issubset(ids)
|
||||
|
||||
|
||||
def test_bulk_status_done_forwards_completion_summary(client):
|
||||
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
||||
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
||||
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/tasks/bulk",
|
||||
json={
|
||||
"ids": [a["id"], b["id"]],
|
||||
"status": "done",
|
||||
"result": "DECIDED: ship it",
|
||||
"summary": "DECIDED: ship it",
|
||||
"metadata": {"source": "dashboard"},
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert all(r["ok"] for r in r.json()["results"])
|
||||
conn = kb.connect()
|
||||
try:
|
||||
for tid in (a["id"], b["id"]):
|
||||
task = kb.get_task(conn, tid)
|
||||
run = kb.latest_run(conn, tid)
|
||||
assert task.status == "done"
|
||||
assert task.result == "DECIDED: ship it"
|
||||
assert run.summary == "DECIDED: ship it"
|
||||
assert run.metadata == {"source": "dashboard"}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_dashboard_done_actions_prompt_for_completion_summary():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
bundle = (
|
||||
repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
|
||||
).read_text()
|
||||
|
||||
assert "withCompletionSummary" in bundle
|
||||
assert "Completion summary" in bundle
|
||||
assert "result: summary" in bundle
|
||||
assert "body: JSON.stringify(patch)" in bundle
|
||||
assert "body: JSON.stringify(finalPatch)" in bundle
|
||||
|
||||
|
||||
def test_bulk_archive(client):
|
||||
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
||||
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
||||
|
|
@ -1074,3 +1117,221 @@ def test_home_channels_empty_when_no_homes_configured(client, monkeypatch):
|
|||
r = client.get("/api/plugins/kanban/home-channels")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["home_channels"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recovery endpoints (reclaim + reassign) and warnings field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_board_surfaces_warnings_field_for_hallucinated_completions(client):
|
||||
"""Tasks with a pending completion_blocked_hallucination event surface
|
||||
a ``warnings`` object on the /board payload so the UI can badge
|
||||
them without fetching per-task events."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
parent = kb.create_task(conn, title="parent", assignee="alice")
|
||||
real = kb.create_task(conn, title="real", assignee="x", created_by="alice")
|
||||
|
||||
import pytest as _pytest
|
||||
with _pytest.raises(kb.HallucinatedCardsError):
|
||||
kb.complete_task(
|
||||
conn, parent,
|
||||
summary="claimed phantom",
|
||||
created_cards=[real, "t_deadbeefcafe"],
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.get("/api/plugins/kanban/board")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
tasks = [t for col in data["columns"] for t in col["tasks"]]
|
||||
parent_dict = next(t for t in tasks if t["title"] == "parent")
|
||||
assert parent_dict.get("warnings") is not None
|
||||
w = parent_dict["warnings"]
|
||||
assert w["count"] >= 1
|
||||
assert "completion_blocked_hallucination" in w["kinds"]
|
||||
|
||||
|
||||
def test_board_warnings_cleared_after_clean_completion(client):
|
||||
"""A completed or edited event after a hallucination event clears
|
||||
the warning badge — we don't mark tasks permanently."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
parent = kb.create_task(conn, title="parent", assignee="alice")
|
||||
real = kb.create_task(conn, title="real", assignee="x", created_by="alice")
|
||||
|
||||
import pytest as _pytest
|
||||
with _pytest.raises(kb.HallucinatedCardsError):
|
||||
kb.complete_task(
|
||||
conn, parent,
|
||||
summary="first attempt phantom",
|
||||
created_cards=[real, "t_phantom11"],
|
||||
)
|
||||
|
||||
# Second attempt drops the bad id — succeeds.
|
||||
ok = kb.complete_task(
|
||||
conn, parent,
|
||||
summary="retry without phantom",
|
||||
created_cards=[real],
|
||||
)
|
||||
assert ok is True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.get("/api/plugins/kanban/board", params={"include_archived": True})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
tasks = [t for col in data["columns"] for t in col["tasks"]]
|
||||
parent_dict = next(t for t in tasks if t["title"] == "parent")
|
||||
# The clean completion wiped the warning.
|
||||
assert parent_dict.get("warnings") is None
|
||||
|
||||
|
||||
def test_reclaim_endpoint_releases_running_claim(client):
|
||||
"""POST /tasks/<id>/reclaim drops the claim, returns ok, and emits
|
||||
a manual reclaimed event."""
|
||||
import secrets
|
||||
conn = kb.connect()
|
||||
try:
|
||||
t = kb.create_task(conn, title="running", assignee="x")
|
||||
lock = secrets.token_hex(8)
|
||||
future = int(time.time()) + 3600
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
|
||||
"worker_pid=? WHERE id=?",
|
||||
(lock, future, 99999, t),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
|
||||
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
|
||||
(t, lock, future, 99999, int(time.time())),
|
||||
)
|
||||
run_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (run_id, t))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{t}/reclaim",
|
||||
json={"reason": "browser recovery"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["task_id"] == t
|
||||
|
||||
# Confirm the task is back to ready.
|
||||
conn2 = kb.connect()
|
||||
try:
|
||||
row = conn2.execute(
|
||||
"SELECT status, claim_lock FROM tasks WHERE id=?", (t,),
|
||||
).fetchone()
|
||||
assert row["status"] == "ready"
|
||||
assert row["claim_lock"] is None
|
||||
finally:
|
||||
conn2.close()
|
||||
|
||||
|
||||
def test_reclaim_endpoint_409_for_non_running_task(client):
|
||||
"""Reclaiming a task that's already ready returns 409."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
t = kb.create_task(conn, title="ready", assignee="x")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{t}/reclaim",
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_reassign_endpoint_switches_profile(client):
|
||||
"""POST /tasks/<id>/reassign changes the assignee field."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
t = kb.create_task(conn, title="task", assignee="orig")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{t}/reassign",
|
||||
json={"profile": "newbie", "reclaim_first": False},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["assignee"] == "newbie"
|
||||
|
||||
conn2 = kb.connect()
|
||||
try:
|
||||
row = conn2.execute(
|
||||
"SELECT assignee FROM tasks WHERE id=?", (t,),
|
||||
).fetchone()
|
||||
assert row["assignee"] == "newbie"
|
||||
finally:
|
||||
conn2.close()
|
||||
|
||||
|
||||
def test_reassign_endpoint_409_on_running_without_reclaim(client):
|
||||
"""Reassigning a running task without reclaim_first returns 409."""
|
||||
import secrets
|
||||
conn = kb.connect()
|
||||
try:
|
||||
t = kb.create_task(conn, title="running", assignee="orig")
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='running', claim_lock=? WHERE id=?",
|
||||
(secrets.token_hex(4), t),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{t}/reassign",
|
||||
json={"profile": "new", "reclaim_first": False},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_reassign_endpoint_with_reclaim_first_succeeds_on_running(client):
|
||||
"""With reclaim_first=true, a running task is reclaimed+reassigned in
|
||||
one call."""
|
||||
import secrets
|
||||
conn = kb.connect()
|
||||
try:
|
||||
t = kb.create_task(conn, title="running", assignee="orig")
|
||||
lock = secrets.token_hex(4)
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
|
||||
"worker_pid=? WHERE id=?",
|
||||
(lock, int(time.time()) + 3600, 1234, t),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
|
||||
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
|
||||
(t, lock, int(time.time()) + 3600, 1234, int(time.time())),
|
||||
)
|
||||
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (rid, t))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{t}/reassign",
|
||||
json={"profile": "new", "reclaim_first": True, "reason": "switch"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["assignee"] == "new"
|
||||
|
||||
conn2 = kb.connect()
|
||||
try:
|
||||
row = conn2.execute(
|
||||
"SELECT status, assignee FROM tasks WHERE id=?", (t,),
|
||||
).fetchone()
|
||||
assert row["status"] == "ready"
|
||||
assert row["assignee"] == "new"
|
||||
finally:
|
||||
conn2.close()
|
||||
|
|
|
|||
107
tests/run_agent/test_last_reasoning_per_turn.py
Normal file
107
tests/run_agent/test_last_reasoning_per_turn.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Tests for per-turn reasoning extraction in AIAgent.run_conversation.
|
||||
|
||||
Verifies the reasoning field returned to display layers (CLI reasoning box,
|
||||
gateway reasoning footer, TUI reasoning event) only reflects the CURRENT
|
||||
turn's reasoning — never leaks from a prior turn — and is picked up
|
||||
correctly when reasoning is attached to a tool-calling assistant step
|
||||
rather than the final-answer assistant step.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _extract_last_reasoning(messages):
|
||||
"""Replica of the extraction loop in run_agent.py (~line 13867).
|
||||
|
||||
Tests pin the loop's behaviour so that refactors can't silently
|
||||
regress the per-turn semantic.
|
||||
"""
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
return last_reasoning
|
||||
|
||||
|
||||
def test_simple_turn_reasoning_present():
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi", "reasoning": "greeting the user"},
|
||||
]
|
||||
assert _extract_last_reasoning(messages) == "greeting the user"
|
||||
|
||||
|
||||
def test_simple_turn_no_reasoning():
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi", "reasoning": None},
|
||||
]
|
||||
assert _extract_last_reasoning(messages) is None
|
||||
|
||||
|
||||
def test_tool_call_turn_reasoning_on_tool_call_step():
|
||||
"""When the model reasons on the tool-call step and the final-answer
|
||||
step has no reasoning (Claude thinking / DeepSeek v4 / Codex Responses
|
||||
pattern), the box must show the tool-call-step reasoning, not empty.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "user", "content": "search the repo for X"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning": "I should use search_files",
|
||||
"tool_calls": [{"id": "c1", "type": "function",
|
||||
"function": {"name": "search_files", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "3 matches"},
|
||||
{"role": "assistant", "content": "Found 3 matches", "reasoning": None},
|
||||
]
|
||||
assert _extract_last_reasoning(messages) == "I should use search_files"
|
||||
|
||||
|
||||
def test_no_stale_reasoning_across_turns():
|
||||
"""The regression the whole change exists for. Prior turn had
|
||||
reasoning; current turn has none. The reasoning box must NOT show
|
||||
the prior turn's text.
|
||||
"""
|
||||
messages = [
|
||||
# prior turn
|
||||
{"role": "user", "content": "explain quantum tunneling"},
|
||||
{"role": "assistant", "content": "It's when...",
|
||||
"reasoning": "tunneling happens when particles..."},
|
||||
# current turn
|
||||
{"role": "user", "content": "thanks"},
|
||||
{"role": "assistant", "content": "You're welcome!", "reasoning": None},
|
||||
]
|
||||
assert _extract_last_reasoning(messages) is None
|
||||
|
||||
|
||||
def test_tool_call_turn_picks_latest_reasoning_within_turn():
|
||||
"""If BOTH the tool-call step and the final step have reasoning
|
||||
(uncommon but possible), the final-step reasoning wins — it's the
|
||||
most recent thought within the current turn.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "user", "content": "search and summarize"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning": "initial plan",
|
||||
"tool_calls": [{"id": "c1", "type": "function",
|
||||
"function": {"name": "search_files", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "results"},
|
||||
{"role": "assistant", "content": "Here's the summary",
|
||||
"reasoning": "synthesized view of results"},
|
||||
]
|
||||
assert _extract_last_reasoning(messages) == "synthesized view of results"
|
||||
|
||||
|
||||
def test_empty_string_reasoning_treated_as_missing():
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello", "reasoning": ""},
|
||||
]
|
||||
assert _extract_last_reasoning(messages) is None
|
||||
|
|
@ -24,7 +24,7 @@ def test_openrouter_base_url_applies_or_headers(mock_openai):
|
|||
|
||||
headers = agent._client_kwargs["default_headers"]
|
||||
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
|
||||
assert headers["X-OpenRouter-Title"] == "Hermes Agent"
|
||||
assert headers["X-Title"] == "Hermes Agent"
|
||||
|
||||
|
||||
@patch("run_agent.OpenAI")
|
||||
|
|
|
|||
|
|
@ -4990,6 +4990,28 @@ class TestDeadRetryCode:
|
|||
)
|
||||
|
||||
|
||||
class TestSupportsReasoningExtraBody:
|
||||
def _make_agent(self):
|
||||
agent = object.__new__(AIAgent)
|
||||
agent.provider = "openrouter"
|
||||
agent.base_url = "https://openrouter.ai/api/v1"
|
||||
agent._base_url_lower = agent.base_url.lower()
|
||||
agent.model = ""
|
||||
return agent
|
||||
|
||||
def test_xiaomi_models_are_treated_as_reasoning_capable(self):
|
||||
agent = self._make_agent()
|
||||
for model in (
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
"xiaomi/mimo-v2.5",
|
||||
"xiaomi/mimo-v2-omni",
|
||||
"xiaomi/mimo-v2-pro",
|
||||
"xiaomi/mimo-v2-flash",
|
||||
):
|
||||
agent.model = model
|
||||
assert agent._supports_reasoning_extra_body() is True, model
|
||||
|
||||
|
||||
class TestMemoryContextSanitization:
|
||||
"""sanitize_context() helper correctness — used at provider boundaries."""
|
||||
|
||||
|
|
|
|||
|
|
@ -241,6 +241,23 @@ class TestSkillViewQualifiedName:
|
|||
assert result["success"] is False
|
||||
assert "not found" in result["error"].lower()
|
||||
|
||||
def test_category_qualified_local_skill_falls_through(self, tmp_path, monkeypatch):
|
||||
from tools.skills_tool import skill_view
|
||||
|
||||
local_skills = tmp_path / "local-skills"
|
||||
skill_dir = local_skills / "productivity" / "ticktick"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: ticktick\ndescription: local categorized\n---\nTickTick body.\n"
|
||||
)
|
||||
monkeypatch.setattr("tools.skills_tool.SKILLS_DIR", local_skills)
|
||||
|
||||
result = json.loads(skill_view("productivity:ticktick"))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["name"] == "ticktick"
|
||||
assert "TickTick body." in result["content"]
|
||||
|
||||
def test_stale_entry_self_heals(self, tmp_path):
|
||||
from tools.skills_tool import skill_view
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,21 @@ class TestGetToolset:
|
|||
assert ts is not None
|
||||
assert "web_search" in ts["tools"]
|
||||
|
||||
def test_merges_registry_tools_into_builtin_toolset(self, monkeypatch):
|
||||
reg = ToolRegistry()
|
||||
reg.register(
|
||||
name="web_search_plus",
|
||||
toolset="web",
|
||||
schema=_make_schema("web_search_plus", "Plugin web search"),
|
||||
handler=_dummy_handler,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("tools.registry.registry", reg)
|
||||
|
||||
ts = get_toolset("web")
|
||||
assert ts is not None
|
||||
assert set(ts["tools"]) == {"web_search", "web_extract", "web_search_plus"}
|
||||
|
||||
def test_unknown_returns_none(self):
|
||||
assert get_toolset("nonexistent") is None
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,11 @@ class TestIsLikelyBinary:
|
|||
|
||||
|
||||
class TestCheckLintBracePaths:
|
||||
"""Verify _check_lint handles file paths with curly braces safely."""
|
||||
"""Verify _check_lint handles file paths with curly braces safely.
|
||||
|
||||
Uses ``.js`` to exercise the shell-linter path since ``.py`` now goes
|
||||
through the in-process ast.parse linter (see TestCheckLintInproc).
|
||||
"""
|
||||
|
||||
@pytest.fixture()
|
||||
def ops(self):
|
||||
|
|
@ -95,12 +99,12 @@ class TestCheckLintBracePaths:
|
|||
with patch.object(ops, "_has_command", return_value=True), \
|
||||
patch.object(ops, "_exec") as mock_exec:
|
||||
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
|
||||
result = ops._check_lint("/tmp/test_file.py")
|
||||
result = ops._check_lint("/tmp/test_file.js")
|
||||
|
||||
assert result.success is True
|
||||
# Verify the command was built correctly
|
||||
cmd_arg = mock_exec.call_args[0][0]
|
||||
assert "'/tmp/test_file.py'" in cmd_arg
|
||||
assert "'/tmp/test_file.js'" in cmd_arg
|
||||
|
||||
def test_path_with_curly_braces(self, ops):
|
||||
"""Path containing ``{`` and ``}`` must not raise KeyError/ValueError."""
|
||||
|
|
@ -108,7 +112,7 @@ class TestCheckLintBracePaths:
|
|||
patch.object(ops, "_exec") as mock_exec:
|
||||
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
|
||||
# This would raise KeyError with .format() but works with .replace()
|
||||
result = ops._check_lint("/tmp/{test}_file.py")
|
||||
result = ops._check_lint("/tmp/{test}_file.js")
|
||||
|
||||
assert result.success is True
|
||||
cmd_arg = mock_exec.call_args[0][0]
|
||||
|
|
@ -119,7 +123,7 @@ class TestCheckLintBracePaths:
|
|||
with patch.object(ops, "_has_command", return_value=True), \
|
||||
patch.object(ops, "_exec") as mock_exec:
|
||||
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
|
||||
result = ops._check_lint("/tmp/{{var}}.py")
|
||||
result = ops._check_lint("/tmp/{{var}}.js")
|
||||
|
||||
assert result.success is True
|
||||
|
||||
|
|
@ -131,7 +135,7 @@ class TestCheckLintBracePaths:
|
|||
def test_missing_linter_skipped(self, ops):
|
||||
"""When the linter binary is not installed, skip gracefully."""
|
||||
with patch.object(ops, "_has_command", return_value=False):
|
||||
result = ops._check_lint("/tmp/test.py")
|
||||
result = ops._check_lint("/tmp/test.js")
|
||||
assert result.skipped is True
|
||||
|
||||
def test_lint_failure_returns_output(self, ops):
|
||||
|
|
@ -142,12 +146,122 @@ class TestCheckLintBracePaths:
|
|||
exit_code=1,
|
||||
stdout="SyntaxError: invalid syntax",
|
||||
)
|
||||
result = ops._check_lint("/tmp/bad.py")
|
||||
result = ops._check_lint("/tmp/bad.js")
|
||||
|
||||
assert result.success is False
|
||||
assert "SyntaxError" in result.output
|
||||
|
||||
|
||||
class TestCheckLintInproc:
|
||||
"""Verify in-process linters (.py via ast.parse, .json, .yaml, .toml).
|
||||
|
||||
These bypass the shell linter table entirely and parse content
|
||||
directly in Python — no subprocess, no toolchain dependency.
|
||||
"""
|
||||
|
||||
@pytest.fixture()
|
||||
def ops(self):
|
||||
obj = ShellFileOperations.__new__(ShellFileOperations)
|
||||
obj._command_cache = {}
|
||||
return obj
|
||||
|
||||
def test_python_inproc_clean(self, ops):
|
||||
"""Valid Python content passes in-process ast.parse."""
|
||||
result = ops._check_lint("/tmp/ok.py", content="x = 1\n")
|
||||
assert result.success is True
|
||||
assert not result.skipped
|
||||
assert result.output == ""
|
||||
|
||||
def test_python_inproc_syntax_error(self, ops):
|
||||
"""Invalid Python content fails with SyntaxError + line info."""
|
||||
result = ops._check_lint("/tmp/bad.py", content="def foo(:\n pass\n")
|
||||
assert result.success is False
|
||||
assert "SyntaxError" in result.output
|
||||
assert "line" in result.output.lower()
|
||||
|
||||
def test_python_inproc_content_explicit(self, ops):
|
||||
"""When content is passed explicitly, the file is not re-read."""
|
||||
with patch.object(ops, "_exec") as mock_exec:
|
||||
result = ops._check_lint("/tmp/explicit.py", content="y = 2\n")
|
||||
# _exec must not have been called — content was supplied
|
||||
mock_exec.assert_not_called()
|
||||
assert result.success is True
|
||||
|
||||
def test_json_inproc_clean(self, ops):
|
||||
result = ops._check_lint("/tmp/a.json", content='{"a": 1}')
|
||||
assert result.success is True
|
||||
|
||||
def test_json_inproc_error(self, ops):
|
||||
result = ops._check_lint("/tmp/b.json", content='{"a": 1')
|
||||
assert result.success is False
|
||||
assert "JSONDecodeError" in result.output
|
||||
|
||||
def test_yaml_inproc_clean(self, ops):
|
||||
result = ops._check_lint("/tmp/a.yaml", content="a: 1\nb: 2\n")
|
||||
assert result.success is True
|
||||
|
||||
def test_yaml_inproc_error(self, ops):
|
||||
result = ops._check_lint("/tmp/b.yaml", content='key: "unclosed\n')
|
||||
assert result.success is False
|
||||
assert "YAMLError" in result.output
|
||||
|
||||
def test_toml_inproc_clean(self, ops):
|
||||
result = ops._check_lint("/tmp/a.toml", content='[section]\nk = "v"\n')
|
||||
assert result.success is True
|
||||
|
||||
def test_toml_inproc_error(self, ops):
|
||||
result = ops._check_lint("/tmp/b.toml", content='[section\nk = "v"')
|
||||
assert result.success is False
|
||||
assert "TOMLDecodeError" in result.output
|
||||
|
||||
|
||||
class TestCheckLintDelta:
|
||||
"""Verify _check_lint_delta() filters pre-existing errors from post-edit output."""
|
||||
|
||||
@pytest.fixture()
|
||||
def ops(self):
|
||||
obj = ShellFileOperations.__new__(ShellFileOperations)
|
||||
obj._command_cache = {}
|
||||
return obj
|
||||
|
||||
def test_clean_post_no_pre_lint(self, ops):
|
||||
"""Hot path: post-write is clean, pre-lint should be skipped entirely."""
|
||||
with patch.object(ops, "_check_lint", wraps=ops._check_lint) as wrapped:
|
||||
r = ops._check_lint_delta("/tmp/a.py", pre_content="x = 0\n", post_content="x = 1\n")
|
||||
# Post-lint called exactly once (clean), pre-lint never called.
|
||||
assert wrapped.call_count == 1
|
||||
assert r.success is True
|
||||
|
||||
def test_new_file_reports_all_errors(self, ops):
|
||||
"""No pre-content means no delta refinement — all post errors surface."""
|
||||
r = ops._check_lint_delta("/tmp/new.py", pre_content=None, post_content="def x(:\n")
|
||||
assert r.success is False
|
||||
assert "SyntaxError" in r.output
|
||||
|
||||
def test_broken_file_becomes_good(self, ops):
|
||||
"""Post-clean short-circuits without any delta refinement."""
|
||||
r = ops._check_lint_delta("/tmp/fix.py", pre_content="def x(:\n", post_content="def x():\n pass\n")
|
||||
assert r.success is True
|
||||
|
||||
def test_introduces_new_error_filters_pre(self, ops):
|
||||
"""Delta filter drops pre-existing errors, surfaces only new ones."""
|
||||
pre = 'def a(:\n pass\n' # line 1 broken
|
||||
post = 'def a():\n pass\n\ndef b(:\n pass\n' # line 1 fixed, line 4 broken
|
||||
r = ops._check_lint_delta("/tmp/d.py", pre_content=pre, post_content=post)
|
||||
assert r.success is False
|
||||
assert "New lint errors" in r.output or "line 4" in r.output
|
||||
|
||||
def test_pre_existing_remains_flagged_but_not_new(self, ops):
|
||||
"""Single-error parsers (ast) may miss that post is OK — be cautious."""
|
||||
# Pre has line-1 error, post keeps it (and doesn't add anything new)
|
||||
pre = 'def a(:\n pass\n'
|
||||
post = 'def a(:\n pass\n\nprint(42)\n' # still line 1 broken
|
||||
r = ops._check_lint_delta("/tmp/d.py", pre_content=pre, post_content=post)
|
||||
# File is still broken — don't lie and claim success — but flag it as pre-existing
|
||||
assert r.success is False
|
||||
assert "pre-existing" in (r.message or "").lower()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Pagination bounds
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||
|
||||
import copy
|
||||
|
||||
from tools.schema_sanitizer import sanitize_tool_schemas
|
||||
from tools.schema_sanitizer import sanitize_tool_schemas, strip_pattern_and_format
|
||||
|
||||
|
||||
def _tool(name: str, parameters: dict) -> dict:
|
||||
|
|
@ -203,3 +203,102 @@ def test_empty_tools_list_returns_empty():
|
|||
|
||||
def test_none_tools_returns_none():
|
||||
assert sanitize_tool_schemas(None) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# strip_pattern_and_format — reactive recovery when llama.cpp rejects a
|
||||
# schema with an HTTP 400 grammar-parse error. Must be opt-in (only
|
||||
# invoked on recovery) and must not damage property names.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_strip_pattern_removes_schema_pattern_keyword():
|
||||
"""`pattern` as a sibling of `type` → stripped."""
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {"type": "string", "pattern": "\\d{4,4}-\\d{2,2}-\\d{2,2}"},
|
||||
},
|
||||
})]
|
||||
_, stripped = strip_pattern_and_format(tools)
|
||||
assert stripped == 1
|
||||
prop = tools[0]["function"]["parameters"]["properties"]["date"]
|
||||
assert "pattern" not in prop
|
||||
assert prop["type"] == "string"
|
||||
|
||||
|
||||
def test_strip_format_removes_schema_format_keyword():
|
||||
"""`format` as a sibling of `type` → stripped."""
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ts": {"type": "string", "format": "date-time"},
|
||||
},
|
||||
})]
|
||||
_, stripped = strip_pattern_and_format(tools)
|
||||
assert stripped == 1
|
||||
assert "format" not in tools[0]["function"]["parameters"]["properties"]["ts"]
|
||||
|
||||
|
||||
def test_strip_preserves_property_named_pattern():
|
||||
"""Property literally *named* 'pattern' (search_files) must survive."""
|
||||
tools = [_tool("search_files", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Regex pattern..."},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": ["pattern"],
|
||||
})]
|
||||
_, stripped = strip_pattern_and_format(tools)
|
||||
assert stripped == 0
|
||||
params = tools[0]["function"]["parameters"]
|
||||
# Property named "pattern" still exists with its schema intact
|
||||
assert "pattern" in params["properties"]
|
||||
assert params["properties"]["pattern"]["type"] == "string"
|
||||
assert params["required"] == ["pattern"]
|
||||
|
||||
|
||||
def test_strip_recurses_into_anyof_variants():
|
||||
"""Pattern/format inside anyOf variant schemas are also stripped."""
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{"type": "string", "pattern": "[A-Z]+", "format": "uuid"},
|
||||
{"type": "integer"},
|
||||
],
|
||||
},
|
||||
},
|
||||
})]
|
||||
_, stripped = strip_pattern_and_format(tools)
|
||||
assert stripped == 2
|
||||
variants = tools[0]["function"]["parameters"]["properties"]["value"]["anyOf"]
|
||||
assert "pattern" not in variants[0]
|
||||
assert "format" not in variants[0]
|
||||
assert variants[0]["type"] == "string"
|
||||
|
||||
|
||||
def test_strip_is_idempotent():
|
||||
"""Second call on already-stripped tools is a no-op."""
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {"d": {"type": "string", "pattern": "\\d+"}},
|
||||
})]
|
||||
_, first = strip_pattern_and_format(tools)
|
||||
_, second = strip_pattern_and_format(tools)
|
||||
assert first == 1
|
||||
assert second == 0
|
||||
|
||||
|
||||
def test_strip_empty_tools_returns_zero():
|
||||
tools, stripped = strip_pattern_and_format([])
|
||||
assert tools == []
|
||||
assert stripped == 0
|
||||
|
||||
|
||||
def test_strip_none_returns_zero():
|
||||
tools, stripped = strip_pattern_and_format(None)
|
||||
assert tools is None
|
||||
assert stripped == 0
|
||||
|
|
|
|||
|
|
@ -838,12 +838,13 @@ class TestExternalSkillMutations:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pinned-skill guard — skill_manage refuses all writes to pinned skills.
|
||||
# The user unpins via `hermes curator unpin <name>`.
|
||||
# Pinned-skill guard — skill_manage refuses only `delete` on pinned skills.
|
||||
# Patches and edits go through so pinned skills can still evolve as pitfalls
|
||||
# come up. The user unpins via `hermes curator unpin <name>` to delete.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPinnedGuard:
|
||||
"""Every mutation action must refuse when the skill is pinned."""
|
||||
"""Delete is refused on pinned skills; patch/edit/write_file/remove_file are allowed."""
|
||||
|
||||
@staticmethod
|
||||
def _pin(name: str):
|
||||
|
|
@ -852,31 +853,28 @@ class TestPinnedGuard:
|
|||
return {"pinned": True} if skill_name == _name else {"pinned": False}
|
||||
return patch("tools.skill_usage.get_record", side_effect=_fake_get_record)
|
||||
|
||||
def test_edit_refuses_pinned(self, tmp_path):
|
||||
def test_edit_allowed_when_pinned(self, tmp_path):
|
||||
"""Pin does NOT block edit — agent can still improve pinned skills."""
|
||||
with _skill_dir(tmp_path):
|
||||
_create_skill("my-skill", VALID_SKILL_CONTENT)
|
||||
with self._pin("my-skill"):
|
||||
result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2)
|
||||
assert result["success"] is False
|
||||
assert "pinned" in result["error"].lower()
|
||||
assert "hermes curator unpin my-skill" in result["error"]
|
||||
# Original content preserved
|
||||
assert result["success"] is True, result
|
||||
# Content updated
|
||||
content = (tmp_path / "my-skill" / "SKILL.md").read_text()
|
||||
assert "A test skill" in content
|
||||
assert "A test skill" not in content
|
||||
|
||||
def test_patch_refuses_pinned(self, tmp_path):
|
||||
def test_patch_allowed_when_pinned(self, tmp_path):
|
||||
with _skill_dir(tmp_path):
|
||||
_create_skill("my-skill", VALID_SKILL_CONTENT)
|
||||
with self._pin("my-skill"):
|
||||
result = _patch_skill("my-skill", "Do the thing.", "Do the new thing.")
|
||||
assert result["success"] is False
|
||||
assert "pinned" in result["error"].lower()
|
||||
assert "hermes curator unpin my-skill" in result["error"]
|
||||
assert result["success"] is True, result
|
||||
content = (tmp_path / "my-skill" / "SKILL.md").read_text()
|
||||
assert "Do the thing." in content # unchanged
|
||||
assert "Do the new thing." in content
|
||||
|
||||
def test_patch_supporting_file_refuses_pinned(self, tmp_path):
|
||||
"""Pin covers supporting files too, not just SKILL.md."""
|
||||
def test_patch_supporting_file_allowed_when_pinned(self, tmp_path):
|
||||
"""Supporting-file patches also go through on pinned skills."""
|
||||
with _skill_dir(tmp_path):
|
||||
_create_skill("my-skill", VALID_SKILL_CONTENT)
|
||||
_write_file("my-skill", "references/api.md", "original")
|
||||
|
|
@ -885,57 +883,56 @@ class TestPinnedGuard:
|
|||
"my-skill", "original", "modified",
|
||||
file_path="references/api.md",
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "pinned" in result["error"].lower()
|
||||
assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "original"
|
||||
assert result["success"] is True, result
|
||||
assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "modified"
|
||||
|
||||
def test_delete_refuses_pinned(self, tmp_path):
|
||||
"""Delete is the one action pin still blocks — it's the irrecoverable one."""
|
||||
with _skill_dir(tmp_path):
|
||||
_create_skill("my-skill", VALID_SKILL_CONTENT)
|
||||
with self._pin("my-skill"):
|
||||
result = _delete_skill("my-skill")
|
||||
assert result["success"] is False
|
||||
assert "pinned" in result["error"].lower()
|
||||
assert "cannot be deleted" in result["error"]
|
||||
assert "hermes curator unpin my-skill" in result["error"]
|
||||
# Skill still exists
|
||||
assert (tmp_path / "my-skill" / "SKILL.md").exists()
|
||||
|
||||
def test_write_file_refuses_pinned(self, tmp_path):
|
||||
def test_write_file_allowed_when_pinned(self, tmp_path):
|
||||
with _skill_dir(tmp_path):
|
||||
_create_skill("my-skill", VALID_SKILL_CONTENT)
|
||||
with self._pin("my-skill"):
|
||||
result = _write_file("my-skill", "references/api.md", "content")
|
||||
assert result["success"] is False
|
||||
assert "pinned" in result["error"].lower()
|
||||
assert not (tmp_path / "my-skill" / "references" / "api.md").exists()
|
||||
assert result["success"] is True, result
|
||||
assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "content"
|
||||
|
||||
def test_remove_file_refuses_pinned(self, tmp_path):
|
||||
def test_remove_file_allowed_when_pinned(self, tmp_path):
|
||||
with _skill_dir(tmp_path):
|
||||
_create_skill("my-skill", VALID_SKILL_CONTENT)
|
||||
_write_file("my-skill", "references/api.md", "content")
|
||||
with self._pin("my-skill"):
|
||||
result = _remove_file("my-skill", "references/api.md")
|
||||
assert result["success"] is False
|
||||
assert "pinned" in result["error"].lower()
|
||||
# File still there
|
||||
assert (tmp_path / "my-skill" / "references" / "api.md").exists()
|
||||
assert result["success"] is True, result
|
||||
assert not (tmp_path / "my-skill" / "references" / "api.md").exists()
|
||||
|
||||
def test_unpinned_skills_still_editable(self, tmp_path):
|
||||
"""Sanity check: the guard doesn't fire for unpinned skills.
|
||||
"""Sanity check: the guard doesn't fire for unpinned skills on delete.
|
||||
|
||||
Only the specifically-pinned skill is refused; a sibling skill must
|
||||
still be freely editable.
|
||||
Only the specifically-pinned skill is refused from delete; a sibling
|
||||
skill must still be freely deletable.
|
||||
"""
|
||||
with _skill_dir(tmp_path):
|
||||
_create_skill("pinned-one", VALID_SKILL_CONTENT)
|
||||
_create_skill("free-one", VALID_SKILL_CONTENT)
|
||||
with self._pin("pinned-one"):
|
||||
blocked = _edit_skill("pinned-one", VALID_SKILL_CONTENT_2)
|
||||
allowed = _edit_skill("free-one", VALID_SKILL_CONTENT_2)
|
||||
blocked = _delete_skill("pinned-one")
|
||||
allowed = _delete_skill("free-one")
|
||||
assert blocked["success"] is False
|
||||
assert allowed["success"] is True
|
||||
|
||||
def test_broken_sidecar_fails_open(self, tmp_path):
|
||||
"""If skill_usage.get_record raises, we allow the write through.
|
||||
"""If skill_usage.get_record raises, we allow delete through.
|
||||
|
||||
Rationale: a corrupted telemetry file shouldn't lock the agent out
|
||||
of skills it would otherwise be allowed to touch.
|
||||
|
|
@ -944,5 +941,5 @@ class TestPinnedGuard:
|
|||
_create_skill("my-skill", VALID_SKILL_CONTENT)
|
||||
with patch("tools.skill_usage.get_record",
|
||||
side_effect=RuntimeError("sidecar broken")):
|
||||
result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2)
|
||||
result = _delete_skill("my-skill")
|
||||
assert result["success"] is True
|
||||
|
|
|
|||
|
|
@ -225,6 +225,52 @@ def test_agent_created_excludes_hub_installed(skills_home):
|
|||
assert "hub-skill" not in names
|
||||
|
||||
|
||||
def test_agent_created_excludes_hub_installed_frontmatter_name(skills_home):
|
||||
from tools.skill_usage import (
|
||||
is_agent_created,
|
||||
list_agent_created_skill_names,
|
||||
mark_agent_created,
|
||||
)
|
||||
|
||||
skills_dir = skills_home / "skills"
|
||||
hub_skill = skills_dir / "productivity" / "getnote"
|
||||
hub_skill.mkdir(parents=True)
|
||||
(hub_skill / "SKILL.md").write_text(
|
||||
"""---
|
||||
name: Get笔记
|
||||
description: test skill
|
||||
---
|
||||
|
||||
# body
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_skill(skills_dir, "my-skill")
|
||||
mark_agent_created("my-skill")
|
||||
hub_dir = skills_dir / ".hub"
|
||||
hub_dir.mkdir()
|
||||
(hub_dir / "lock.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"installed": {
|
||||
"getnote": {
|
||||
"source": "taps/main",
|
||||
"install_path": "productivity/getnote",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
names = list_agent_created_skill_names()
|
||||
assert "my-skill" in names
|
||||
assert "Get笔记" not in names
|
||||
assert is_agent_created("Get笔记") is False
|
||||
assert is_agent_created("getnote") is False
|
||||
|
||||
|
||||
def test_is_agent_created(skills_home):
|
||||
from tools.skill_usage import is_agent_created
|
||||
skills_dir = skills_home / "skills"
|
||||
|
|
|
|||
|
|
@ -628,15 +628,18 @@ def prompt_dangerous_approval(command: str, description: str,
|
|||
|
||||
os.environ["HERMES_SPINNER_PAUSE"] = "1"
|
||||
try:
|
||||
# Resolve the active UI language once per prompt so we don't re-read
|
||||
# config/YAML inside the retry loop below.
|
||||
from agent.i18n import t
|
||||
while True:
|
||||
print()
|
||||
print(f" ⚠️ DANGEROUS COMMAND: {description}")
|
||||
print(f" {t('approval.dangerous_header', description=description)}")
|
||||
print(f" {command}")
|
||||
print()
|
||||
if allow_permanent:
|
||||
print(" [o]nce | [s]ession | [a]lways | [d]eny")
|
||||
print(t("approval.choose_long"))
|
||||
else:
|
||||
print(" [o]nce | [s]ession | [d]eny")
|
||||
print(t("approval.choose_short"))
|
||||
print()
|
||||
sys.stdout.flush()
|
||||
|
||||
|
|
@ -644,7 +647,7 @@ def prompt_dangerous_approval(command: str, description: str,
|
|||
|
||||
def get_input():
|
||||
try:
|
||||
prompt = " Choice [o/s/a/D]: " if allow_permanent else " Choice [o/s/D]: "
|
||||
prompt = t("approval.prompt_long") if allow_permanent else t("approval.prompt_short")
|
||||
result["choice"] = input(prompt).strip().lower()
|
||||
except (EOFError, OSError):
|
||||
result["choice"] = ""
|
||||
|
|
@ -654,28 +657,28 @@ def prompt_dangerous_approval(command: str, description: str,
|
|||
thread.join(timeout=timeout_seconds)
|
||||
|
||||
if thread.is_alive():
|
||||
print("\n ⏱ Timeout - denying command")
|
||||
print("\n" + t("approval.timeout"))
|
||||
return "deny"
|
||||
|
||||
choice = result["choice"]
|
||||
if choice in ('o', 'once'):
|
||||
print(" ✓ Allowed once")
|
||||
print(t("approval.allowed_once"))
|
||||
return "once"
|
||||
elif choice in ('s', 'session'):
|
||||
print(" ✓ Allowed for this session")
|
||||
print(t("approval.allowed_session"))
|
||||
return "session"
|
||||
elif choice in ('a', 'always'):
|
||||
if not allow_permanent:
|
||||
print(" ✓ Allowed for this session")
|
||||
print(t("approval.allowed_session"))
|
||||
return "session"
|
||||
print(" ✓ Added to permanent allowlist")
|
||||
print(t("approval.allowed_always"))
|
||||
return "always"
|
||||
else:
|
||||
print(" ✗ Denied")
|
||||
print(t("approval.denied"))
|
||||
return "deny"
|
||||
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n ✗ Cancelled")
|
||||
print("\n" + t("approval.cancelled"))
|
||||
return "deny"
|
||||
finally:
|
||||
if "HERMES_SPINNER_PAUSE" in os.environ:
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ def _ensure_ssh_available() -> None:
|
|||
raise RuntimeError(
|
||||
"SSH is not installed or not in PATH. Install OpenSSH client: apt install openssh-client"
|
||||
)
|
||||
if not shutil.which("scp"):
|
||||
raise RuntimeError(
|
||||
"SCP is not installed or not in PATH. Install OpenSSH client: apt install openssh-client"
|
||||
)
|
||||
|
||||
|
||||
class SSHEnvironment(BaseEnvironment):
|
||||
|
|
|
|||
|
|
@ -119,9 +119,10 @@ class WriteResult:
|
|||
"""Result from writing a file."""
|
||||
bytes_written: int = 0
|
||||
dirs_created: bool = False
|
||||
lint: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
warning: Optional[str] = None
|
||||
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {k: v for k, v in self.__dict__.items() if v is not None}
|
||||
|
||||
|
|
@ -202,10 +203,10 @@ class LintResult:
|
|||
def to_dict(self) -> dict:
|
||||
if self.skipped:
|
||||
return {"status": "skipped", "message": self.message}
|
||||
return {
|
||||
"status": "ok" if self.success else "error",
|
||||
"output": self.output
|
||||
}
|
||||
result = {"status": "ok" if self.success else "error", "output": self.output}
|
||||
if self.message:
|
||||
result["message"] = self.message
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -303,7 +304,9 @@ class FileOperations(ABC):
|
|||
# Image extensions (subset of binary that we can return as base64)
|
||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico'}
|
||||
|
||||
# Linters by file extension
|
||||
# Shell-based linters by file extension. Invoked via _exec() with the
|
||||
# filesystem path. Cover languages where a compile/type check needs an
|
||||
# external toolchain (py_compile, node, tsc, go vet, rustfmt).
|
||||
LINTERS = {
|
||||
'.py': 'python -m py_compile {file} 2>&1',
|
||||
'.js': 'node --check {file} 2>&1',
|
||||
|
|
@ -312,6 +315,86 @@ LINTERS = {
|
|||
'.rs': 'rustfmt --check {file} 2>&1',
|
||||
}
|
||||
|
||||
|
||||
def _lint_json_inproc(content: str) -> tuple[bool, str]:
|
||||
"""In-process JSON syntax check. Returns (ok, error_message)."""
|
||||
import json as _json
|
||||
try:
|
||||
_json.loads(content)
|
||||
return True, ""
|
||||
except _json.JSONDecodeError as e:
|
||||
return False, f"JSONDecodeError: {e.msg} (line {e.lineno}, column {e.colno})"
|
||||
except Exception as e: # noqa: BLE001 — any parse failure is a lint failure
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
def _lint_yaml_inproc(content: str) -> tuple[bool, str]:
|
||||
"""In-process YAML syntax check. Returns (ok, error_message).
|
||||
|
||||
Skipped gracefully if PyYAML isn't installed — YAML parsing is optional.
|
||||
"""
|
||||
try:
|
||||
import yaml as _yaml
|
||||
except ImportError:
|
||||
# PyYAML not available — skip silently, caller treats as no linter.
|
||||
return True, "__SKIP__"
|
||||
try:
|
||||
_yaml.safe_load(content)
|
||||
return True, ""
|
||||
except _yaml.YAMLError as e:
|
||||
return False, f"YAMLError: {e}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
def _lint_toml_inproc(content: str) -> tuple[bool, str]:
|
||||
"""In-process TOML syntax check (stdlib tomllib, Python 3.11+)."""
|
||||
try:
|
||||
import tomllib as _toml
|
||||
except ImportError:
|
||||
# Pre-3.11 fallback via tomli, if installed.
|
||||
try:
|
||||
import tomli as _toml # type: ignore[no-redef]
|
||||
except ImportError:
|
||||
return True, "__SKIP__"
|
||||
try:
|
||||
_toml.loads(content)
|
||||
return True, ""
|
||||
except Exception as e: # tomllib raises TOMLDecodeError, a ValueError subclass
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
def _lint_python_inproc(content: str) -> tuple[bool, str]:
|
||||
"""In-process Python syntax check via ast.parse.
|
||||
|
||||
Catches SyntaxError, IndentationError, and everything else the
|
||||
ast module rejects — matching py_compile's scope but with no
|
||||
subprocess overhead and no dependency on a ``python`` in PATH.
|
||||
"""
|
||||
import ast as _ast
|
||||
try:
|
||||
_ast.parse(content)
|
||||
return True, ""
|
||||
except SyntaxError as e:
|
||||
loc = f" (line {e.lineno}, column {e.offset})" if e.lineno else ""
|
||||
return False, f"{type(e).__name__}: {e.msg}{loc}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
# In-process linters by file extension. Preferred over shell linters when
|
||||
# present — no subprocess overhead, microseconds per call. Each callable
|
||||
# takes file content (str) and returns (ok: bool, error: str). An error
|
||||
# string of ``"__SKIP__"`` signals the linter isn't available (missing
|
||||
# dependency) and should be treated as "no linter".
|
||||
LINTERS_INPROC = {
|
||||
'.py': _lint_python_inproc,
|
||||
'.json': _lint_json_inproc,
|
||||
'.yaml': _lint_yaml_inproc,
|
||||
'.yml': _lint_yaml_inproc,
|
||||
'.toml': _lint_toml_inproc,
|
||||
}
|
||||
|
||||
# Max limits for read operations
|
||||
MAX_LINES = 2000
|
||||
MAX_LINE_LENGTH = 2000
|
||||
|
|
@ -745,12 +828,19 @@ class ShellFileOperations(FileOperations):
|
|||
files. The content never appears in the shell command string —
|
||||
only the file path does.
|
||||
|
||||
After the write, runs a post-first / pre-lazy lint check via
|
||||
``_check_lint_delta()``. If the new content is clean, the lint
|
||||
call is O(one parse). If the new content has errors, the pre-write
|
||||
content is linted too and only errors newly introduced by this
|
||||
write are surfaced — pre-existing problems are filtered out so
|
||||
the agent isn't distracted chasing them.
|
||||
|
||||
Args:
|
||||
path: File path to write
|
||||
content: Content to write
|
||||
|
||||
Returns:
|
||||
WriteResult with bytes written or error
|
||||
WriteResult with bytes written, lint summary, or error.
|
||||
"""
|
||||
# Expand ~ and other shell paths
|
||||
path = self._expand_path(path)
|
||||
|
|
@ -759,36 +849,58 @@ class ShellFileOperations(FileOperations):
|
|||
if _is_write_denied(path):
|
||||
return WriteResult(error=f"Write denied: '{path}' is a protected system/credential file.")
|
||||
|
||||
# Capture pre-write content for lint-delta computation. Only do this
|
||||
# when an in-process OR shell linter exists for this extension — no
|
||||
# point paying for the read otherwise. For in-process linters we
|
||||
# pass the content directly; for shell linters the pre-state isn't
|
||||
# useful (we'd have to re-write-read to lint the old version, which
|
||||
# defeats the purpose), so we skip the capture and accept the naive
|
||||
# "all errors" report.
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
pre_content: Optional[str] = None
|
||||
if ext in LINTERS_INPROC:
|
||||
# Best-effort read; failure (file missing, permission) leaves
|
||||
# pre_content as None which makes the delta step degrade
|
||||
# gracefully to "report all errors".
|
||||
read_cmd = f"cat {self._escape_shell_arg(path)} 2>/dev/null"
|
||||
read_result = self._exec(read_cmd)
|
||||
if read_result.exit_code == 0 and read_result.stdout:
|
||||
pre_content = read_result.stdout
|
||||
|
||||
# Create parent directories
|
||||
parent = os.path.dirname(path)
|
||||
dirs_created = False
|
||||
|
||||
|
||||
if parent:
|
||||
mkdir_cmd = f"mkdir -p {self._escape_shell_arg(parent)}"
|
||||
mkdir_result = self._exec(mkdir_cmd)
|
||||
if mkdir_result.exit_code == 0:
|
||||
dirs_created = True
|
||||
|
||||
|
||||
# Write via stdin pipe — content bypasses shell arg parsing entirely,
|
||||
# so there's no ARG_MAX limit regardless of file size.
|
||||
write_cmd = f"cat > {self._escape_shell_arg(path)}"
|
||||
write_result = self._exec(write_cmd, stdin_data=content)
|
||||
|
||||
|
||||
if write_result.exit_code != 0:
|
||||
return WriteResult(error=f"Failed to write file: {write_result.stdout}")
|
||||
|
||||
|
||||
# Get bytes written (wc -c is POSIX, works on Linux + macOS)
|
||||
stat_cmd = f"wc -c < {self._escape_shell_arg(path)} 2>/dev/null"
|
||||
stat_result = self._exec(stat_cmd)
|
||||
|
||||
|
||||
try:
|
||||
bytes_written = int(stat_result.stdout.strip())
|
||||
except ValueError:
|
||||
bytes_written = len(content.encode('utf-8'))
|
||||
|
||||
|
||||
# Post-write lint with delta refinement.
|
||||
lint_result = self._check_lint_delta(path, pre_content=pre_content, post_content=content)
|
||||
|
||||
return WriteResult(
|
||||
bytes_written=bytes_written,
|
||||
dirs_created=dirs_created
|
||||
dirs_created=dirs_created,
|
||||
lint=lint_result.to_dict() if lint_result else None,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
|
|
@ -864,10 +976,12 @@ class ShellFileOperations(FileOperations):
|
|||
|
||||
# Generate diff
|
||||
diff = self._unified_diff(content, new_content, path)
|
||||
|
||||
# Auto-lint
|
||||
lint_result = self._check_lint(path)
|
||||
|
||||
|
||||
# Auto-lint with delta refinement: only surface errors introduced
|
||||
# by this patch, filtering out pre-existing lint failures so the
|
||||
# agent isn't distracted by problems that were already there.
|
||||
lint_result = self._check_lint_delta(path, pre_content=content, post_content=new_content)
|
||||
|
||||
return PatchResult(
|
||||
success=True,
|
||||
diff=diff,
|
||||
|
|
@ -905,37 +1019,143 @@ class ShellFileOperations(FileOperations):
|
|||
result = apply_v4a_operations(operations, self)
|
||||
return result
|
||||
|
||||
def _check_lint(self, path: str) -> LintResult:
|
||||
def _check_lint(self, path: str, content: Optional[str] = None) -> LintResult:
|
||||
"""
|
||||
Run syntax check on a file after editing.
|
||||
|
||||
|
||||
Prefers the in-process linter for structured formats (JSON, YAML,
|
||||
TOML) when possible — those parse via the Python stdlib in
|
||||
microseconds and don't require a subprocess. Falls back to the
|
||||
shell linter table for compiled/type-checked languages
|
||||
(py_compile, node --check, tsc, go vet, rustfmt).
|
||||
|
||||
Args:
|
||||
path: File path to lint
|
||||
|
||||
path: File path (used to select the linter + for shell invocation).
|
||||
content: Optional file content. If provided AND an in-process
|
||||
linter matches the extension, we lint the content
|
||||
directly without re-reading the file from disk. Ignored
|
||||
for shell linters.
|
||||
|
||||
Returns:
|
||||
LintResult with status and any errors
|
||||
LintResult with status and any errors.
|
||||
"""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
|
||||
|
||||
# Prefer in-process linter when available.
|
||||
inproc = LINTERS_INPROC.get(ext)
|
||||
if inproc is not None:
|
||||
# Need content — either passed in or read from disk.
|
||||
if content is None:
|
||||
read_cmd = f"cat {self._escape_shell_arg(path)} 2>/dev/null"
|
||||
read_result = self._exec(read_cmd)
|
||||
if read_result.exit_code != 0:
|
||||
return LintResult(skipped=True, message=f"Failed to read {path} for lint")
|
||||
content = read_result.stdout
|
||||
ok, err = inproc(content)
|
||||
if err == "__SKIP__":
|
||||
return LintResult(skipped=True, message=f"No linter available for {ext} (missing dependency)")
|
||||
return LintResult(success=ok, output="" if ok else err)
|
||||
|
||||
# Fall back to shell linter.
|
||||
if ext not in LINTERS:
|
||||
return LintResult(skipped=True, message=f"No linter for {ext} files")
|
||||
|
||||
# Check if linter command is available
|
||||
|
||||
linter_cmd = LINTERS[ext]
|
||||
# Extract the base command (first word)
|
||||
base_cmd = linter_cmd.split()[0]
|
||||
|
||||
|
||||
if not self._has_command(base_cmd):
|
||||
return LintResult(skipped=True, message=f"{base_cmd} not available")
|
||||
|
||||
|
||||
# Run linter
|
||||
cmd = linter_cmd.replace("{file}", self._escape_shell_arg(path))
|
||||
result = self._exec(cmd, timeout=30)
|
||||
|
||||
|
||||
return LintResult(
|
||||
success=result.exit_code == 0,
|
||||
output=result.stdout.strip() if result.stdout.strip() else ""
|
||||
)
|
||||
|
||||
def _check_lint_delta(self, path: str, pre_content: Optional[str],
|
||||
post_content: Optional[str] = None) -> LintResult:
|
||||
"""
|
||||
Run post-write lint with pre-write baseline comparison.
|
||||
|
||||
Strategy (post-first, pre-lazy):
|
||||
1. Lint the post-write state. If clean → return clean immediately.
|
||||
This is the hot path and matches _check_lint() in cost.
|
||||
2. If post-lint found errors AND we have pre-write content, lint
|
||||
that too. If the pre-write file was already broken, return only
|
||||
the *new* errors introduced by this edit — errors that existed
|
||||
before aren't the agent's problem to chase right now.
|
||||
3. If pre_content is None (new file or unavailable), skip the delta
|
||||
step and return all post-write errors.
|
||||
|
||||
This mirrors Cline's and OpenCode's post-edit LSP pattern: surface
|
||||
only the errors this specific edit introduced, so the agent doesn't
|
||||
get distracted by pre-existing problems.
|
||||
|
||||
Args:
|
||||
path: File path (for linter selection).
|
||||
pre_content: File content BEFORE the write. Pass None for new
|
||||
files or when the pre-state isn't available — the
|
||||
delta refinement is skipped and all post errors
|
||||
are returned.
|
||||
post_content: File content AFTER the write. Optional; if None,
|
||||
the shell linter reads from disk (same as
|
||||
_check_lint).
|
||||
|
||||
Returns:
|
||||
LintResult. ``output`` contains either the full post-lint
|
||||
errors (no pre-state) or just the new-error lines (delta
|
||||
refinement applied).
|
||||
"""
|
||||
post = self._check_lint(path, content=post_content)
|
||||
|
||||
# Hot path: clean post-write, no pre-lint needed.
|
||||
if post.success or post.skipped:
|
||||
return post
|
||||
|
||||
# Post-write has errors. If we have pre-content, run the delta
|
||||
# refinement to filter out pre-existing errors.
|
||||
if pre_content is None:
|
||||
return post
|
||||
|
||||
pre = self._check_lint(path, content=pre_content)
|
||||
if pre.success or pre.skipped or not pre.output:
|
||||
# Pre-write was clean (or we couldn't lint it) — post errors
|
||||
# are all new. Return the full post output.
|
||||
return post
|
||||
|
||||
# Both pre- and post-write had errors. Compute the set-difference
|
||||
# on non-empty stripped lines. Caveat: single-error parsers
|
||||
# (ast.parse, json.loads) stop at the first error and don't report
|
||||
# later ones — if the pre-existing error blocks parsing before
|
||||
# reaching the edit region, we can't prove the edit is clean. So
|
||||
# if every post error also appeared pre-edit, we report the file
|
||||
# as still broken but annotate that this edit introduced nothing
|
||||
# new on top — the agent knows it's inherited state, not fresh
|
||||
# damage, without silently dropping the error.
|
||||
pre_lines = {ln.strip() for ln in pre.output.splitlines() if ln.strip()}
|
||||
post_lines = [ln for ln in post.output.splitlines() if ln.strip() and ln.strip() not in pre_lines]
|
||||
|
||||
if not post_lines:
|
||||
# Every error in post was also in pre — this edit didn't make
|
||||
# anything obviously worse, but the file remains broken and
|
||||
# the agent should know.
|
||||
return LintResult(
|
||||
success=False,
|
||||
output=post.output,
|
||||
message="Pre-existing lint errors — this edit didn't introduce new ones but the file is still broken.",
|
||||
)
|
||||
|
||||
return LintResult(
|
||||
success=False,
|
||||
output=(
|
||||
"New lint errors introduced by this edit "
|
||||
"(pre-existing errors filtered out):\n" + "\n".join(post_lines)
|
||||
)
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# SEARCH Implementation
|
||||
|
|
|
|||
|
|
@ -1042,7 +1042,7 @@ READ_FILE_SCHEMA = {
|
|||
|
||||
WRITE_FILE_SCHEMA = {
|
||||
"name": "write_file",
|
||||
"description": "Write content to a file, completely replacing existing content. Use this instead of echo/cat heredoc in terminal. Creates parent directories automatically. OVERWRITES the entire file — use 'patch' for targeted edits.",
|
||||
"description": "Write content to a file, completely replacing existing content. Use this instead of echo/cat heredoc in terminal. Creates parent directories automatically. OVERWRITES the entire file — use 'patch' for targeted edits. Auto-runs syntax checks on .py/.json/.yaml/.toml and other linted languages; only NEW errors introduced by this write are surfaced (pre-existing errors are filtered out).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -210,6 +210,20 @@ def _handle_complete(args: dict, **kw) -> str:
|
|||
summary = args.get("summary")
|
||||
metadata = args.get("metadata")
|
||||
result = args.get("result")
|
||||
created_cards = args.get("created_cards")
|
||||
if created_cards is not None:
|
||||
if isinstance(created_cards, str):
|
||||
# Accept a single id as a string for convenience.
|
||||
created_cards = [created_cards]
|
||||
if not isinstance(created_cards, (list, tuple)):
|
||||
return tool_error(
|
||||
f"created_cards must be a list of task ids, got "
|
||||
f"{type(created_cards).__name__}"
|
||||
)
|
||||
# Normalise: strings only, stripped, non-empty.
|
||||
created_cards = [
|
||||
str(c).strip() for c in created_cards if str(c).strip()
|
||||
]
|
||||
if not (summary or result):
|
||||
return tool_error(
|
||||
"provide at least one of: summary (preferred), result"
|
||||
|
|
@ -221,10 +235,23 @@ def _handle_complete(args: dict, **kw) -> str:
|
|||
try:
|
||||
kb, conn = _connect()
|
||||
try:
|
||||
ok = kb.complete_task(
|
||||
conn, tid,
|
||||
result=result, summary=summary, metadata=metadata,
|
||||
)
|
||||
try:
|
||||
ok = kb.complete_task(
|
||||
conn, tid,
|
||||
result=result, summary=summary, metadata=metadata,
|
||||
created_cards=created_cards,
|
||||
)
|
||||
except kb.HallucinatedCardsError as hall_err:
|
||||
# Structured rejection — surface the phantom ids so the
|
||||
# worker can retry with a corrected list or drop the
|
||||
# field. Audit event already landed in the DB.
|
||||
return tool_error(
|
||||
f"kanban_complete blocked: the following created_cards "
|
||||
f"do not exist or were not created by this worker: "
|
||||
f"{', '.join(hall_err.phantom)}. "
|
||||
f"Either omit them, use only ids returned from successful "
|
||||
f"kanban_create calls, or remove the created_cards field."
|
||||
)
|
||||
if not ok:
|
||||
return tool_error(
|
||||
f"could not complete {tid} (unknown id or already terminal)"
|
||||
|
|
@ -452,7 +479,11 @@ KANBAN_COMPLETE_SCHEMA = {
|
|||
"human-readable 1-3 sentence description of what you did; put "
|
||||
"machine-readable facts in ``metadata`` (changed_files, "
|
||||
"tests_run, decisions, findings, etc). At least one of "
|
||||
"``summary`` or ``result`` is required."
|
||||
"``summary`` or ``result`` is required. If you created new "
|
||||
"tasks via ``kanban_create`` during this run, list their ids "
|
||||
"in ``created_cards`` — the kernel verifies them so phantom "
|
||||
"references are caught before they leak into downstream "
|
||||
"automation."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
|
|
@ -487,6 +518,22 @@ KANBAN_COMPLETE_SCHEMA = {
|
|||
"callers that still set --result on the CLI."
|
||||
),
|
||||
},
|
||||
"created_cards": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Optional structured manifest of task ids you "
|
||||
"created via ``kanban_create`` during this run. "
|
||||
"The kernel verifies each id exists and was "
|
||||
"created by this worker's profile; any phantom "
|
||||
"id blocks the completion with an error listing "
|
||||
"what went wrong (auditable in the task's events). "
|
||||
"Only list ids you got back from a successful "
|
||||
"``kanban_create`` call — do not invent or "
|
||||
"remember ids from prose. Omit the field if you "
|
||||
"did not create any cards."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1038,14 +1038,43 @@ class MCPServerTask:
|
|||
with a fresh signal.
|
||||
|
||||
Shutdown takes precedence if both events are set simultaneously.
|
||||
|
||||
Periodically sends a lightweight keepalive (``list_tools``) to
|
||||
prevent TCP connections from going stale during long idle
|
||||
periods (#17003). If the keepalive fails, triggers a reconnect.
|
||||
"""
|
||||
# Keepalive interval in seconds. Must be shorter than typical
|
||||
# LB / NAT idle-timeout (commonly 300-600s).
|
||||
_KEEPALIVE_INTERVAL = 180 # 3 minutes
|
||||
|
||||
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
|
||||
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
|
||||
try:
|
||||
await asyncio.wait(
|
||||
{shutdown_task, reconnect_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
while True:
|
||||
done, _pending = await asyncio.wait(
|
||||
{shutdown_task, reconnect_task},
|
||||
timeout=_KEEPALIVE_INTERVAL,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if done:
|
||||
break
|
||||
|
||||
# Timeout — no lifecycle event fired. Send a keepalive
|
||||
# to exercise the connection and detect stale sockets.
|
||||
if self.session:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.session.list_tools(),
|
||||
timeout=30.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"MCP server '%s' keepalive failed, "
|
||||
"triggering reconnect: %s",
|
||||
self.name, exc,
|
||||
)
|
||||
self._reconnect_event.set()
|
||||
break
|
||||
finally:
|
||||
for t in (shutdown_task, reconnect_task):
|
||||
if not t.done():
|
||||
|
|
|
|||
|
|
@ -255,3 +255,75 @@ def _sanitize_node(node: Any, path: str) -> Any:
|
|||
out["required"] = valid
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Reactive strip — only invoked when llama.cpp rejects a schema
|
||||
# =============================================================================
|
||||
|
||||
_STRIP_ON_RECOVERY_KEYS = frozenset({"pattern", "format"})
|
||||
|
||||
|
||||
def strip_pattern_and_format(tools: list[dict]) -> tuple[list[dict], int]:
|
||||
"""Strip ``pattern`` and ``format`` JSON Schema keywords from tool schemas.
|
||||
|
||||
This is a *reactive* sanitizer invoked only when llama.cpp's
|
||||
``json-schema-to-grammar`` converter has rejected a tool schema with an
|
||||
HTTP 400 grammar-parse error. llama.cpp's regex engine supports only a
|
||||
small subset of ECMAScript regex (literals, ``.``, ``[...]``, ``|``,
|
||||
``*``, ``+``, ``?``, ``{n,m}``) — it rejects escape classes like ``\\d``,
|
||||
``\\w``, ``\\s`` and most ``format`` values. Cloud providers (OpenAI,
|
||||
Anthropic, OpenRouter, Gemini) accept these keywords fine and rely on
|
||||
them as prompting hints, so we keep them in the default schema and only
|
||||
strip on demand.
|
||||
|
||||
The strip operates on a sibling of ``type`` (so schema keywords are
|
||||
removed) — a property literally *named* ``pattern`` (e.g. the first arg
|
||||
of the built-in ``search_files`` tool) is not affected because property
|
||||
names live in the ``properties`` dict, not as siblings of ``type``.
|
||||
|
||||
Args:
|
||||
tools: OpenAI-format tool list, mutated in place for efficiency.
|
||||
Callers that need to preserve the original should deep-copy first.
|
||||
|
||||
Returns:
|
||||
``(tools, stripped_count)`` — the same list reference plus a count of
|
||||
how many ``pattern``/``format`` keywords were removed across all tools.
|
||||
"""
|
||||
if not tools:
|
||||
return tools, 0
|
||||
|
||||
stripped = 0
|
||||
|
||||
def _walk(node: Any) -> None:
|
||||
nonlocal stripped
|
||||
if isinstance(node, dict):
|
||||
# Only strip as a sibling of ``type`` — i.e. when this node is
|
||||
# itself a schema. This avoids stripping literal property keys
|
||||
# named "pattern" (search_files.pattern, etc.) because those live
|
||||
# inside a ``properties`` dict, not as siblings of ``type``.
|
||||
is_schema_node = "type" in node or "anyOf" in node or "oneOf" in node or "allOf" in node
|
||||
for key in list(node.keys()):
|
||||
if is_schema_node and key in _STRIP_ON_RECOVERY_KEYS:
|
||||
node.pop(key, None)
|
||||
stripped += 1
|
||||
continue
|
||||
_walk(node[key])
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
_walk(item)
|
||||
|
||||
for tool in tools:
|
||||
fn = tool.get("function") if isinstance(tool, dict) else None
|
||||
if isinstance(fn, dict):
|
||||
params = fn.get("parameters")
|
||||
if isinstance(params, dict):
|
||||
_walk(params)
|
||||
|
||||
if stripped:
|
||||
logger.info(
|
||||
"schema_sanitizer: stripped %d pattern/format keyword(s) from "
|
||||
"tool schemas (llama.cpp grammar-parse recovery)",
|
||||
stripped,
|
||||
)
|
||||
return tools, stripped
|
||||
|
|
|
|||
|
|
@ -137,14 +137,12 @@ def _containing_skills_root(skill_path: Path) -> Path:
|
|||
def _pinned_guard(name: str) -> Optional[str]:
|
||||
"""Return a refusal message if *name* is pinned, else None.
|
||||
|
||||
Pinned skills are off-limits to the agent's skill_manage tool. The only
|
||||
way to modify one is for the user to unpin it via
|
||||
``hermes curator unpin <name>`` (or edit it directly by hand). This
|
||||
mirrors the curator's own pinned-skip behavior but extends the guard
|
||||
to tool-driven writes as well, giving users a hard fence against
|
||||
accidental agent edits.
|
||||
Pin protects a skill from **deletion** — both the curator's auto-archive
|
||||
passes and the agent's ``skill_manage(action="delete")`` tool call. The
|
||||
agent can still patch/edit pinned skills; pin only guards against
|
||||
irrecoverable loss, not against content evolution.
|
||||
|
||||
Best-effort: if the sidecar is unreadable we let the write through
|
||||
Best-effort: if the sidecar is unreadable we let the delete through
|
||||
rather than block on a broken telemetry file.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -152,9 +150,11 @@ def _pinned_guard(name: str) -> Optional[str]:
|
|||
rec = skill_usage.get_record(name)
|
||||
if rec.get("pinned"):
|
||||
return (
|
||||
f"Skill '{name}' is pinned and cannot be modified by "
|
||||
f"Skill '{name}' is pinned and cannot be deleted by "
|
||||
f"skill_manage. Ask the user to run "
|
||||
f"`hermes curator unpin {name}` if they want the change."
|
||||
f"`hermes curator unpin {name}` if they want to delete it. "
|
||||
f"Patches and edits are allowed on pinned skills; only "
|
||||
f"deletion is blocked."
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("pinned-guard lookup failed for %s", name, exc_info=True)
|
||||
|
|
@ -439,10 +439,6 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
|
|||
if not existing:
|
||||
return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."}
|
||||
|
||||
pinned_err = _pinned_guard(name)
|
||||
if pinned_err:
|
||||
return {"success": False, "error": pinned_err}
|
||||
|
||||
skill_md = existing["path"] / "SKILL.md"
|
||||
# Back up original content for rollback
|
||||
original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None
|
||||
|
|
@ -483,10 +479,6 @@ def _patch_skill(
|
|||
if not existing:
|
||||
return {"success": False, "error": f"Skill '{name}' not found."}
|
||||
|
||||
pinned_err = _pinned_guard(name)
|
||||
if pinned_err:
|
||||
return {"success": False, "error": pinned_err}
|
||||
|
||||
skill_dir = existing["path"]
|
||||
|
||||
if file_path:
|
||||
|
|
@ -645,10 +637,6 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:
|
|||
if not existing:
|
||||
return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."}
|
||||
|
||||
pinned_err = _pinned_guard(name)
|
||||
if pinned_err:
|
||||
return {"success": False, "error": pinned_err}
|
||||
|
||||
target, err = _resolve_skill_target(existing["path"], file_path)
|
||||
if err:
|
||||
return {"success": False, "error": err}
|
||||
|
|
@ -683,10 +671,6 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
|
|||
if not existing:
|
||||
return {"success": False, "error": f"Skill '{name}' not found."}
|
||||
|
||||
pinned_err = _pinned_guard(name)
|
||||
if pinned_err:
|
||||
return {"success": False, "error": pinned_err}
|
||||
|
||||
skill_dir = existing["path"]
|
||||
|
||||
target, err = _resolve_skill_target(skill_dir, file_path)
|
||||
|
|
@ -835,9 +819,10 @@ SKILL_MANAGE_SCHEMA = {
|
|||
"Skip for simple one-offs. Confirm with user before creating/deleting.\n\n"
|
||||
"Good skills: trigger conditions, numbered steps with exact commands, "
|
||||
"pitfalls section, verification steps. Use skill_view() to see format examples.\n\n"
|
||||
"Pinned skills are off-limits — all write actions refuse with a message "
|
||||
"pointing the user to `hermes curator unpin <name>`. Don't try to route "
|
||||
"around this by renaming or recreating."
|
||||
"Pinned skills are protected from deletion only — skill_manage(action='delete') "
|
||||
"will refuse with a message pointing the user to `hermes curator unpin <name>`. "
|
||||
"Patches and edits go through on pinned skills so you can still improve them as "
|
||||
"pitfalls come up; pin only guards against irrecoverable loss."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
|
|
|
|||
|
|
@ -143,7 +143,26 @@ def _read_hub_installed_names() -> Set[str]:
|
|||
if isinstance(data, dict):
|
||||
installed = data.get("installed") or {}
|
||||
if isinstance(installed, dict):
|
||||
return {str(k) for k in installed.keys()}
|
||||
names = {str(k) for k in installed.keys()}
|
||||
skills_dir = _skills_dir()
|
||||
for entry in installed.values():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
install_path = entry.get("install_path")
|
||||
if not isinstance(install_path, str) or not install_path.strip():
|
||||
continue
|
||||
skill_dir = Path(install_path)
|
||||
if not skill_dir.is_absolute():
|
||||
skill_dir = skills_dir / skill_dir
|
||||
try:
|
||||
resolved = skill_dir.resolve()
|
||||
resolved.relative_to(skills_dir.resolve())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
skill_md = resolved / "SKILL.md"
|
||||
if skill_md.exists():
|
||||
names.add(_read_skill_name(skill_md, fallback=resolved.name))
|
||||
return names
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.debug("Failed to read hub lock file: %s", e)
|
||||
return set()
|
||||
|
|
|
|||
|
|
@ -868,6 +868,7 @@ def skill_view(
|
|||
JSON string with skill content or error message
|
||||
"""
|
||||
try:
|
||||
local_category_name: str | None = None
|
||||
# ── Qualified name dispatch (plugin skills) ──────────────────
|
||||
# Names containing ':' are routed to the plugin skill registry.
|
||||
# Bare names fall through to the existing flat-tree scan below.
|
||||
|
|
@ -928,8 +929,12 @@ def skill_view(
|
|||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
# Plugin itself not found — fall through to flat-tree scan
|
||||
# which will return a normal "not found" with suggestions.
|
||||
# Plugin itself not found — fall through to flat-tree scan.
|
||||
# Categorized local skills also use `category:skill` in config and
|
||||
# gateway prompts, so preserve that form and translate it to the
|
||||
# on-disk `category/skill` path during the local scan below.
|
||||
if bare:
|
||||
local_category_name = f"{namespace}/{bare}"
|
||||
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
|
||||
|
|
@ -962,6 +967,15 @@ def skill_view(
|
|||
elif direct_path.with_suffix(".md").exists():
|
||||
skill_md = direct_path.with_suffix(".md")
|
||||
break
|
||||
if local_category_name:
|
||||
categorized_path = search_dir / local_category_name
|
||||
if categorized_path.is_dir() and (categorized_path / "SKILL.md").exists():
|
||||
skill_dir = categorized_path
|
||||
skill_md = categorized_path / "SKILL.md"
|
||||
break
|
||||
elif categorized_path.with_suffix(".md").exists():
|
||||
skill_md = categorized_path.with_suffix(".md")
|
||||
break
|
||||
|
||||
# Search by directory name across all dirs
|
||||
if not skill_md:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue