Merge origin/main into core-tool-deferral (resolve show_tip test seam onto the check_tips_enabled gate)
This commit is contained in:
+136
-12
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user