Merge origin/main into core-tool-deferral (resolve show_tip test seam onto the check_tips_enabled gate)

This commit is contained in:
Teknium
2026-09-01 21:49:14 -07:00
1137 changed files with 138764 additions and 7075 deletions
+136 -12
View File
@@ -43,6 +43,10 @@ import uuid
from pathlib import Path
_IS_WINDOWS = platform.system() == "Windows"
# systemd transient scopes exist only on Linux. Gate every scope-path branch
# on this constant (not merely "not Windows") so macOS and other POSIX
# platforms provably never touch systemd code (#70716 cross-platform audit).
_IS_LINUX = platform.system() == "Linux"
from tools.environments.local import _find_shell, _resolve_safe_cwd, _sanitize_subprocess_env
from hermes_cli._subprocess_compat import windows_hide_flags
from dataclasses import dataclass, field
@@ -215,7 +219,7 @@ def _systemd_run_user_scope_available() -> bool:
return False
available = False
if not _IS_WINDOWS:
if _IS_LINUX:
try:
import shutil
@@ -379,6 +383,10 @@ class ProcessSession:
id: str # Unique session ID ("proc_xxxxxxxxxxxx")
command: str # Original command string
task_id: str = "" # Task/sandbox isolation key
owner_task_id: str = "" # RAW spawning task id (e.g. subagent "sa-...");
# task_id is the CONTAINER key and may be collapsed
# to "default"/session key by _resolve_container_task_id,
# so ownership checks must use this field (#child-notify)
session_key: str = "" # Gateway session key (for reset protection)
pid: Optional[int] = None # OS process ID
process: Optional[subprocess.Popen] = None # Popen handle (local only)
@@ -623,6 +631,7 @@ class ProcessRegistry:
"session_id": session.id,
"session_key": session.session_key,
"task_id": session.task_id,
"owner_task_id": session.owner_task_id or session.task_id,
"command": session.command,
"type": "watch_disabled",
"suppressed": session._watch_suppressed,
@@ -661,6 +670,7 @@ class ProcessRegistry:
"session_id": session.id,
"session_key": session.session_key,
"task_id": session.task_id,
"owner_task_id": session.owner_task_id or session.task_id,
"command": session.command,
"type": "watch_match",
"pattern": matched_pattern,
@@ -687,6 +697,7 @@ class ProcessRegistry:
"session_id": session.id,
"session_key": session.session_key,
"task_id": session.task_id,
"owner_task_id": session.owner_task_id or session.task_id,
"command": session.command,
"type": "watch_disabled",
"suppressed": 0,
@@ -1038,6 +1049,7 @@ class ProcessRegistry:
session_key: str = "",
env_vars: dict = None,
use_pty: bool = False,
owner_task_id: str = "",
) -> ProcessSession:
"""
Spawn a background process locally.
@@ -1062,6 +1074,7 @@ class ProcessRegistry:
id=f"proc_{uuid.uuid4().hex[:12]}",
command=command,
task_id=task_id,
owner_task_id=owner_task_id or task_id,
session_key=session_key,
cwd=_resolve_safe_cwd(cwd or os.getcwd()),
started_at=time.time(),
@@ -1089,7 +1102,7 @@ class ProcessRegistry:
# Wrap the PTY command in a systemd scope so interactive
# executors get their own cgroup, same as pipe mode.
pty_in_supervised_gateway = (
not _IS_WINDOWS and _is_supervised_gateway_process()
_IS_LINUX and _is_supervised_gateway_process()
)
pty_use_systemd_scope = (
pty_in_supervised_gateway and _systemd_run_user_scope_available()
@@ -1167,7 +1180,7 @@ class ProcessRegistry:
# cgroup (and the messaging control plane with it). This applies to
# both pipe mode and the PTY path above.
shell_argv = [user_shell, "-lic", f"set +m; {safe_command}"]
in_supervised_gateway = not _IS_WINDOWS and _is_supervised_gateway_process()
in_supervised_gateway = _IS_LINUX and _is_supervised_gateway_process()
use_systemd_scope = (
in_supervised_gateway and _systemd_run_user_scope_available()
)
@@ -1281,6 +1294,7 @@ class ProcessRegistry:
task_id: str = "",
session_key: str = "",
timeout: int = 10,
owner_task_id: str = "",
) -> ProcessSession:
"""
Spawn a background process through a non-local environment backend.
@@ -1297,6 +1311,7 @@ class ProcessRegistry:
id=f"proc_{uuid.uuid4().hex[:12]}",
command=command,
task_id=task_id,
owner_task_id=owner_task_id or task_id,
session_key=session_key,
cwd=cwd,
started_at=time.time(),
@@ -1640,6 +1655,7 @@ class ProcessRegistry:
"session_id": session.id,
"session_key": session.session_key,
"task_id": session.task_id,
"owner_task_id": session.owner_task_id or session.task_id,
"command": session.command,
"exit_code": session.exit_code,
"completion_reason": session.completion_reason,
@@ -1948,14 +1964,20 @@ class ProcessRegistry:
):
continue
# Subagent-owned process notifications (task_id "sa-...") are
# suppressed from the parent conversation by default — the
# child's consolidated delegation result is the deliverable;
# "npm ci finished" walls mid-chat are noise. Dropped, NOT
# requeued (children never drain notify events, so requeueing
# would pin them in the queue forever). Type 'async_delegation'
# is the delegation result itself and is NEVER suppressed.
_evt_task_id = str(evt.get("task_id") or "")
# Subagent-owned process notifications are suppressed from the
# parent conversation by default — the child's consolidated
# delegation result is the deliverable; "npm ci finished" walls
# mid-chat are noise. Ownership is judged on owner_task_id (the
# RAW spawning task id): the container key in task_id is
# deliberately collapsed to "default"/the session key by
# _resolve_container_task_id, which previously let child events
# bypass this gate. Dropped, NOT requeued (children never drain
# notify events, so requeueing would pin them in the queue
# forever). Type 'async_delegation' is the delegation result
# itself and is NEVER suppressed.
_evt_task_id = str(
evt.get("owner_task_id") or evt.get("task_id") or ""
)
if not is_async_delegation and _evt_task_id.startswith("sa-"):
if surface_child is None:
surface_child = self._surface_child_process_notifications()
@@ -2806,6 +2828,7 @@ class ProcessRegistry:
"cwd": s.cwd,
"started_at": s.started_at,
"task_id": s.task_id,
"owner_task_id": s.owner_task_id or s.task_id,
"session_key": s.session_key,
"watcher_platform": s.watcher_platform,
"watcher_chat_id": s.watcher_chat_id,
@@ -2896,6 +2919,7 @@ class ProcessRegistry:
id=entry["session_id"],
command=entry.get("command", "unknown"),
task_id=entry.get("task_id", ""),
owner_task_id=entry.get("owner_task_id", "") or entry.get("task_id", ""),
session_key=entry.get("session_key", ""),
pid=pid,
host_start_time=recorded_start,
@@ -2960,6 +2984,95 @@ def _format_age(seconds: float) -> str:
return f"{h}h" if m == 0 else f"{h}h{m}m"
def _model_not_found_patterns() -> "list[str]":
"""Model-not-found phrases from the failover classifier.
Imported from ``agent.error_classifier`` so the batch renderer applies
the SAME classification the failover path consumes — no hand-copied
pattern list to drift. Fails open to a minimal built-in set so a
classifier import problem never hides the per-task blocks.
(Import approach from PR #97667 by @liuhao1024.)
"""
try:
from agent.error_classifier import _MODEL_NOT_FOUND_PATTERNS
return list(_MODEL_NOT_FOUND_PATTERNS)
except Exception:
return ["is not a valid model", "model not found", "model_not_found"]
def _delegation_config() -> dict:
"""Load the active delegation config (model/provider/fallbacks), fail-open.
Mirrors ``tools.delegate_tool._load_config`` so the renderer sees the same
``model`` / ``provider`` the dispatcher used, without importing the heavy
delegation module at import time. Returns ``{}`` on any error so callers
fail open to "no notice" rather than dropping the per-task blocks.
"""
try:
from tools.delegate_tool import _load_config as _cfg
return _cfg() or {}
except Exception:
return {}
def _delegation_model_not_found(results, config) -> bool:
"""True when a result entry reflects a config-level model_not_found rejection.
Matches when at least one entry's error/summary text contains both a
model-not-found phrase AND the name of the currently-configured delegation
model — so a stale task failing on a *different* (removed) model is not
mis-attributed to the config-level root cause.
"""
model = (config or {}).get("model")
if not model:
return False
model = str(model).lower()
for r in results or []:
text = " ".join(
str(part) for part in (r.get("error"), r.get("summary")) if part
).lower()
if not text or model not in text:
continue
if any(p in text for p in _model_not_found_patterns()):
return True
return False
def _delegation_model_not_found_notice(results) -> "list[str] | None":
"""Build the config-level model_not_found notice lines, or None.
Returns ``None`` unless at least one result entry shows the configured
delegation model being rejected by its provider, in which case a short
actionable block is returned. Every failure path fails open to ``None`` so
a config hiccup never hides the per-task blocks. Emit once per batch.
"""
config = _delegation_config()
if not _delegation_model_not_found(results, config):
return None
model = config.get("model") or "?"
provider = config.get("provider") or "configured provider"
lines = [
"⚠ SUBAGENT MODEL REJECTED: the configured Subagent Model "
f'"{model}" was rejected by provider "{provider}" '
"(HTTP 400: not a valid model ID).",
"Every task in this batch failed for this reason before doing any work.",
"Check Settings → Advanced → Subagent Model (or: "
"hermes config get delegation.model).",
]
try:
from hermes_cli.fallback_config import get_fallback_chain
if not get_fallback_chain(config):
lines.append(
"No fallback chain is configured, so no failover was attempted."
)
except Exception:
pass
return lines
def _format_async_delegation(evt: dict) -> str:
"""Format an async-delegation completion into a self-contained re-injection.
@@ -3018,6 +3131,13 @@ def _format_async_delegation(evt: dict) -> str:
lines.append("--- ERROR ---")
lines.append(f"The batch did not complete successfully: {error}")
return "\n".join(lines)
# Config-level rejection notice BEFORE the per-task wall — a rejected
# delegation model fails every task identically before doing any
# work, and that signal must not stay buried in the task blocks.
_notice = _delegation_model_not_found_notice(results)
if _notice:
lines.append("")
lines.extend(_notice)
for r in sorted(results, key=lambda x: x.get("task_index", 0)):
idx = r.get("task_index", 0)
r_status = r.get("status", "?")
@@ -3085,6 +3205,10 @@ def _format_async_delegation(evt: dict) -> str:
if toolsets:
lines.append(f"Toolsets: {', '.join(toolsets)}")
lines.append(f"Role: {role} Model: {model}")
_notice = _delegation_model_not_found_notice([evt])
if _notice:
lines.append("")
lines.extend(_notice)
_trunc = " [TRUNCATED: hit max_iterations — work may be incomplete]" if truncated else ""
lines.append(f"Status: {status} API calls: {api_calls} Duration: {duration}s{_trunc}")
lines.append("--- RESULT ---")
@@ -3129,7 +3253,7 @@ def _delegation_attribution_line(evt: dict) -> "str | None":
the task_id against the live + recently-finished subagent registry and
return a short provenance line, or None for parent-owned processes.
"""
task_id = str(evt.get("task_id") or "")
task_id = str(evt.get("owner_task_id") or evt.get("task_id") or "")
if not task_id.startswith("sa-"):
return None
try: