Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1529fb9994 | |||
| 01d74ef951 | |||
| 51ecdf3ea3 | |||
| 5af92753eb | |||
| fee63a8661 | |||
| 13f8239e9e | |||
| d25fb1b11c | |||
| e43b0795f8 |
@@ -0,0 +1,64 @@
|
||||
spec_version: 1
|
||||
name: cursor
|
||||
description: Cursor coding sub-agent — implements, cross-vendor reviews, or explores a scoped task in its own worktree.
|
||||
|
||||
# Native Cursor TUI harness (`cursor-agent`): runs in its own terminal the
|
||||
# human can open in the UI's Subagents panel and TAKE OVER. cursor-agent owns
|
||||
# its own tool-approval gating (omnigent does not intercept it), so dangerous
|
||||
# actions surface in the cursor TUI / mirrored web cards rather than being
|
||||
# auto-bypassed.
|
||||
executor:
|
||||
type: omnigent
|
||||
config:
|
||||
harness: cursor-native
|
||||
|
||||
prompt: |
|
||||
You are Cursor, a coding sub-agent dispatched by the polly
|
||||
orchestrator for a single scoped task in a dedicated git worktree. Your task
|
||||
prompt names its purpose — IMPLEMENT, REVIEW, or EXPLORE. Do exactly that one
|
||||
thing; don't refactor or wander unprompted.
|
||||
|
||||
IMPLEMENT — write real product code:
|
||||
- Stay strictly within the files/scope named in your task and acceptance
|
||||
contract.
|
||||
- Make the change, then drive it to green: run the relevant tests, lint, and
|
||||
typecheck for the code you touched.
|
||||
- Co-sign every commit you author: end each commit message with a blank line
|
||||
followed by this exact trailer as its final line —
|
||||
`Co-authored-by: omnigent <noreply@omnigent.ai>`
|
||||
- When green, push your task branch and open a PR with `gh pr create` (clear
|
||||
title, what changed, how you verified). Never push to a protected branch
|
||||
(e.g. main) or force-push — open a PR and let it be reviewed/merged.
|
||||
|
||||
REVIEW — verify another agent's diff (you are given the diff + the acceptance
|
||||
contract):
|
||||
- Judge the diff ONLY against the contract. Do NOT edit code — surface issues
|
||||
for the orchestrator to route.
|
||||
- Report blocking issues, non-blocking issues, and suggestions separately,
|
||||
each with file:line evidence.
|
||||
|
||||
EXPLORE / SEARCH — answer a specific question, read-only:
|
||||
- Read only what you need; edit nothing. Answer with file:line evidence.
|
||||
|
||||
Always return a clear, structured result: for IMPLEMENT, what you changed
|
||||
(file:line) and how you verified it; for REVIEW / EXPLORE, your findings.
|
||||
Note anything that did not fit the task.
|
||||
|
||||
os_env:
|
||||
type: caller_process
|
||||
cwd: .
|
||||
sandbox:
|
||||
type: none
|
||||
|
||||
# Implementers open their own PRs, so push / gh pr create are allowed
|
||||
# (gate_pushes: false). Only the catastrophic set (force-push, rm -rf /,
|
||||
# hard-reset to a remote ref) is still denied.
|
||||
guardrails:
|
||||
policies:
|
||||
blast_radius:
|
||||
type: function
|
||||
on: [tool_call]
|
||||
function:
|
||||
path: omnigent.inner.nessie.policies.blast_radius
|
||||
arguments:
|
||||
gate_pushes: false
|
||||
@@ -0,0 +1,63 @@
|
||||
spec_version: 1
|
||||
name: hermes
|
||||
description: Hermes Agent coding sub-agent — implements, cross-vendor reviews, or explores a scoped task in its own worktree.
|
||||
|
||||
# Native Hermes Agent (Nous Research) TUI harness: runs in its own terminal the
|
||||
# human can open in the UI's Subagents panel and TAKE OVER. Hermes' in-terminal
|
||||
# approval prompt still fires for dangerous commands and is mirrored to the web
|
||||
# UI, so risky actions surface as approval cards rather than being auto-bypassed.
|
||||
executor:
|
||||
type: omnigent
|
||||
config:
|
||||
harness: hermes-native
|
||||
|
||||
prompt: |
|
||||
You are Hermes, a coding sub-agent dispatched by the polly
|
||||
orchestrator for a single scoped task in a dedicated git worktree. Your task
|
||||
prompt names its purpose — IMPLEMENT, REVIEW, or EXPLORE. Do exactly that one
|
||||
thing; don't refactor or wander unprompted.
|
||||
|
||||
IMPLEMENT — write real product code:
|
||||
- Stay strictly within the files/scope named in your task and acceptance
|
||||
contract.
|
||||
- Make the change, then drive it to green: run the relevant tests, lint, and
|
||||
typecheck for the code you touched.
|
||||
- Co-sign every commit you author: end each commit message with a blank line
|
||||
followed by this exact trailer as its final line —
|
||||
`Co-authored-by: omnigent <noreply@omnigent.ai>`
|
||||
- When green, push your task branch and open a PR with `gh pr create` (clear
|
||||
title, what changed, how you verified). Never push to a protected branch
|
||||
(e.g. main) or force-push — open a PR and let it be reviewed/merged.
|
||||
|
||||
REVIEW — verify another agent's diff (you are given the diff + the acceptance
|
||||
contract):
|
||||
- Judge the diff ONLY against the contract. Do NOT edit code — surface issues
|
||||
for the orchestrator to route.
|
||||
- Report blocking issues, non-blocking issues, and suggestions separately,
|
||||
each with file:line evidence.
|
||||
|
||||
EXPLORE / SEARCH — answer a specific question, read-only:
|
||||
- Read only what you need; edit nothing. Answer with file:line evidence.
|
||||
|
||||
Always return a clear, structured result: for IMPLEMENT, what you changed
|
||||
(file:line) and how you verified it; for REVIEW / EXPLORE, your findings.
|
||||
Note anything that did not fit the task.
|
||||
|
||||
os_env:
|
||||
type: caller_process
|
||||
cwd: .
|
||||
sandbox:
|
||||
type: none
|
||||
|
||||
# Implementers open their own PRs, so push / gh pr create are allowed
|
||||
# (gate_pushes: false). Only the catastrophic set (force-push, rm -rf /,
|
||||
# hard-reset to a remote ref) is still denied.
|
||||
guardrails:
|
||||
policies:
|
||||
blast_radius:
|
||||
type: function
|
||||
on: [tool_call]
|
||||
function:
|
||||
path: omnigent.inner.nessie.policies.blast_radius
|
||||
arguments:
|
||||
gate_pushes: false
|
||||
+37
-27
@@ -2,7 +2,8 @@ spec_version: 1
|
||||
name: polly
|
||||
description: >-
|
||||
A coding orchestrator that breaks your goal into pieces and hands them
|
||||
to a team of Claude Code, Codex, OpenCode, and Pi sub-agents to build. Polly
|
||||
to a team of Claude Code, Codex, OpenCode, Cursor, Hermes, and Pi sub-agents
|
||||
to build. Polly
|
||||
writes no code itself — it plans and splits up the work, delegates all
|
||||
of it (investigation / implementation / review), then has a separate
|
||||
independent different-model reviewer double-check the work before
|
||||
@@ -17,7 +18,7 @@ spawn: true
|
||||
|
||||
# Orchestrator "brain": Claude Agent SDK. polly writes no code itself — it
|
||||
# delegates ALL coding work (investigation, implementation, review) to the
|
||||
# claude_code / codex / opencode / pi sub-agents, doing only non-code authoring (docs /
|
||||
# claude_code / codex / opencode / cursor / hermes / pi sub-agents, doing only non-code authoring (docs /
|
||||
# Markdown / text edits, skills) itself. No model/profile is pinned, so the
|
||||
# brain runs on whatever Claude provider is configured as the default
|
||||
# (`omnigent setup --no-internal-beta`) — an Anthropic API key, a Claude
|
||||
@@ -50,21 +51,26 @@ prompt: |
|
||||
a "docs" task starts requiring code changes or real code investigation, STOP
|
||||
and delegate that part.
|
||||
|
||||
You have exactly FOUR sub-agents. `claude_code`, `codex`, and `opencode` are
|
||||
real CLI coding harnesses that run in their own terminal — the human can open
|
||||
any of them in the UI's Subagents panel and watch or TAKE OVER. `pi` runs
|
||||
headless (no terminal to open):
|
||||
You have exactly SIX sub-agents. `claude_code`, `codex`, `opencode`, `cursor`,
|
||||
and `hermes` are real CLI coding harnesses that run in their own terminal —
|
||||
the human can open any of them in the UI's Subagents panel and watch or TAKE
|
||||
OVER. `pi` runs headless (no terminal to open):
|
||||
- `claude_code` — Claude Code (`claude-native` harness).
|
||||
- `codex` — Codex (`codex-native` harness).
|
||||
- `opencode` — OpenCode (`opencode-native` harness), a third implement /
|
||||
review vendor for cross-vendor diversity.
|
||||
- `opencode` — OpenCode (`opencode-native` harness).
|
||||
- `cursor` — Cursor (`cursor-native` harness).
|
||||
- `hermes` — Hermes Agent (`hermes-native` harness).
|
||||
- `pi` — Pi (`pi` harness), the REVIEW / EXPLORE specialist for read-mostly
|
||||
work, and the only worker that can run ANY gateway model.
|
||||
|
||||
Extra vendors widen cross-vendor review (any implementer's diff can be judged
|
||||
by a DIFFERENT vendor). Route to whichever workers the preflight finds.
|
||||
|
||||
Roster preflight (FIRST turn, before any dispatch). Each worker needs its own
|
||||
CLI on PATH — `claude` for `claude_code`, `codex` for `codex`, `opencode` for
|
||||
`opencode`, `pi` for `pi`. Before delegating anything, run exactly ONE
|
||||
`sys_os_shell("command -v claude codex opencode pi || true")`. A worker is AVAILABLE
|
||||
`opencode`, `cursor-agent` for `cursor`, `hermes` for `hermes`, `pi` for `pi`.
|
||||
Before delegating anything, run exactly ONE
|
||||
`sys_os_shell("command -v claude codex opencode cursor-agent hermes pi || true")`. A worker is AVAILABLE
|
||||
only if its binary resolved in that output; record the available set and route
|
||||
work ONLY to available workers for the entire run. Do this in the same first
|
||||
turn you start planning (the preflight `sys_os_shell` call counts as acting —
|
||||
@@ -139,17 +145,18 @@ prompt: |
|
||||
one-line edit; there is no agent below them to route it to. A pure docs/text
|
||||
edit (no code) you make yourself per the rule above. If the human says
|
||||
"launch agents",
|
||||
"use claude code", "use codex", "use opencode", or "use pi", that is a
|
||||
literal instruction to dispatch them. `claude_code`, `codex`, and `opencode`
|
||||
are the primary implementers — pick per task: `claude_code` for multi-file /
|
||||
refactor / test-writing work, `codex` for narrow, well-scoped changes,
|
||||
`opencode` when a third implement/review vendor is wanted. Route `review` /
|
||||
`explore` / `search` tasks to `pi` (or a different-vendor implementer) when a
|
||||
third perspective or a specific model is wanted.
|
||||
"use claude code", "use codex", "use opencode", "use cursor", "use hermes",
|
||||
or "use pi", that is a literal instruction to dispatch them. `claude_code`,
|
||||
`codex`, `opencode`, `cursor`, and `hermes` are the primary implementers —
|
||||
pick per task: `claude_code` for multi-file / refactor / test-writing work,
|
||||
`codex` for narrow, well-scoped changes, and `opencode` / `cursor` / `hermes`
|
||||
when another implement/review vendor is wanted. Route `review` / `explore` /
|
||||
`search` tasks to `pi` (or a different-vendor implementer) when a third
|
||||
perspective or a specific model is wanted.
|
||||
|
||||
Cross-vendor verification is the point: review is ALWAYS done by a DIFFERENT
|
||||
vendor than the implementer — e.g. `claude_code`'s PR is reviewed by `codex`,
|
||||
`opencode`, or `pi`; `codex`'s by `claude_code`, `opencode`, or `pi`. Give the
|
||||
vendor than the implementer — e.g. `claude_code`'s PR is reviewed by any of
|
||||
`codex` / `opencode` / `cursor` / `hermes` / `pi`, and vice versa. Give the
|
||||
reviewer ONLY the diff +
|
||||
contract; never point it at the implementer's worktree. Only the implementer
|
||||
ever opens a PR (the reviewer just reports), so a reviewer's stray edits
|
||||
@@ -177,7 +184,7 @@ prompt: |
|
||||
terminal via `sys_terminal_launch` (it accepts a `cwd` override so you can run
|
||||
it inside a per-task worktree). NEVER use this terminal to launch coding
|
||||
agents / sub-agents — all delegation goes through `sys_session_send` to
|
||||
`claude_code` / `codex` / `opencode` / `pi`.
|
||||
`claude_code` / `codex` / `opencode` / `cursor` / `hermes` / `pi`.
|
||||
|
||||
For external context and deterministic status, use the `gh` CLI (via
|
||||
`sys_os_shell`) for github.com. Reach for such tools sparingly — only to
|
||||
@@ -237,9 +244,10 @@ prompt: |
|
||||
useful, cancel it instead of leaving it running while you re-dispatch. Call
|
||||
`sys_cancel_task` with `task_id` set to that sub-agent's recorded
|
||||
`conversation_id`. `claude_code` workers are hard-stopped and will wake you
|
||||
with a cancelled inbox item; `pi` and `opencode` workers honor the interrupt
|
||||
and stop; `codex` cancellation is currently best-effort, so do not assume its
|
||||
worker is gone unless the tool result or inbox confirms it.
|
||||
with a cancelled inbox item; `pi`, `opencode`, `cursor`, and `hermes` workers
|
||||
honor the interrupt and stop; `codex` cancellation is currently best-effort,
|
||||
so do not assume its worker is gone unless the tool result or inbox confirms
|
||||
it.
|
||||
- Do not repeatedly re-prompt a dark or failing sub-agent. For coding work,
|
||||
re-dispatch a fresh implementation sub-agent in a clean worktree, or
|
||||
escalate to the human.
|
||||
@@ -279,13 +287,15 @@ terminals:
|
||||
type: none
|
||||
|
||||
tools:
|
||||
# Coding sub-agents — see agents/<name>/. claude_code, codex, and opencode are
|
||||
# real CLI coding harnesses; pi is a headless multi-model worker. Each
|
||||
# implements, reviews (cross-vendor), and explores.
|
||||
# Coding sub-agents — see agents/<name>/. claude_code, codex, opencode,
|
||||
# cursor, and hermes are real CLI coding harnesses; pi is a headless
|
||||
# multi-model worker. Each implements, reviews (cross-vendor), and explores.
|
||||
agents:
|
||||
- claude_code
|
||||
- codex
|
||||
- opencode
|
||||
- cursor
|
||||
- hermes
|
||||
- pi
|
||||
|
||||
# Mechanism-layer enforcement — runner-side tool gate, no server change.
|
||||
@@ -308,7 +318,7 @@ guardrails:
|
||||
function:
|
||||
path: omnigent.inner.nessie.policies.spawn_bounds
|
||||
arguments:
|
||||
max_dispatches_per_turn: 5
|
||||
max_dispatches_per_turn: 6
|
||||
# sys_session_create counts too: with spawn: true polly can
|
||||
# launch self-defined children, and an uncounted create would
|
||||
# bypass the per-turn fan-out cap.
|
||||
|
||||
@@ -15,12 +15,12 @@ anyone needs to read through.
|
||||
2. Run the deterministic gates first — tests / lint / typecheck via
|
||||
`sys_os_shell`. If red, re-dispatch the implementer to drive it green first;
|
||||
don't involve the reviewer yet.
|
||||
3. Dispatch a DIFFERENT-vendor sub-agent as reviewer (Claude built it →
|
||||
`codex`, `opencode`, or `pi`; Codex built it → `claude_code`, `opencode`, or
|
||||
`pi`; OpenCode built it → `claude_code`, `codex`, or `pi`; Pi built it →
|
||||
`claude_code`, `codex`, or `opencode`). Use a task-based title such as
|
||||
`review-auth-refactor`, never the raw vendor name:
|
||||
`sys_session_send(agent="claude_code"|"codex"|"opencode"|"pi", title="review-<task_slug>",
|
||||
3. Dispatch a DIFFERENT-vendor sub-agent as reviewer: pick any AVAILABLE worker
|
||||
whose vendor differs from the implementer's — `claude_code`, `codex`,
|
||||
`opencode`, `cursor`, `hermes`, or `pi` (e.g. Claude built it → any of
|
||||
`codex` / `opencode` / `cursor` / `hermes` / `pi`, and so on). Use a
|
||||
task-based title such as `review-auth-refactor`, never the raw vendor name:
|
||||
`sys_session_send(agent="claude_code"|"codex"|"opencode"|"cursor"|"hermes"|"pi", title="review-<task_slug>",
|
||||
args={purpose: "review", input: "<the diff> + <the acceptance contract>.
|
||||
Review ONLY against the contract. Report blocking / non-blocking /
|
||||
suggestions. Do not edit code."})`. Give it the diff as text — do NOT point
|
||||
@@ -55,7 +55,7 @@ anyone needs to read through.
|
||||
human at the plan gate.
|
||||
- Give the reviewer ONLY the diff + contract — never the implementer's
|
||||
transcript or worktree. The cross-vendor independence is the whole point.
|
||||
- Review is a coding sub-agent (`claude_code`/`codex`/`opencode`/`pi`) dispatched with
|
||||
- Review is a coding sub-agent (`claude_code`/`codex`/`opencode`/`cursor`/`hermes`/`pi`) dispatched with
|
||||
`purpose: "review"` — a DIFFERENT vendor from the one that built the diff. It
|
||||
reports issues and never edits; only the implementer opens a PR, so a stray
|
||||
reviewer edit never reaches the deliverable.
|
||||
|
||||
@@ -14,7 +14,7 @@ dependency).
|
||||
Record the worktree path + branch in the registry
|
||||
(`.polly/registry.json`).
|
||||
2. Dispatch one implementation sub-agent per task, scoped to its worktree:
|
||||
`sys_session_send(agent="claude_code"|"codex"|"opencode", title="<task_slug>",
|
||||
`sys_session_send(agent="claude_code"|"codex"|"opencode"|"cursor"|"hermes", title="<task_slug>",
|
||||
args={purpose: "implement", input: "<task + acceptance contract +
|
||||
worktree path>"})`. Use a short task-based title such as `auth-refactor` or
|
||||
`fix-sse-error`, never the raw vendor name. State the scope and that it must
|
||||
|
||||
@@ -12,8 +12,8 @@ repository-specific technical question.
|
||||
## Procedure
|
||||
1. Decompose the question into one or more bounded investigation tasks. Prefer
|
||||
two independent lenses for ambiguous or high-stakes questions.
|
||||
2. Dispatch each task to `claude_code`, `codex`, `opencode`, or `pi`:
|
||||
`sys_session_send(agent="claude_code"|"codex"|"opencode"|"pi",
|
||||
2. Dispatch each task to `claude_code`, `codex`, `opencode`, `cursor`, `hermes`, or `pi`:
|
||||
`sys_session_send(agent="claude_code"|"codex"|"opencode"|"cursor"|"hermes"|"pi",
|
||||
title="explore-<task_slug>", args={purpose: "explore", input: "<question +
|
||||
exact scope + evidence requested>"})`. Use a task-based title such as
|
||||
`explore-ci-flake`, never the raw vendor name. Use `purpose: "search"` only
|
||||
|
||||
@@ -19,10 +19,16 @@ that store, extract new user/assistant messages, and POST them as
|
||||
``external_conversation_item`` events — which also seeds the session title from
|
||||
the first user message (the same hook claude/codex rely on).
|
||||
|
||||
Status (``running``/``idle``) is intentionally NOT posted here: the runner's
|
||||
PTY-activity watcher owns those edges for cursor-native (see
|
||||
``_publish_turn_status`` in :mod:`omnigent.runner.app`), exactly as for
|
||||
claude-native and pi-native.
|
||||
The web-facing ``running``/``idle`` *spinner* edges are intentionally NOT posted
|
||||
here: the runner's PTY-activity watcher owns those ``session.status`` edges for
|
||||
cursor-native (see ``_publish_turn_status`` in :mod:`omnigent.runner.app`),
|
||||
exactly as for claude-native and pi-native. That watcher drives only the web
|
||||
"Working…" spinner, though — it never wakes a parent orchestrator. So this
|
||||
forwarder additionally POSTs an ``external_session_status: idle`` event once per
|
||||
completed turn (the cursor ``stop`` hook's turn-end markers, tailed via
|
||||
:mod:`omnigent.cursor_native_status`) — the SAME server contract
|
||||
claude-/codex-/opencode-native use to mark a sub-agent turn terminal and wake its
|
||||
parent's inbox.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -41,6 +47,7 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from omnigent import cursor_native_status
|
||||
from omnigent._native_post_delivery import post_may_have_been_delivered
|
||||
from omnigent.cursor_native_bridge import FORK_HISTORY_CLOSE_TAG, FORK_HISTORY_OPEN_TAG
|
||||
|
||||
@@ -721,6 +728,30 @@ async def _patch_external_session_id(
|
||||
)
|
||||
|
||||
|
||||
async def _post_external_session_status(
|
||||
client: httpx.AsyncClient, *, session_id: str, status: str
|
||||
) -> None:
|
||||
"""POST one ``external_session_status`` event to the Sessions API.
|
||||
|
||||
For a sub-agent conversation the server maps an ``idle`` edge to a terminal
|
||||
completion that wakes the parent orchestrator's inbox — the SAME contract
|
||||
claude-/codex-/opencode-native use (see their ``_post_external_session_status``
|
||||
/ ``_post_status``). cursor-agent exposes turn completion only through its
|
||||
``stop`` hook, which records a marker
|
||||
(:func:`omnigent.cursor_native_status.record_turn_end`); this turns that
|
||||
marker into the authoritative wake signal. The PTY-activity watcher's
|
||||
``session.status: idle`` edge only drives the web spinner and never wakes a
|
||||
parent, which is why this explicit post is required.
|
||||
|
||||
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
||||
"""
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
json={"type": "external_session_status", "data": {"status": status}},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _post_conversation_item(
|
||||
client: httpx.AsyncClient, *, session_id: str, item: _MirrorItem
|
||||
) -> None:
|
||||
@@ -1124,6 +1155,29 @@ async def forward_cursor_store_to_session(
|
||||
state=model_state,
|
||||
model=observed_model,
|
||||
)
|
||||
# Turn over the cursor ``stop`` hook's turn-completion markers to
|
||||
# an ``external_session_status: idle`` edge — the signal that wakes
|
||||
# a parent orchestrator (the PTY watcher's spinner status never
|
||||
# does). This block sits at the poll-loop body level, deliberately
|
||||
# OUTSIDE the ``if store_path`` mirroring branch above: the stop
|
||||
# hook writes turn-end markers independently of the SQLite store,
|
||||
# so a turn-end is never missed even on a poll where the store is
|
||||
# unbound or empty. Deduped against a persisted posted-count so a
|
||||
# supervisor restart never re-wakes the parent for a turn it
|
||||
# already reported. Best-effort: a failed post leaves the count
|
||||
# unadvanced so the next poll retries.
|
||||
total_turn_ends = await asyncio.to_thread(
|
||||
cursor_native_status.count_turn_ends, bridge_dir
|
||||
)
|
||||
if total_turn_ends > await asyncio.to_thread(
|
||||
cursor_native_status.read_posted_count, bridge_dir
|
||||
):
|
||||
await _post_external_session_status(
|
||||
client, session_id=session_id, status="idle"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
cursor_native_status.write_posted_count, bridge_dir, total_turn_ends
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Turn-completion ("idle") signal for the cursor-native harness.
|
||||
|
||||
cursor-agent fires its ``stop`` hook once per completed turn (see
|
||||
:func:`omnigent.cursor_native_bridge.build_hooks_config`, which registers the
|
||||
:mod:`omnigent.cursor_native_usage` recorder). That hook is the ONLY
|
||||
authoritative "this turn just finished" signal cursor-agent exposes: its SQLite
|
||||
chat store (tailed by :mod:`omnigent.cursor_native_forwarder`) carries no
|
||||
turn-boundary marker, and the runner's PTY-activity watcher only drives the web
|
||||
"Working…" spinner (a ``session.status`` edge) — which never wakes a parent
|
||||
orchestrator.
|
||||
|
||||
So the stop hook ALSO appends a turn-end marker line here (alongside its usage
|
||||
line), and the runner-owned
|
||||
:func:`omnigent.cursor_native_forwarder.forward_cursor_store_to_session` poll
|
||||
loop tails it and POSTs an ``external_session_status: idle`` event — the SAME
|
||||
server contract claude-/codex-/opencode-native use to mark a sub-agent turn
|
||||
terminal and wake its parent's inbox. Without this a cursor-native sub-agent
|
||||
finished silently and never notified the orchestrator.
|
||||
|
||||
Stdlib-only (no httpx) so importing it from the stop hook keeps the hook fast —
|
||||
cursor blocks the turn end on the hook.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
#: Append-only log of per-turn completion markers written by the cursor ``stop``
|
||||
#: hook (one JSON object per completed turn) and tailed by the forwarder.
|
||||
TURN_END_FILE = "cursor_turn_end.jsonl"
|
||||
|
||||
#: Durable poster state: how many turn-end markers the forwarder has already
|
||||
#: turned into an ``external_session_status: idle`` post. Persisted so a
|
||||
#: supervisor restart never re-wakes the parent for a turn it already reported.
|
||||
_STATE_FILE = "cursor_status_forwarder.json"
|
||||
|
||||
|
||||
def record_turn_end(bridge_dir: Path, payload: object | None = None) -> None:
|
||||
"""Append one turn-completion marker (called from the cursor ``stop`` hook).
|
||||
|
||||
Fires on EVERY completed turn — including turns with no billable token usage,
|
||||
which :func:`omnigent.cursor_native_usage.record_usage_payload` skips — so the
|
||||
parent wake never depends on a turn having produced usage. Best-effort and
|
||||
stdlib-only; the caller swallows failures so usage/idle capture never breaks
|
||||
the agent turn.
|
||||
|
||||
:param bridge_dir: The cursor-native bridge dir (where the forwarder reads).
|
||||
:param payload: The cursor ``stop`` hook payload, if any — its
|
||||
``generation_id`` is recorded for traceability (not required).
|
||||
"""
|
||||
line: dict[str, object] = {"ts": time.time()}
|
||||
if isinstance(payload, dict):
|
||||
gen_id = payload.get("generation_id") or payload.get("conversation_id")
|
||||
if isinstance(gen_id, str) and gen_id:
|
||||
line["generation_id"] = gen_id
|
||||
bridge_dir.mkdir(parents=True, exist_ok=True)
|
||||
# O_APPEND keeps a fast-firing hook's short JSON line from interleaving.
|
||||
with open(bridge_dir / TURN_END_FILE, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(line, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def count_turn_ends(bridge_dir: Path) -> int:
|
||||
"""Return how many turn-end markers have been recorded (0 if none/unreadable).
|
||||
|
||||
The marker file is append-only, so the line count is the number of turns that
|
||||
have completed since the terminal was (re-)created.
|
||||
"""
|
||||
try:
|
||||
text = (bridge_dir / TURN_END_FILE).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return 0
|
||||
return sum(1 for raw in text.splitlines() if raw.strip())
|
||||
|
||||
|
||||
def read_posted_count(bridge_dir: Path) -> int:
|
||||
"""Load the count of turn-ends already POSTed as idle (0 on cold/unreadable)."""
|
||||
try:
|
||||
data = json.loads((bridge_dir / _STATE_FILE).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
posted = data.get("posted") if isinstance(data, dict) else None
|
||||
return posted if isinstance(posted, int) and posted >= 0 else 0
|
||||
|
||||
|
||||
def write_posted_count(bridge_dir: Path, posted: int) -> None:
|
||||
"""Atomically persist the count of turn-ends already POSTed as idle.
|
||||
|
||||
Persisted only AFTER a successful idle POST so a failed flush is retried (the
|
||||
unreported turn-ends stay unreported until the post lands).
|
||||
"""
|
||||
bridge_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp = bridge_dir / (_STATE_FILE + ".tmp")
|
||||
tmp.write_text(json.dumps({"posted": posted}), encoding="utf-8")
|
||||
os.replace(tmp, bridge_dir / _STATE_FILE)
|
||||
|
||||
|
||||
def clear_cursor_status_state(bridge_dir: Path) -> None:
|
||||
"""Remove the turn-end marker + poster state so a re-created terminal starts clean.
|
||||
|
||||
Sibling of :func:`omnigent.cursor_native_usage.clear_cursor_usage_state`: the
|
||||
runner calls this when it re-creates a cursor terminal so a stale marker count
|
||||
from a prior terminal can't make the new forwarder skip (or re-fire) idle.
|
||||
"""
|
||||
for name in (TURN_END_FILE, _STATE_FILE):
|
||||
with contextlib.suppress(OSError):
|
||||
(bridge_dir / name).unlink()
|
||||
@@ -120,14 +120,28 @@ def record_usage_payload(bridge_dir: Path, payload: object) -> bool:
|
||||
def _cli_record_usage(bridge_dir: Path) -> int:
|
||||
"""Hook entrypoint: read the JSON payload from stdin and append it.
|
||||
|
||||
The cursor ``stop`` hook fires once per completed turn, so this is also the
|
||||
authoritative "turn finished" signal: it records a turn-end marker
|
||||
(:func:`omnigent.cursor_native_status.record_turn_end`) on EVERY firing — even
|
||||
a turn with no billable usage, which :func:`record_usage_payload` skips — so
|
||||
the forwarder can POST an ``external_session_status: idle`` edge and wake the
|
||||
parent orchestrator.
|
||||
|
||||
Always emits ``{}`` (a no-op hook response cursor reads as "continue") and
|
||||
exits 0 — a usage-capture failure must never block or fail the agent turn.
|
||||
exits 0 — a usage/idle-capture failure must never block or fail the agent
|
||||
turn.
|
||||
"""
|
||||
from omnigent import cursor_native_status
|
||||
|
||||
try:
|
||||
raw = sys.stdin.read()
|
||||
payload = json.loads(raw) if raw.strip() else {}
|
||||
# Record the turn-end marker FIRST (and unconditionally) so the parent
|
||||
# wake never depends on usage parsing succeeding or the turn being
|
||||
# billable; usage capture is a best-effort addition on top.
|
||||
cursor_native_status.record_turn_end(bridge_dir, payload)
|
||||
record_usage_payload(bridge_dir, payload)
|
||||
except Exception: # noqa: BLE001 — never let usage capture break the turn
|
||||
except Exception: # noqa: BLE001 — never let usage/idle capture break the turn
|
||||
_logger.debug("cursor usage recorder failed", exc_info=True)
|
||||
# cursor expects JSON on stdout; an empty object is the "continue" response.
|
||||
sys.stdout.write("{}")
|
||||
|
||||
@@ -23,9 +23,17 @@ launched in the same cwd never mirror the same row into two conversations. We th
|
||||
poll ``messages`` past a high-water ``id`` and POST new user/assistant rows as
|
||||
``external_conversation_item`` events (which also seeds the session title).
|
||||
|
||||
Status (``running``/``idle``) is intentionally NOT posted here: the runner's
|
||||
PTY-activity watcher owns those edges for hermes-native (see
|
||||
:mod:`omnigent.runner.app`), exactly as for goose-/cursor-native.
|
||||
The web-facing ``running``/``idle`` *spinner* edges are intentionally NOT posted
|
||||
here: the runner's PTY-activity watcher owns those ``session.status`` edges for
|
||||
hermes-native (see :mod:`omnigent.runner.app`), exactly as for goose-/cursor-native.
|
||||
That watcher drives only the web "Working…" spinner, though — it never wakes a
|
||||
parent orchestrator. So this forwarder additionally derives turn completion from
|
||||
the message log (an ``assistant`` row with no ``tool_calls`` is the agentic loop's
|
||||
terminal step) and POSTs an ``external_session_status: idle`` event once per
|
||||
completed turn — the SAME server contract claude-/codex-/opencode-/cursor-native
|
||||
use to mark a sub-agent turn terminal and wake its parent's inbox. The post is
|
||||
deduped against a persisted posted-count (:mod:`omnigent.hermes_native_status`) so
|
||||
a supervisor restart never re-wakes the parent for a turn it already reported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -43,6 +51,8 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from omnigent import hermes_native_status
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
#: Seconds between store polls. Hermes flushes a ``messages`` row per agentic step
|
||||
@@ -569,6 +579,72 @@ def _read_new_items(
|
||||
return items
|
||||
|
||||
|
||||
def _assistant_row_has_tool_calls(tool_calls: object) -> bool:
|
||||
"""Whether an assistant ``messages`` row carries a non-empty ``tool_calls`` list.
|
||||
|
||||
Hermes writes one ``messages`` row per agentic step (complete, append-only —
|
||||
rows are never updated in place, which is why message mirroring keys off
|
||||
``id > last_id``). An assistant row with one or more tool calls means the loop
|
||||
continues (a tool result + further assistant step follow); a row with no tool
|
||||
calls is the loop's terminal step — the model returning its final answer.
|
||||
Mirrors the ``tool_calls`` parsing in :func:`_message_to_items`.
|
||||
"""
|
||||
if not isinstance(tool_calls, str) or not tool_calls.strip():
|
||||
return False
|
||||
try:
|
||||
calls = json.loads(tool_calls)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
return isinstance(calls, list) and len(calls) > 0
|
||||
|
||||
|
||||
def _count_completed_turns(db_path: Path, hermes_session_id: str) -> int:
|
||||
"""Count completed turns for *hermes_session_id* (0 on unreadable/empty).
|
||||
|
||||
A completed turn is an ``assistant`` row with no ``tool_calls`` — the agentic
|
||||
loop's terminal step (see :func:`_assistant_row_has_tool_calls`). Rows are
|
||||
counted regardless of the ``active`` flag: Hermes soft-deletes on compaction
|
||||
(sets ``active = 0``) rather than deleting rows, so ignoring it keeps the
|
||||
count monotonic and append-only — the dedup baseline can then only grow, never
|
||||
drop below the posted-count and falsely re-arm an idle post for an old turn.
|
||||
"""
|
||||
con = _connect_ro(db_path)
|
||||
if con is None:
|
||||
return 0
|
||||
try:
|
||||
rows = con.execute(
|
||||
"SELECT tool_calls FROM messages "
|
||||
"WHERE session_id = ? AND role = 'assistant' ORDER BY id",
|
||||
(hermes_session_id,),
|
||||
).fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
_warn_sqlite_once("turn-end count", exc)
|
||||
return 0
|
||||
finally:
|
||||
con.close()
|
||||
return sum(1 for (tool_calls,) in rows if not _assistant_row_has_tool_calls(tool_calls))
|
||||
|
||||
|
||||
async def _post_external_session_status(
|
||||
client: httpx.AsyncClient, *, session_id: str, status: str
|
||||
) -> None:
|
||||
"""POST one ``external_session_status`` event to the Sessions API.
|
||||
|
||||
For a sub-agent conversation the server maps an ``idle`` edge to a terminal
|
||||
completion that wakes the parent orchestrator's inbox — the SAME contract
|
||||
claude-/codex-/opencode-/cursor-native use. The runner's PTY-activity watcher
|
||||
emits only a web-spinner ``session.status`` edge for hermes-native and never
|
||||
wakes a parent, which is why this explicit post is required.
|
||||
|
||||
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
||||
"""
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
json={"type": "external_session_status", "data": {"status": status}},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _post_conversation_item(
|
||||
client: httpx.AsyncClient, *, session_id: str, item: _MirrorItem
|
||||
) -> None:
|
||||
@@ -814,6 +890,21 @@ async def forward_hermes_store_to_session(
|
||||
last_id = 0
|
||||
compaction_persisted = False
|
||||
_external_id_synced = False
|
||||
# The idle dedup baseline is per-terminal but
|
||||
# the completed-turn count is per
|
||||
# hermes_session_id; the child restarts its
|
||||
# count near 0, so rebase the baseline to the
|
||||
# child's current count. Without this the guard
|
||||
# `completed_turns > posted_count` stays False
|
||||
# until the child exceeds the parent's total,
|
||||
# suppressing idle posts for the child's first
|
||||
# turns — a worker that compacts then finishes
|
||||
# would never wake its parent.
|
||||
await asyncio.to_thread(
|
||||
hermes_native_status.write_posted_count,
|
||||
bridge_dir,
|
||||
await asyncio.to_thread(_count_completed_turns, db, child),
|
||||
)
|
||||
_write_state(
|
||||
bridge_dir,
|
||||
_ForwardState(
|
||||
@@ -843,6 +934,31 @@ async def forward_hermes_store_to_session(
|
||||
launch_epoch_s=launch_epoch_s,
|
||||
),
|
||||
)
|
||||
# Turn each newly-completed turn into an
|
||||
# ``external_session_status: idle`` edge — the signal that
|
||||
# wakes a parent orchestrator (the PTY watcher's spinner
|
||||
# status never does). A completed turn is an assistant row
|
||||
# with no tool_calls (the agentic loop's terminal step);
|
||||
# posted only AFTER its messages are mirrored above so the
|
||||
# parent sees the content before the completion. Deduped
|
||||
# against a persisted posted-count so a supervisor restart
|
||||
# never re-wakes the parent for a turn it already reported.
|
||||
# Best-effort: a failed post raises into the outer handler
|
||||
# and leaves the count unadvanced, so the next poll retries.
|
||||
completed_turns = await asyncio.to_thread(
|
||||
_count_completed_turns, db, hermes_session_id
|
||||
)
|
||||
if completed_turns > await asyncio.to_thread(
|
||||
hermes_native_status.read_posted_count, bridge_dir
|
||||
):
|
||||
await _post_external_session_status(
|
||||
client, session_id=session_id, status="idle"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
hermes_native_status.write_posted_count,
|
||||
bridge_dir,
|
||||
completed_turns,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Turn-completion ("idle") poster state for the hermes-native harness.
|
||||
|
||||
The completion->parent-wake path needs a harness to POST an
|
||||
``external_session_status: idle`` event to the Sessions API: the server maps
|
||||
that edge to a sub-agent turn-terminal and wakes the parent orchestrator's
|
||||
inbox (the SAME contract claude-/codex-/opencode-/cursor-native use). Hermes'
|
||||
PTY-activity watcher only emits a ``session.status: idle`` SSE edge that drives
|
||||
the web "Working…" spinner and never wakes a parent — so without an explicit
|
||||
post a hermes-native sub-agent finishes silently and the orchestrator hangs.
|
||||
|
||||
Unlike cursor-agent, hermes-agent exposes NO per-turn ``stop`` hook (only a
|
||||
``pre_tool_call`` hook, used for policy enforcement), so there is no separate
|
||||
process writing a turn-end marker. Instead the runner-owned
|
||||
:func:`omnigent.hermes_native_forwarder.forward_hermes_store_to_session` poll
|
||||
loop *derives* turn completion from Hermes' ``state.db`` itself — an
|
||||
``assistant`` row with no ``tool_calls`` is the agentic loop's terminal step,
|
||||
i.e. one completed turn (see ``_count_completed_turns`` in that module). The
|
||||
``messages`` table is the append-only "marker store"; this module owns only the
|
||||
*poster* state: how many of those completed turns have already been turned into
|
||||
an ``external_session_status: idle`` post.
|
||||
|
||||
It is the hermes analog of the poster-state half of
|
||||
:mod:`omnigent.cursor_native_status`. Persisting the posted-count means a
|
||||
supervisor restart never re-wakes the parent for a turn it already reported.
|
||||
Stdlib-only (no httpx) so it stays a cheap, dependency-free state file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
#: Durable poster state: how many completed turns the forwarder has already
|
||||
#: turned into an ``external_session_status: idle`` post. Persisted so a
|
||||
#: supervisor restart never re-wakes the parent for a turn it already reported.
|
||||
_STATE_FILE = "hermes_status_forwarder.json"
|
||||
|
||||
|
||||
def read_posted_count(bridge_dir: Path) -> int:
|
||||
"""Load the count of completed turns already POSTed as idle (0 on cold/unreadable)."""
|
||||
try:
|
||||
data = json.loads((bridge_dir / _STATE_FILE).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
posted = data.get("posted") if isinstance(data, dict) else None
|
||||
return posted if isinstance(posted, int) and posted >= 0 else 0
|
||||
|
||||
|
||||
def write_posted_count(bridge_dir: Path, posted: int) -> None:
|
||||
"""Atomically persist the count of completed turns already POSTed as idle.
|
||||
|
||||
Persisted only AFTER a successful idle POST so a failed flush is retried (the
|
||||
unreported turns stay unreported until the post lands).
|
||||
"""
|
||||
bridge_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp = bridge_dir / (_STATE_FILE + ".tmp")
|
||||
tmp.write_text(json.dumps({"posted": posted}), encoding="utf-8")
|
||||
os.replace(tmp, bridge_dir / _STATE_FILE)
|
||||
|
||||
|
||||
def clear_hermes_status_state(bridge_dir: Path) -> None:
|
||||
"""Remove the idle poster state so a re-created terminal starts clean.
|
||||
|
||||
Sibling of :func:`omnigent.hermes_native_forwarder.clear_hermes_bridge_state`:
|
||||
the runner calls this when it re-creates a hermes terminal so a stale
|
||||
posted-count from a prior terminal can't make the new forwarder skip (or
|
||||
re-fire) the ``external_session_status: idle`` parent-wake edge. The
|
||||
completed-turn count is derived per ``hermes_session_id`` (not per terminal),
|
||||
so after an in-session compaction re-pins to a forked child the forwarder
|
||||
rebases this posted-count to the child's current count; only the poster state
|
||||
is persisted here, so only it needs clearing.
|
||||
"""
|
||||
with contextlib.suppress(OSError):
|
||||
(bridge_dir / _STATE_FILE).unlink()
|
||||
@@ -2076,6 +2076,7 @@ async def _auto_create_cursor_terminal(
|
||||
write_mcp_config,
|
||||
)
|
||||
from omnigent.cursor_native_forwarder import clear_cursor_bridge_state, preseed_resume_state
|
||||
from omnigent.cursor_native_status import clear_cursor_status_state
|
||||
from omnigent.cursor_native_usage import clear_cursor_usage_state
|
||||
|
||||
bridge_dir = bridge_dir_for_session_id(session_id)
|
||||
@@ -2125,6 +2126,10 @@ async def _auto_create_cursor_terminal(
|
||||
# the cumulative count clean. Preserved across a preseeded resume (the
|
||||
# accumulator's generation-id dedup makes re-reading the log safe).
|
||||
clear_cursor_usage_state(bridge_dir)
|
||||
# Likewise drop the turn-end marker + idle poster state so a stale count
|
||||
# from a prior terminal can't make the new forwarder skip (or re-fire)
|
||||
# the ``external_session_status: idle`` parent-wake edge.
|
||||
clear_cursor_status_state(bridge_dir)
|
||||
if resume_chat_id is not None:
|
||||
_logger.warning(
|
||||
"cursor-native: could not pre-seed prior chat store for %r; "
|
||||
@@ -2504,9 +2509,14 @@ async def _auto_create_hermes_terminal(
|
||||
write_tmux_target,
|
||||
)
|
||||
from omnigent.hermes_native_forwarder import clear_hermes_bridge_state
|
||||
from omnigent.hermes_native_status import clear_hermes_status_state
|
||||
|
||||
bridge_dir = bridge_dir_for_session_id(session_id)
|
||||
clear_hermes_bridge_state(bridge_dir)
|
||||
# Likewise drop the idle poster state so a stale posted-count from a prior
|
||||
# terminal can't make the new forwarder skip (or re-fire) the
|
||||
# ``external_session_status: idle`` parent-wake edge.
|
||||
clear_hermes_status_state(bridge_dir)
|
||||
|
||||
# Write a per-session HERMES_HOME with the Omnigent policy hook so the
|
||||
# native TUI evaluates tool calls against Omnigent policies.
|
||||
|
||||
@@ -1970,6 +1970,8 @@ def test_materialize_directory_bundle_with_override_keeps_nested_harness_unpinne
|
||||
"claude_code": "claude-native",
|
||||
"codex": "codex-native",
|
||||
"opencode": "opencode-native",
|
||||
"cursor": "cursor-native",
|
||||
"hermes": "hermes-native",
|
||||
"pi": "pi",
|
||||
},
|
||||
),
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
polly is the standalone multi-agent coding orchestrator (successor to the
|
||||
deleted nessie example, whose deep structural pins were folded in here).
|
||||
Loads the bundle and asserts the distinctive wiring stays intact: the
|
||||
claude-sdk orchestrator brain, the four cross-vendor coding sub-agents
|
||||
(claude_code / codex / opencode / pi, which implement, review, and explore),
|
||||
claude-sdk orchestrator brain, the six cross-vendor coding sub-agents
|
||||
(claude_code / codex / opencode / cursor / hermes / pi, which implement, review,
|
||||
and explore),
|
||||
the three spine skills, and the bounds/blast-radius guardrails. Pure spec-load
|
||||
— no LLM, no credentials.
|
||||
|
||||
@@ -68,25 +69,34 @@ def test_orchestrator_executor(polly_spec: AgentSpec) -> None:
|
||||
|
||||
def test_coding_subagents(polly_spec: AgentSpec) -> None:
|
||||
"""
|
||||
The bundle has exactly four coding sub-agents: ``claude_code`` (Claude
|
||||
Code, claude-native), ``codex`` (Codex, codex-native), and ``opencode``
|
||||
(OpenCode, opencode-native) on the native terminal harnesses, plus ``pi``
|
||||
(Pi, pi) as the headless multi-model worker. All implement, review, and
|
||||
explore. The native harnesses make claude_code / codex / opencode render
|
||||
terminal-first (Chat / Terminal pill) so the human can watch or take over.
|
||||
The bundle has exactly six coding sub-agents: ``claude_code`` (claude-native),
|
||||
``codex`` (codex-native), ``opencode`` (opencode-native), ``cursor``
|
||||
(cursor-native), and ``hermes`` (hermes-native) on the native terminal
|
||||
harnesses, plus ``pi`` (pi) as the headless multi-model worker. All
|
||||
implement, review, and explore. The native harnesses render terminal-first
|
||||
(Chat / Terminal pill) so the human can watch or take over.
|
||||
|
||||
A missing/renamed agent means fewer implementers, and same-vendor harnesses
|
||||
would break cross-vendor review — polly's differentiator.
|
||||
"""
|
||||
fam = {a.name: a.executor.config.get("harness") for a in polly_spec.sub_agents}
|
||||
assert sorted(polly_spec.tools.agents) == ["claude_code", "codex", "opencode", "pi"]
|
||||
assert sorted(polly_spec.tools.agents) == [
|
||||
"claude_code",
|
||||
"codex",
|
||||
"cursor",
|
||||
"hermes",
|
||||
"opencode",
|
||||
"pi",
|
||||
]
|
||||
assert fam["claude_code"] == "claude-native"
|
||||
assert fam["codex"] == "codex-native"
|
||||
assert fam["opencode"] == "opencode-native"
|
||||
assert fam["cursor"] == "cursor-native"
|
||||
assert fam["hermes"] == "hermes-native"
|
||||
assert fam["pi"] == "pi"
|
||||
# Four distinct vendors → any implementer's diff is reviewable by another.
|
||||
assert len(set(fam.values())) == 4
|
||||
for name in ("claude_code", "codex", "opencode", "pi"):
|
||||
# Six distinct vendors → any implementer's diff is reviewable by another.
|
||||
assert len(set(fam.values())) == 6
|
||||
for name in ("claude_code", "codex", "opencode", "cursor", "hermes", "pi"):
|
||||
prompt = (_POLLY_BUNDLE / "agents" / name / "config.yaml").read_text(encoding="utf-8")
|
||||
assert "IMPLEMENT — write real product code" in prompt
|
||||
assert "REVIEW — verify another agent's diff" in prompt
|
||||
@@ -285,8 +295,8 @@ def test_investigation_skill_delegates_read_only_work() -> None:
|
||||
|
||||
assert "Use for any read-only task: investigation, debugging, audit" in compact
|
||||
assert (
|
||||
"Dispatch each task to `claude_code`, `codex`, `opencode`, or `pi`: "
|
||||
'`sys_session_send(agent="claude_code"|"codex"|"opencode"|"pi", '
|
||||
"Dispatch each task to `claude_code`, `codex`, `opencode`, `cursor`, `hermes`, or `pi`: "
|
||||
'`sys_session_send(agent="claude_code"|"codex"|"opencode"|"cursor"|"hermes"|"pi", '
|
||||
'title="explore-<task_slug>", '
|
||||
'args={purpose: "explore", input: "<question + exact scope + evidence requested>"})`'
|
||||
) in compact
|
||||
@@ -357,7 +367,7 @@ def test_orchestrator_guardrails(polly_spec: AgentSpec) -> None:
|
||||
def test_subagent_guardrails(polly_spec: AgentSpec) -> None:
|
||||
"""Each sub-agent carries the blast_radius gate (push/destructive)."""
|
||||
by_name = {a.name: a for a in polly_spec.sub_agents}
|
||||
for name in ("claude_code", "codex", "opencode", "pi"):
|
||||
for name in ("claude_code", "codex", "opencode", "cursor", "hermes", "pi"):
|
||||
guardrails = by_name[name].guardrails
|
||||
assert guardrails is not None, name
|
||||
assert [p.name for p in guardrails.policies] == ["blast_radius"], name
|
||||
@@ -390,6 +400,6 @@ def test_function_policies_have_nonempty_arguments(polly_spec: AgentSpec) -> Non
|
||||
)
|
||||
checked += 1
|
||||
# orchestrator: blast_radius + spawn_bounds + headless_subagent_purpose_guard
|
||||
# = 3; sub-agents: blast_radius x4 (claude_code, codex, opencode, pi) = 4
|
||||
# -> 7 total. Fewer = a policy dropped.
|
||||
assert checked == 7, f"expected 7 function policies in the bundle, inspected {checked}"
|
||||
# = 3; sub-agents: blast_radius x6 (claude_code, codex, opencode, cursor,
|
||||
# hermes, pi) = 6 -> 9 total. Fewer = a policy dropped.
|
||||
assert checked == 9, f"expected 9 function policies in the bundle, inspected {checked}"
|
||||
|
||||
@@ -104,7 +104,11 @@ def test_bundle_builder_is_reproducible(
|
||||
|
||||
# (name, bundle source dir, sub-agents the shipped definition declares today)
|
||||
_SHIPPED_SUB_AGENT_EXAMPLES = [
|
||||
("polly", app._POLLY_BUNDLE_SOURCE, {"claude_code", "codex", "opencode", "pi"}),
|
||||
(
|
||||
"polly",
|
||||
app._POLLY_BUNDLE_SOURCE,
|
||||
{"claude_code", "codex", "opencode", "cursor", "hermes", "pi"},
|
||||
),
|
||||
("debby", app._DEBBY_BUNDLE_SOURCE, {"claude", "gpt"}),
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Unit + loop tests for the cursor-native turn-completion ("idle") signal.
|
||||
|
||||
Covers the three layers of the cursor parent-wake path:
|
||||
|
||||
1. :mod:`omnigent.cursor_native_status` — the turn-end marker store + poster
|
||||
state (record/count markers, read/write/clear the posted-count).
|
||||
2. :func:`omnigent.cursor_native_usage._cli_record_usage` — the cursor ``stop``
|
||||
hook entrypoint, which must record a turn-end marker on EVERY firing (even a
|
||||
turn with no billable usage) so the parent wake never depends on usage.
|
||||
3. :func:`omnigent.cursor_native_forwarder.forward_cursor_store_to_session` —
|
||||
the poll loop must POST ``external_session_status: idle`` exactly once per
|
||||
completed turn, deduped against the persisted posted-count and restart-safe.
|
||||
|
||||
The loop tests deliberately let store discovery return ``None`` so the idle path
|
||||
is exercised in isolation from the chat-mirroring machinery — the idle check runs
|
||||
every poll independent of store binding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent import cursor_native_forwarder as fwd
|
||||
from omnigent import cursor_native_status as status
|
||||
|
||||
# --- status store: markers + poster state ------------------------------------
|
||||
|
||||
|
||||
def test_record_and_count_turn_ends(tmp_path: Path) -> None:
|
||||
bridge = tmp_path / "cursor-native" / "sess"
|
||||
assert status.count_turn_ends(bridge) == 0 # nothing / unreadable -> 0
|
||||
status.record_turn_end(bridge)
|
||||
status.record_turn_end(bridge, {"generation_id": "gen-2"})
|
||||
assert status.count_turn_ends(bridge) == 2
|
||||
|
||||
|
||||
def test_record_turn_end_fires_without_usage(tmp_path: Path) -> None:
|
||||
"""A turn-end marker is recorded even with no/empty payload (no billable usage)."""
|
||||
bridge = tmp_path / "b"
|
||||
status.record_turn_end(bridge, None)
|
||||
status.record_turn_end(bridge, {})
|
||||
assert status.count_turn_ends(bridge) == 2
|
||||
|
||||
|
||||
def test_posted_count_roundtrip_and_clear(tmp_path: Path) -> None:
|
||||
bridge = tmp_path / "b"
|
||||
assert status.read_posted_count(bridge) == 0
|
||||
status.write_posted_count(bridge, 3)
|
||||
assert status.read_posted_count(bridge) == 3
|
||||
# A re-created terminal clears BOTH the marker file and the poster state.
|
||||
status.record_turn_end(bridge)
|
||||
status.clear_cursor_status_state(bridge)
|
||||
assert status.count_turn_ends(bridge) == 0
|
||||
assert status.read_posted_count(bridge) == 0
|
||||
|
||||
|
||||
def test_read_posted_count_ignores_corrupt_state(tmp_path: Path) -> None:
|
||||
bridge = tmp_path / "b"
|
||||
bridge.mkdir(parents=True)
|
||||
(bridge / "cursor_status_forwarder.json").write_text("not json", encoding="utf-8")
|
||||
assert status.read_posted_count(bridge) == 0
|
||||
|
||||
|
||||
# --- stop-hook wiring: usage recorder also records a turn-end marker ----------
|
||||
|
||||
|
||||
def test_cli_record_usage_records_turn_end(tmp_path: Path, monkeypatch) -> None:
|
||||
"""The cursor ``stop`` hook entrypoint records a turn-end marker per firing."""
|
||||
from omnigent import cursor_native_usage
|
||||
|
||||
bridge = tmp_path / "cursor-native" / "sess"
|
||||
bridge.mkdir(parents=True)
|
||||
# The hook reads its JSON payload from stdin and writes ``{}`` to stdout.
|
||||
monkeypatch.setattr("sys.stdin.read", lambda: json.dumps({"generation_id": "g1"}))
|
||||
rc = cursor_native_usage._cli_record_usage(bridge)
|
||||
assert rc == 0
|
||||
assert status.count_turn_ends(bridge) == 1
|
||||
|
||||
|
||||
def test_cli_record_usage_records_turn_end_on_empty_stdin(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Even an empty hook payload (no usage) still records the turn-end marker."""
|
||||
from omnigent import cursor_native_usage
|
||||
|
||||
bridge = tmp_path / "b"
|
||||
bridge.mkdir(parents=True)
|
||||
monkeypatch.setattr("sys.stdin.read", lambda: "")
|
||||
assert cursor_native_usage._cli_record_usage(bridge) == 0
|
||||
assert status.count_turn_ends(bridge) == 1
|
||||
|
||||
|
||||
# --- forwarder loop: idle POST is once-per-turn, deduped, restart-safe --------
|
||||
|
||||
|
||||
class _StatusRecorder:
|
||||
"""Async stub for ``_post_external_session_status`` capturing posted statuses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.statuses: list[str] = []
|
||||
|
||||
async def __call__(self, client: object, *, session_id: str, status: str) -> None:
|
||||
self.statuses.append(status)
|
||||
|
||||
|
||||
async def _wait_until(predicate, *, max_ticks: int = 2000) -> None:
|
||||
"""Poll *predicate* on the event loop until true, or fail if it never holds."""
|
||||
for _ in range(max_ticks):
|
||||
if predicate():
|
||||
return
|
||||
await asyncio.sleep(0.001)
|
||||
raise AssertionError("forwarder never reached the expected state (wedged?)")
|
||||
|
||||
|
||||
async def _drive_idle_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
bridge_dir: Path,
|
||||
recorder: _StatusRecorder,
|
||||
*,
|
||||
until,
|
||||
max_ticks: int = 2000,
|
||||
) -> None:
|
||||
"""Run the real poll loop with store discovery disabled so only the idle path runs."""
|
||||
monkeypatch.setattr(fwd, "_discover_store", lambda *a, **k: None)
|
||||
monkeypatch.setattr(fwd, "_post_external_session_status", recorder)
|
||||
task = asyncio.create_task(
|
||||
fwd.forward_cursor_store_to_session(
|
||||
base_url="http://test",
|
||||
headers={},
|
||||
session_id="conv_1",
|
||||
bridge_dir=bridge_dir,
|
||||
agent_name="cursor-native-ui",
|
||||
workspace="/ws",
|
||||
launch_epoch_ms=1_000,
|
||||
poll_interval_s=0.001,
|
||||
)
|
||||
)
|
||||
try:
|
||||
for _ in range(max_ticks):
|
||||
if until():
|
||||
break
|
||||
await asyncio.sleep(0.001)
|
||||
else:
|
||||
raise AssertionError("forwarder never reached the expected state (wedged?)")
|
||||
# Let a few more polls run so a (buggy) duplicate post would surface.
|
||||
await asyncio.sleep(0.02)
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_posted_once_per_completed_turn(tmp_path: Path, monkeypatch) -> None:
|
||||
bridge = tmp_path / "cursor-native" / "sess"
|
||||
bridge.mkdir(parents=True)
|
||||
status.record_turn_end(bridge) # one completed turn
|
||||
recorder = _StatusRecorder()
|
||||
await _drive_idle_loop(
|
||||
monkeypatch, bridge, recorder, until=lambda: status.read_posted_count(bridge) >= 1
|
||||
)
|
||||
assert recorder.statuses == ["idle"]
|
||||
assert status.read_posted_count(bridge) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_dedupes_and_posts_per_new_turn(tmp_path: Path, monkeypatch) -> None:
|
||||
"""No duplicate idle while quiescent; a later turn-end posts exactly one more."""
|
||||
bridge = tmp_path / "cursor-native" / "sess"
|
||||
bridge.mkdir(parents=True)
|
||||
recorder = _StatusRecorder()
|
||||
monkeypatch.setattr(fwd, "_discover_store", lambda *a, **k: None)
|
||||
monkeypatch.setattr(fwd, "_post_external_session_status", recorder)
|
||||
status.record_turn_end(bridge) # turn 1 completes
|
||||
task = asyncio.create_task(
|
||||
fwd.forward_cursor_store_to_session(
|
||||
base_url="http://test",
|
||||
headers={},
|
||||
session_id="conv_1",
|
||||
bridge_dir=bridge,
|
||||
agent_name="cursor-native-ui",
|
||||
workspace="/ws",
|
||||
launch_epoch_ms=1_000,
|
||||
poll_interval_s=0.001,
|
||||
)
|
||||
)
|
||||
try:
|
||||
await _wait_until(lambda: status.read_posted_count(bridge) >= 1)
|
||||
await asyncio.sleep(0.02) # quiescent: a one-per-poll bug would post again
|
||||
assert recorder.statuses == ["idle"]
|
||||
status.record_turn_end(bridge) # turn 2 completes later
|
||||
await _wait_until(lambda: status.read_posted_count(bridge) >= 2)
|
||||
await asyncio.sleep(0.02)
|
||||
assert recorder.statuses == ["idle", "idle"]
|
||||
assert status.read_posted_count(bridge) == 2
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_restart_safe_does_not_rewake(tmp_path: Path, monkeypatch) -> None:
|
||||
"""A restart whose posted-count already covers every marker posts no idle."""
|
||||
bridge = tmp_path / "cursor-native" / "sess"
|
||||
bridge.mkdir(parents=True)
|
||||
status.record_turn_end(bridge)
|
||||
status.write_posted_count(bridge, 1) # already reported this turn before the "restart"
|
||||
recorder = _StatusRecorder()
|
||||
monkeypatch.setattr(fwd, "_discover_store", lambda *a, **k: None)
|
||||
monkeypatch.setattr(fwd, "_post_external_session_status", recorder)
|
||||
task = asyncio.create_task(
|
||||
fwd.forward_cursor_store_to_session(
|
||||
base_url="http://test",
|
||||
headers={},
|
||||
session_id="conv_1",
|
||||
bridge_dir=bridge,
|
||||
agent_name="cursor-native-ui",
|
||||
workspace="/ws",
|
||||
launch_epoch_ms=1_000,
|
||||
poll_interval_s=0.001,
|
||||
)
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(0.05) # let several polls run
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
assert recorder.statuses == []
|
||||
assert status.read_posted_count(bridge) == 1
|
||||
@@ -10,12 +10,14 @@ idempotent high-water cursor.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent import hermes_native_forwarder as f
|
||||
from omnigent import hermes_native_status as hstatus
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE sessions (
|
||||
@@ -470,6 +472,94 @@ async def _noop() -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def test_forward_loop_rebases_idle_count_on_compaction_repin(tmp_path, monkeypatch) -> None:
|
||||
"""Compaction re-pin rebases the idle posted-count to the child's count.
|
||||
|
||||
Regression: the completed-turn count is per hermes_session_id, but the idle
|
||||
dedup baseline (posted_count) is per bridge dir. A parent that accrued N idle
|
||||
posts leaves posted_count=N; the child's count restarts near 0, so without a
|
||||
rebase the guard ``completed_turns > posted_count`` stays False until the
|
||||
child exceeds N terminal turns — suppressing the child's early idle posts and
|
||||
hanging the orchestrator. On re-pin the baseline must drop to the child's
|
||||
current completed-turn count so the child's next completion still wakes the
|
||||
parent.
|
||||
"""
|
||||
workspace = str(tmp_path)
|
||||
db = tmp_path / "state.db"
|
||||
con = sqlite3.connect(db)
|
||||
con.executescript(_SCHEMA)
|
||||
con.executemany(
|
||||
"INSERT INTO sessions(id, source, cwd, started_at, parent_session_id) VALUES (?,?,?,?,?)",
|
||||
[
|
||||
("parent_1", "cli", workspace, 1000.0, None),
|
||||
("child_1", "cli", workspace, 1005.0, "parent_1"),
|
||||
],
|
||||
)
|
||||
con.executemany(
|
||||
"INSERT INTO messages(session_id, role, content, tool_calls, active, compacted) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
[
|
||||
# Parent: two completed turns (terminal assistant rows) + a compaction.
|
||||
("parent_1", "assistant", "done 1", None, 1, 0),
|
||||
("parent_1", "assistant", "done 2", None, 1, 0),
|
||||
("parent_1", "assistant", "compacted summary", None, 1, 1),
|
||||
# Child: one completed turn so far.
|
||||
("child_1", "user", "child hi", None, 1, 0),
|
||||
("child_1", "assistant", "child done", None, 1, 0),
|
||||
],
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
# Pin the parent; posted_count reflects the parent's two completed turns.
|
||||
f._write_state(bridge_dir, f._ForwardState(hermes_session_id="parent_1", last_id=3))
|
||||
hstatus.write_posted_count(bridge_dir, 2)
|
||||
|
||||
async def _fake_post(_client, *, session_id, item):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(f, "_post_conversation_item", _fake_post)
|
||||
monkeypatch.setattr(f, "_persist_hermes_compaction_item", lambda *a, **k: _noop())
|
||||
|
||||
idle_posts: list[str] = []
|
||||
|
||||
async def _fake_idle(_client, *, session_id, status):
|
||||
idle_posts.append(status)
|
||||
|
||||
monkeypatch.setattr(f, "_post_external_session_status", _fake_idle)
|
||||
|
||||
iteration = {"n": 0}
|
||||
|
||||
async def _sleep(_s):
|
||||
iteration["n"] += 1
|
||||
if iteration["n"] >= 2:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(f.asyncio, "sleep", _sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await f.forward_hermes_store_to_session(
|
||||
base_url="http://x",
|
||||
headers={},
|
||||
session_id="conv_child",
|
||||
bridge_dir=bridge_dir,
|
||||
agent_name="hermes-native-ui",
|
||||
workspace=workspace,
|
||||
launch_epoch_s=1000.0,
|
||||
db_path=db,
|
||||
)
|
||||
|
||||
# Re-pinned to the child, and the baseline dropped from the parent's 2 to the
|
||||
# child's 1 completed turn — so the child's already-present completion is not
|
||||
# suppressed. Without the rebase, posted_count would stay 2 and the guard
|
||||
# (1 > 2) would suppress the child's idle.
|
||||
assert f._read_state(bridge_dir).hermes_session_id == "child_1"
|
||||
assert hstatus.read_posted_count(bridge_dir) == 1
|
||||
assert idle_posts == [] # child's single completed turn == baseline, no double-post
|
||||
|
||||
|
||||
# --- Usage tracker tests ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -664,3 +754,227 @@ async def test_persist_hermes_compaction_item_empty_db(tmp_path: Path) -> None:
|
||||
assert body["type"] == "compaction"
|
||||
assert body["data"]["last_item_id"].startswith("compact_boundary_")
|
||||
assert "compacted_messages" not in body["data"]
|
||||
|
||||
|
||||
# --- turn-completion ("idle") parent-wake path --------------------------------
|
||||
#
|
||||
# Hermes has no per-turn stop hook (only a ``pre_tool_call`` policy hook), so the
|
||||
# forwarder derives turn completion from the message log itself: an ``assistant``
|
||||
# row with no ``tool_calls`` is the agentic loop's terminal step. It POSTs
|
||||
# ``external_session_status: idle`` once per completed turn — the edge that wakes
|
||||
# the parent orchestrator — deduped against a persisted posted-count.
|
||||
|
||||
|
||||
def _seed_turns(path: Path, *, cwd: str, started_at: float, session_id: str, n_turns: int) -> None:
|
||||
"""Seed *n_turns* completed turns: each is user + assistant(final, no tool_calls)."""
|
||||
con = sqlite3.connect(path)
|
||||
con.executescript(_SCHEMA)
|
||||
con.execute(
|
||||
"INSERT INTO sessions(id, source, cwd, started_at) VALUES (?,?,?,?)",
|
||||
(session_id, "cli", cwd, started_at),
|
||||
)
|
||||
rows = []
|
||||
for i in range(n_turns):
|
||||
rows.append((session_id, "user", f"ask {i}", None, None, None, 1))
|
||||
rows.append((session_id, "assistant", f"answer {i}", None, None, None, 1))
|
||||
con.executemany(
|
||||
"INSERT INTO messages"
|
||||
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
|
||||
" VALUES (?,?,?,?,?,?,?)",
|
||||
rows,
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def test_assistant_row_has_tool_calls() -> None:
|
||||
assert f._assistant_row_has_tool_calls(None) is False
|
||||
assert f._assistant_row_has_tool_calls("") is False
|
||||
assert f._assistant_row_has_tool_calls("[]") is False
|
||||
assert f._assistant_row_has_tool_calls("not json") is False
|
||||
assert f._assistant_row_has_tool_calls(json.dumps([{"id": "c1"}])) is True
|
||||
|
||||
|
||||
def test_count_completed_turns_counts_no_tool_call_assistant_rows(tmp_path: Path) -> None:
|
||||
"""A turn ends on an assistant row with no tool_calls; tool-call steps don't count."""
|
||||
db = tmp_path / "state.db"
|
||||
con = sqlite3.connect(db)
|
||||
con.executescript(_SCHEMA)
|
||||
con.execute(
|
||||
"INSERT INTO sessions(id, source, cwd, started_at) VALUES (?,?,?,?)",
|
||||
("s1", "cli", str(tmp_path), 1000.0),
|
||||
)
|
||||
tc = json.dumps([{"id": "c1", "call_id": "c1", "function": {"name": "f", "arguments": "{}"}}])
|
||||
rows = [
|
||||
("s1", "user", "go", None, None, None, 1),
|
||||
("s1", "assistant", "", None, tc, None, 1), # tool-call step -> not terminal
|
||||
("s1", "tool", "result", "c1", None, "f", 1),
|
||||
("s1", "assistant", "final answer", None, None, None, 1), # terminal -> +1
|
||||
]
|
||||
con.executemany(
|
||||
"INSERT INTO messages"
|
||||
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
|
||||
" VALUES (?,?,?,?,?,?,?)",
|
||||
rows,
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
assert f._count_completed_turns(db, "s1") == 1
|
||||
|
||||
|
||||
def test_count_completed_turns_counts_regardless_of_active(tmp_path: Path) -> None:
|
||||
"""Soft-deleted (compacted, active=0) terminal rows still count, keeping it monotonic."""
|
||||
db = tmp_path / "state.db"
|
||||
_seed_turns(db, cwd=str(tmp_path), started_at=1000.0, session_id="s1", n_turns=2)
|
||||
con = sqlite3.connect(db)
|
||||
con.execute("UPDATE messages SET active = 0 WHERE role = 'assistant'")
|
||||
con.commit()
|
||||
con.close()
|
||||
assert f._count_completed_turns(db, "s1") == 2
|
||||
|
||||
|
||||
def test_hermes_status_posted_count_roundtrip_and_clear(tmp_path: Path) -> None:
|
||||
bridge = tmp_path / "b"
|
||||
assert hstatus.read_posted_count(bridge) == 0
|
||||
hstatus.write_posted_count(bridge, 4)
|
||||
assert hstatus.read_posted_count(bridge) == 4
|
||||
hstatus.clear_hermes_status_state(bridge)
|
||||
assert hstatus.read_posted_count(bridge) == 0
|
||||
|
||||
|
||||
async def _run_hermes_loop(
|
||||
monkeypatch,
|
||||
*,
|
||||
db: Path,
|
||||
bridge_dir: Path,
|
||||
workspace: str,
|
||||
statuses: list[str],
|
||||
stop_after_iterations: int,
|
||||
) -> None:
|
||||
"""Drive the real hermes forward loop with HTTP stubbed; record idle statuses."""
|
||||
|
||||
async def _noop_item(_client, *, session_id, item):
|
||||
pass
|
||||
|
||||
async def _record_status(_client, *, session_id, status):
|
||||
statuses.append(status)
|
||||
|
||||
monkeypatch.setattr(f, "_post_conversation_item", _noop_item)
|
||||
monkeypatch.setattr(f, "_post_external_session_status", _record_status)
|
||||
|
||||
iteration = {"n": 0}
|
||||
|
||||
async def _sleep(_s):
|
||||
iteration["n"] += 1
|
||||
if iteration["n"] >= stop_after_iterations:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(f.asyncio, "sleep", _sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await f.forward_hermes_store_to_session(
|
||||
base_url="http://x",
|
||||
headers={},
|
||||
session_id="conv_idle",
|
||||
bridge_dir=bridge_dir,
|
||||
agent_name="hermes-native-ui",
|
||||
workspace=workspace,
|
||||
launch_epoch_s=1000.0,
|
||||
db_path=db,
|
||||
)
|
||||
|
||||
|
||||
async def test_forward_loop_posts_idle_once_per_turn(tmp_path, monkeypatch) -> None:
|
||||
workspace = str(tmp_path)
|
||||
db = tmp_path / "state.db"
|
||||
_seed_turns(db, cwd=workspace, started_at=1000.0, session_id="s1", n_turns=1)
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
statuses: list[str] = []
|
||||
await _run_hermes_loop(
|
||||
monkeypatch,
|
||||
db=db,
|
||||
bridge_dir=bridge_dir,
|
||||
workspace=workspace,
|
||||
statuses=statuses,
|
||||
stop_after_iterations=2, # discover+mirror+idle in iter 1, then stop
|
||||
)
|
||||
assert statuses == ["idle"]
|
||||
assert hstatus.read_posted_count(bridge_dir) == 1
|
||||
|
||||
|
||||
async def test_forward_loop_idle_restart_safe(tmp_path, monkeypatch) -> None:
|
||||
"""A restart whose posted-count already covers the completed turn posts no idle."""
|
||||
workspace = str(tmp_path)
|
||||
db = tmp_path / "state.db"
|
||||
_seed_turns(db, cwd=workspace, started_at=1000.0, session_id="s1", n_turns=1)
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
hstatus.write_posted_count(bridge_dir, 1) # already reported before the "restart"
|
||||
statuses: list[str] = []
|
||||
await _run_hermes_loop(
|
||||
monkeypatch,
|
||||
db=db,
|
||||
bridge_dir=bridge_dir,
|
||||
workspace=workspace,
|
||||
statuses=statuses,
|
||||
stop_after_iterations=3,
|
||||
)
|
||||
assert statuses == []
|
||||
assert hstatus.read_posted_count(bridge_dir) == 1
|
||||
|
||||
|
||||
async def test_forward_loop_idle_dedupes_and_posts_per_new_turn(tmp_path, monkeypatch) -> None:
|
||||
"""No duplicate idle while quiescent; a turn that lands later posts exactly one more."""
|
||||
workspace = str(tmp_path)
|
||||
db = tmp_path / "state.db"
|
||||
_seed_turns(db, cwd=workspace, started_at=1000.0, session_id="s1", n_turns=1)
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
statuses: list[str] = []
|
||||
|
||||
async def _noop_item(_client, *, session_id, item):
|
||||
pass
|
||||
|
||||
async def _record_status(_client, *, session_id, status):
|
||||
statuses.append(status)
|
||||
|
||||
monkeypatch.setattr(f, "_post_conversation_item", _noop_item)
|
||||
monkeypatch.setattr(f, "_post_external_session_status", _record_status)
|
||||
|
||||
iteration = {"n": 0}
|
||||
|
||||
async def _sleep(_s):
|
||||
iteration["n"] += 1
|
||||
# After the first turn has been reported, append a second completed turn
|
||||
# so the next poll observes a strictly higher count and posts once more.
|
||||
if iteration["n"] == 3:
|
||||
con = sqlite3.connect(db)
|
||||
con.execute(
|
||||
"INSERT INTO messages"
|
||||
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
|
||||
" VALUES (?,?,?,?,?,?,?)",
|
||||
("s1", "assistant", "answer 2", None, None, None, 1),
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
if iteration["n"] >= 6:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(f.asyncio, "sleep", _sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await f.forward_hermes_store_to_session(
|
||||
base_url="http://x",
|
||||
headers={},
|
||||
session_id="conv_idle",
|
||||
bridge_dir=bridge_dir,
|
||||
agent_name="hermes-native-ui",
|
||||
workspace=workspace,
|
||||
launch_epoch_s=1000.0,
|
||||
db_path=db,
|
||||
)
|
||||
# One idle for the seeded turn, one for the turn appended mid-run — never
|
||||
# one-per-poll across the six iterations.
|
||||
assert statuses == ["idle", "idle"]
|
||||
assert hstatus.read_posted_count(bridge_dir) == 2
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ClaudeIcon } from "@/components/icons/ClaudeIcon";
|
||||
import { CodexIcon } from "@/components/icons/CodexIcon";
|
||||
import { CursorIcon } from "@/components/icons/CursorIcon";
|
||||
import { GooseIcon } from "@/components/icons/GooseIcon";
|
||||
import { HermesIcon } from "@/components/icons/HermesIcon";
|
||||
import { KimiIcon } from "@/components/icons/KimiIcon";
|
||||
import { KiroIcon } from "@/components/icons/KiroIcon";
|
||||
import { NessieIcon } from "@/components/icons/NessieIcon";
|
||||
@@ -39,11 +40,13 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
|
||||
if (nativeAgent?.iconKind === "goose") return GooseIcon;
|
||||
if (nativeAgent?.iconKind === "kimi") return KimiIcon;
|
||||
if (nativeAgent?.iconKind === "antigravity") return AntigravityIcon;
|
||||
if (nativeAgent?.iconKind === "hermes") return HermesIcon;
|
||||
// A null harness (spec couldn't load) flows through to the bot fallback.
|
||||
if (agent.harness?.includes("codex")) return CodexIcon;
|
||||
if (agent.harness?.includes("claude")) return ClaudeIcon;
|
||||
// Both the SDK "cursor" harness and "cursor-native" get the Cursor glyph.
|
||||
if (agent.harness?.includes("cursor")) return CursorIcon;
|
||||
if (agent.harness?.includes("hermes")) return HermesIcon;
|
||||
if (agent.harness?.includes("kiro")) return KiroIcon;
|
||||
if (agent.harness?.includes("goose")) return GooseIcon;
|
||||
// Both the SDK "kimi"/"kimi-code" harness and "kimi-native" get the Kimi glyph.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
// Hermes Agent (Nous Research) glyph — an original caduceus: the winged staff
|
||||
// with two entwined serpents, the classic emblem of Hermes the messenger.
|
||||
// Drawn in currentColor so it follows the app theme like its sibling icons;
|
||||
// authored here (not a third-party brand asset). The left wing + serpent are
|
||||
// drawn once and the whole set is mirrored around the x=12 center for exact
|
||||
// symmetry, so the two snakes open the caduceus' twin lens loops.
|
||||
export function HermesIcon(props: SVGProps<SVGSVGElement>) {
|
||||
// One wing (two feathers) spreading from the top of the staff.
|
||||
const wing = "M11.5 5.9 C 9 4.7 6.4 4.7 4.8 5.7 " + "M11.5 7.3 C 9.4 6.6 7.2 6.8 5.8 7.9";
|
||||
// One serpent weaving down the staff: it bows out, crosses the centre, bows
|
||||
// back the other way, and converges at the foot. Mirrored, the two snakes
|
||||
// weave the entwined caduceus.
|
||||
const snake = "M12 8.2 C 8.9 9 8.6 11 12 12 C 15.4 13 15.1 15 12 16 C 9.9 16.7 9.6 17.6 12 18.2";
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
{/* staff */}
|
||||
<path d="M12 5 V 20" />
|
||||
{/* left wing + serpent */}
|
||||
<path d={wing} />
|
||||
<path d={snake} />
|
||||
{/* right wing + serpent — the left set mirrored around the x=12 center */}
|
||||
<g transform="matrix(-1 0 0 1 24 0)">
|
||||
<path d={wing} />
|
||||
<path d={snake} />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { ClaudeIcon } from "@/components/icons/ClaudeIcon";
|
||||
import { CodexIcon } from "@/components/icons/CodexIcon";
|
||||
import { CursorIcon } from "@/components/icons/CursorIcon";
|
||||
import { GooseIcon } from "@/components/icons/GooseIcon";
|
||||
import { HermesIcon } from "@/components/icons/HermesIcon";
|
||||
import { KimiIcon } from "@/components/icons/KimiIcon";
|
||||
import { KiroIcon } from "@/components/icons/KiroIcon";
|
||||
import { NessieIcon } from "@/components/icons/NessieIcon";
|
||||
@@ -317,6 +318,7 @@ function brandChildIcon(child: ChildSessionInfo): AgentRowIcon | null {
|
||||
if (nativeAgent?.iconKind === "antigravity") return AntigravityIcon;
|
||||
if (nativeAgent?.iconKind === "goose") return GooseIcon;
|
||||
if (nativeAgent?.iconKind === "kimi") return KimiIcon;
|
||||
if (nativeAgent?.iconKind === "hermes") return HermesIcon;
|
||||
// Exact match — substring checks would false-match names like "pipeline".
|
||||
if (child.tool === PI_AGENT_NAME) return PiIcon;
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user