mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 04:08:28 +08:00
Compare commits
7 Commits
feat/codex
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a712947966 | ||
|
|
8d7ba19f71 | ||
|
|
485a1d06b3 | ||
|
|
6f8dd5844f | ||
|
|
e66e1d0417 | ||
|
|
c01a050eac | ||
|
|
244cd59c73 |
@@ -1340,6 +1340,47 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str
|
||||
return SendResult(success=False, error=str(e), retryable=not is_timeout)
|
||||
|
||||
async def send_kanban_blocked(
|
||||
self,
|
||||
chat_id: str,
|
||||
task_id: Any,
|
||||
reason: Optional[str] = None,
|
||||
*,
|
||||
assignee: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> SendResult:
|
||||
"""Send a kanban blocked notification through the standard Telegram sender."""
|
||||
task = task_id if isinstance(task_id, dict) else {}
|
||||
resolved_task_id = (
|
||||
task.get("task_id")
|
||||
or task.get("id")
|
||||
or kwargs.get("task_id")
|
||||
or kwargs.get("id")
|
||||
or task_id
|
||||
)
|
||||
resolved_reason = (
|
||||
reason
|
||||
or task.get("blocked_reason")
|
||||
or task.get("block_reason")
|
||||
or task.get("reason")
|
||||
or kwargs.get("blocked_reason")
|
||||
or kwargs.get("block_reason")
|
||||
or kwargs.get("reason")
|
||||
or ""
|
||||
)
|
||||
resolved_assignee = assignee or task.get("assignee") or kwargs.get("assignee") or ""
|
||||
resolved_title = title or task.get("title") or kwargs.get("title") or ""
|
||||
|
||||
tag = f"@{resolved_assignee} " if resolved_assignee else ""
|
||||
reason_suffix = f": {str(resolved_reason)[:160]}" if resolved_reason else ""
|
||||
title_suffix = f" - {str(resolved_title)[:120]}" if resolved_title else ""
|
||||
message = f"⏸ {tag}Kanban {resolved_task_id} blocked{reason_suffix}{title_suffix}"
|
||||
|
||||
return await self.send(chat_id, message, reply_to=reply_to, metadata=metadata)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
||||
@@ -2542,6 +2542,16 @@ DEFAULT_FAILURE_LIMIT = 5
|
||||
# Legacy alias — callers / tests still reference the old name.
|
||||
DEFAULT_SPAWN_FAILURE_LIMIT = DEFAULT_FAILURE_LIMIT
|
||||
|
||||
# OpenAI Codex OAuth credentials live in profile-local auth.json rather than
|
||||
# only in config.yaml / .env. Kanban workers run under their assignee's profile,
|
||||
# so a cloned profile can have a valid model provider but still exit immediately
|
||||
# if its auth store was not intentionally initialized. Keep this non-secret: the
|
||||
# preflight checks only config metadata plus auth.json presence/size, never token
|
||||
# values.
|
||||
_AUTH_STORE_REQUIRED_PROVIDERS = {
|
||||
"openai-codex": "OpenAI Codex",
|
||||
}
|
||||
|
||||
# Max bytes to keep in a single worker log file. The dispatcher truncates
|
||||
# and rotates on spawn if the file is larger than this at spawn time.
|
||||
DEFAULT_LOG_ROTATE_BYTES = 2 * 1024 * 1024 # 2 MiB
|
||||
@@ -3107,6 +3117,79 @@ def has_spawnable_ready(conn: sqlite3.Connection) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _worker_profile_dir(profile: str) -> Path:
|
||||
"""Resolve a kanban assignee profile to its HERMES_HOME directory."""
|
||||
from hermes_cli.profiles import get_profile_dir
|
||||
|
||||
return get_profile_dir(profile)
|
||||
|
||||
|
||||
def _profile_configured_provider(profile_dir: Path) -> Optional[str]:
|
||||
"""Read the profile's configured model provider without loading secrets."""
|
||||
config_path = profile_dir / "config.yaml"
|
||||
if not config_path.is_file():
|
||||
return None
|
||||
try:
|
||||
import yaml
|
||||
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
provider: Any = None
|
||||
model_cfg = raw.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
provider = model_cfg.get("provider")
|
||||
if not provider:
|
||||
provider = raw.get("provider")
|
||||
if not isinstance(provider, str):
|
||||
return None
|
||||
provider = provider.strip().lower()
|
||||
return provider or None
|
||||
|
||||
|
||||
def _preflight_worker_profile_auth(task: Task) -> None:
|
||||
"""Fail early when a profile-local OAuth auth store is plainly absent.
|
||||
|
||||
This intentionally does NOT read or copy auth.json contents. It only checks
|
||||
the assignee profile's non-secret configured provider and whether an auth
|
||||
store file exists with nonzero size. The worker still performs the
|
||||
authoritative credential validation when it starts; this preflight catches
|
||||
the common cloned-profile footgun before spawning a process that can only
|
||||
exit immediately.
|
||||
"""
|
||||
profile = (task.assignee or "").strip()
|
||||
if not profile:
|
||||
return
|
||||
try:
|
||||
profile_dir = _worker_profile_dir(profile)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
provider = _profile_configured_provider(profile_dir)
|
||||
display_name = _AUTH_STORE_REQUIRED_PROVIDERS.get(provider or "")
|
||||
if not display_name:
|
||||
return
|
||||
|
||||
auth_path = profile_dir / "auth.json"
|
||||
try:
|
||||
has_auth_store = auth_path.is_file() and auth_path.stat().st_size > 0
|
||||
except OSError:
|
||||
has_auth_store = False
|
||||
if has_auth_store:
|
||||
return
|
||||
|
||||
raise RuntimeError(
|
||||
f"Kanban worker profile {profile!r} is configured for {display_name} "
|
||||
f"but has no profile-local auth store at {auth_path}. "
|
||||
f"Authenticate this profile intentionally with `hermes -p {profile} auth` "
|
||||
f"or `hermes -p {profile} model` before dispatching. Hermes will not "
|
||||
"copy OAuth credentials from another profile automatically."
|
||||
)
|
||||
|
||||
|
||||
def dispatch_once(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
@@ -3274,6 +3357,7 @@ def _default_spawn(
|
||||
import subprocess
|
||||
if not task.assignee:
|
||||
raise ValueError(f"task {task.id} has no assignee")
|
||||
_preflight_worker_profile_auth(task)
|
||||
|
||||
from hermes_cli.profiles import normalize_profile_name
|
||||
|
||||
|
||||
3
plugins/kanban/dashboard/dist/index.js
vendored
3
plugins/kanban/dashboard/dist/index.js
vendored
@@ -496,6 +496,7 @@
|
||||
if (!boardData) return null;
|
||||
const q = search.trim().toLowerCase();
|
||||
const filterTask = function (t) {
|
||||
if (tenantFilter && t.tenant !== tenantFilter) return false;
|
||||
if (assigneeFilter && t.assignee !== assigneeFilter) return false;
|
||||
if (q) {
|
||||
const hay = `${t.id} ${t.title || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase();
|
||||
@@ -508,7 +509,7 @@
|
||||
return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) });
|
||||
}),
|
||||
});
|
||||
}, [boardData, assigneeFilter, search]);
|
||||
}, [boardData, tenantFilter, assigneeFilter, search]);
|
||||
|
||||
// --- actions ------------------------------------------------------------
|
||||
const moveTask = useCallback(function (taskId, newStatus) {
|
||||
|
||||
9
plugins/kanban/dashboard/dist/style.css
vendored
9
plugins/kanban/dashboard/dist/style.css
vendored
@@ -9,6 +9,15 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Override the Nous DS global `code { background: var(--midground) }` rule
|
||||
which paints an opaque cream/yellow fill on every <code> inside the board,
|
||||
hiding the text underneath. Kanban uses <code> for event payloads, run-meta,
|
||||
and log panes — those need transparent backgrounds. */
|
||||
.hermes-kanban code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---- Columns layout -------------------------------------------------- */
|
||||
|
||||
.hermes-kanban-columns {
|
||||
|
||||
@@ -50,6 +50,8 @@ AUTHOR_MAP = {
|
||||
"159539633+MottledShadow@users.noreply.github.com": "MottledShadow",
|
||||
"aludwin+gh@gmail.com": "adamludwin",
|
||||
"ngusev@astralinux.ru": "NikolayGusev-astra",
|
||||
"liuguangyong201@hellobike.com": "liuguangyong93",
|
||||
"maciekczech@users.noreply.github.com": "maciekczech",
|
||||
"2093036+exiao@users.noreply.github.com": "exiao",
|
||||
"rylen.anil@gmail.com": "rylena",
|
||||
"godnanijatin@gmail.com": "jatingodnani",
|
||||
|
||||
@@ -13,6 +13,7 @@ WITHOUT message_thread_id so the message still reaches the chat.
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -273,6 +274,55 @@ async def test_send_without_thread_id_unaffected():
|
||||
assert call_log[0]["message_thread_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_kanban_blocked_delegates_to_send_with_metadata():
|
||||
"""The blocked-notifier compatibility API should use the normal sender."""
|
||||
adapter = _make_adapter()
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="kanban-msg"))
|
||||
|
||||
result = await adapter.send_kanban_blocked(
|
||||
"123",
|
||||
"KAN-7",
|
||||
"waiting for owner",
|
||||
assignee="ada",
|
||||
metadata={"thread_id": "42"},
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "kanban-msg"
|
||||
adapter.send.assert_awaited_once_with(
|
||||
"123",
|
||||
"⏸ @ada Kanban KAN-7 blocked: waiting for owner",
|
||||
reply_to=None,
|
||||
metadata={"thread_id": "42"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_kanban_blocked_accepts_task_mapping():
|
||||
"""Downstream guardrails may pass task payloads instead of separate fields."""
|
||||
adapter = _make_adapter()
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="kanban-msg"))
|
||||
|
||||
result = await adapter.send_kanban_blocked(
|
||||
"123",
|
||||
{
|
||||
"task_id": "KAN-8",
|
||||
"blocked_reason": "needs maintainer answer",
|
||||
"assignee": "leon",
|
||||
"title": "Clarify gateway hook contract",
|
||||
},
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
adapter.send.assert_awaited_once_with(
|
||||
"123",
|
||||
"⏸ @leon Kanban KAN-8 blocked: needs maintainer answer - Clarify gateway hook contract",
|
||||
reply_to=None,
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_retries_network_errors_normally():
|
||||
"""Real transient network errors (not BadRequest) should still be retried."""
|
||||
|
||||
@@ -474,6 +474,104 @@ def test_dispatch_spawn_failure_releases_claim(kanban_home, all_assignees_spawna
|
||||
assert kb.get_task(conn, t).claim_lock is None
|
||||
|
||||
|
||||
def test_default_spawn_preflights_missing_profile_oauth_auth_store(
|
||||
kanban_home, monkeypatch, tmp_path
|
||||
):
|
||||
profile_home = kanban_home / "profiles" / "dogfood"
|
||||
profile_home.mkdir(parents=True)
|
||||
(profile_home / "config.yaml").write_text(
|
||||
"model:\n"
|
||||
" default: gpt-5.5\n"
|
||||
" provider: openai-codex\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
popen_called = False
|
||||
|
||||
class _FakePopen:
|
||||
def __init__(self, cmd, **kwargs):
|
||||
nonlocal popen_called
|
||||
popen_called = True
|
||||
self.pid = 4242
|
||||
|
||||
monkeypatch.setattr("subprocess.Popen", _FakePopen)
|
||||
|
||||
task = kb.Task(
|
||||
id="t_missing_auth",
|
||||
title="x",
|
||||
body=None,
|
||||
assignee="dogfood",
|
||||
status="ready",
|
||||
priority=0,
|
||||
created_by=None,
|
||||
created_at=0,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
workspace_kind="scratch",
|
||||
workspace_path=None,
|
||||
claim_lock=None,
|
||||
claim_expires=None,
|
||||
tenant=None,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="dogfood.*OpenAI Codex.*auth"):
|
||||
kb._default_spawn(task, str(tmp_path / "ws"))
|
||||
|
||||
assert not popen_called
|
||||
|
||||
|
||||
def test_default_spawn_allows_profile_with_oauth_auth_store(
|
||||
kanban_home, monkeypatch, tmp_path
|
||||
):
|
||||
profile_home = kanban_home / "profiles" / "dogfood"
|
||||
profile_home.mkdir(parents=True)
|
||||
(profile_home / "config.yaml").write_text(
|
||||
"model:\n"
|
||||
" default: gpt-5.5\n"
|
||||
" provider: openai-codex\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(profile_home / "auth.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
popen_calls = []
|
||||
|
||||
class _FakePopen:
|
||||
def __init__(self, cmd, **kwargs):
|
||||
popen_calls.append((cmd, kwargs))
|
||||
stdout = kwargs.get("stdout")
|
||||
if stdout is not None:
|
||||
stdout.close()
|
||||
self.pid = 4242
|
||||
|
||||
monkeypatch.setattr("subprocess.Popen", _FakePopen)
|
||||
|
||||
task = kb.Task(
|
||||
id="t_has_auth",
|
||||
title="x",
|
||||
body=None,
|
||||
assignee="dogfood",
|
||||
status="ready",
|
||||
priority=0,
|
||||
created_by=None,
|
||||
created_at=0,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
workspace_kind="scratch",
|
||||
workspace_path=None,
|
||||
claim_lock=None,
|
||||
claim_expires=None,
|
||||
tenant=None,
|
||||
)
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
|
||||
assert kb._default_spawn(task, str(workspace)) == 4242
|
||||
assert len(popen_calls) == 1
|
||||
cmd, kwargs = popen_calls[0]
|
||||
assert cmd[:3] == ["hermes", "-p", "dogfood"]
|
||||
assert kwargs["env"]["HERMES_PROFILE"] == "dogfood"
|
||||
|
||||
|
||||
def test_dispatch_reclaims_stale_before_spawning(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="x", assignee="alice")
|
||||
|
||||
@@ -127,6 +127,43 @@ def test_tenant_filter(client):
|
||||
assert total == 1
|
||||
|
||||
|
||||
def test_dashboard_select_filters_use_sdk_value_change_handler():
|
||||
"""Tenant/assignee filters must work with the dashboard SDK Select API.
|
||||
|
||||
The dashboard Select component is shadcn-like and calls
|
||||
``onValueChange(value)`` instead of native ``onChange(event)``. A native-only
|
||||
handler leaves the tenant dropdown visually selectable but never updates the
|
||||
filtered board query.
|
||||
"""
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
bundle = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
|
||||
js = bundle.read_text()
|
||||
|
||||
assert "function selectChangeHandler(setter)" in js
|
||||
assert "onValueChange: function (v)" in js
|
||||
assert "onChange: function (e)" in js
|
||||
assert "selectChangeHandler(props.setTenantFilter)" in js
|
||||
assert "selectChangeHandler(props.setAssigneeFilter)" in js
|
||||
|
||||
|
||||
def test_dashboard_client_side_filtering_includes_tenant_filter():
|
||||
"""The rendered board must also filter by tenant.
|
||||
|
||||
The API request includes ``?tenant=...``, but the dashboard also filters the
|
||||
locally cached board for search/assignee changes. Without checking
|
||||
``tenantFilter`` here, switching tenants can leave stale cards visible until a
|
||||
full reload finishes.
|
||||
"""
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
bundle = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
|
||||
js = bundle.read_text()
|
||||
|
||||
assert "if (tenantFilter && t.tenant !== tenantFilter) return false;" in js
|
||||
assert "[boardData, tenantFilter, assigneeFilter, search]" in js
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /tasks/:id returns body + comments + events + links
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user