diff --git a/forks/hermes-agent b/forks/hermes-agent index 44a6808..0a278fc 160000 --- a/forks/hermes-agent +++ b/forks/hermes-agent @@ -1 +1 @@ -Subproject commit 44a6808f506b6beb00c152f045c9e7af1d556cd5 +Subproject commit 0a278fc14fb41059c0a2a6784afdd599ccb8574f diff --git a/packages/fox-overlay/MANIFEST.toml b/packages/fox-overlay/MANIFEST.toml index da43cbb..99fb6fa 100644 --- a/packages/fox-overlay/MANIFEST.toml +++ b/packages/fox-overlay/MANIFEST.toml @@ -22,4 +22,19 @@ "hostname-prompt.js" = "fox-only" [python] -# Filled in Phase 5 (additive webui modules) and Phase 6 (mid-file monkey-patches). +# Phase 3 — agent-side overlay. Three monkey-patch modules applied via +# the `hermes_agent.plugins` entry-point group. +# Phase 5 and Phase 6 add webui-side entries below. +# +# mem0_oss relocation deferred: Engineer discovered during Phase 3b PR +# review that upstream's memory-provider discovery is a separate +# directory-scan path (plugins/memory/__init__.py) with its own +# FakePluginContext exposing register_memory_provider — incompatible +# with the standard PluginContext that entry-points use. mem0_oss +# remains a bundled fork plugin until a dedicated future phase +# properly relocates memory providers. +"fox_overlay.agent_plugins" = "fox-only-additive" # register(ctx) entry-point +"fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches.runtime_provider" = "fox-only-additive" +"fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches.auxiliary_client" = "fox-only-additive" +"fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches.cron_diagnostics" = "fox-only-additive" +"fox_overlay.agent_plugins.fox_overlay_plugin.monkey_patches._helpers" = "fox-only-additive" diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py b/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py index e69de29..6134271 100644 --- a/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py +++ b/packages/fox-overlay/fox_overlay/agent_plugins/__init__.py @@ -0,0 +1,45 @@ +"""Fox overlay entry-point for hermes-agent's plugin manager. + +``fox_overlay`` is wired in ``packages/fox-overlay/pyproject.toml`` under +the ``[project.entry-points."hermes_agent.plugins"]`` group. + +``register(ctx)`` is called once per process by: + +* hermes-agent's ``PluginManager.discover_and_load`` (only from ``hermes`` + CLI subcommands), AND +* the Fox bootstrap shim added to ``forks/hermes-agent/gateway/run.py`` for + the gateway code path (upstream's gateway never calls PluginManager + directly; the shim is a Phase-3 follow-on patch — see fork PR #2). + +We apply the three agent-side monkey-patches here so the patched +behavior is in place before any runtime code path touches the patched +symbols. + +Note on mem0_oss: the original Phase 3 plan relocated mem0_oss to this +overlay as a second entry-point plugin. During Phase 3b PR review, +Engineer discovered upstream's memory-provider discovery is a separate +system (``plugins/memory/__init__.py``) with its own ``FakePluginContext`` +exposing ``register_memory_provider`` (which standard ``PluginContext`` +lacks). The relocation was reverted (fork PR #3 + companion monorepo +changes); mem0_oss remains a bundled fork plugin until a dedicated +future phase properly relocates memory providers. + +Phase 3 of the v0.6.0 upstream-separation migration (fox-in-the-box-ai/ +fox-in-the-box#155). +""" +from .fox_overlay_plugin.monkey_patches import ( + auxiliary_client, + cron_diagnostics, + runtime_provider, +) + + +def register(ctx) -> None: + """Entry point. ``ctx`` is a ``PluginContext`` from ``hermes_cli.plugins``. + + Idempotent: each ``apply()`` no-ops on its second call via per-target + sentinel attribute. + """ + runtime_provider.apply() + auxiliary_client.apply() + cron_diagnostics.apply() diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/__init__.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/__init__.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/_helpers.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/_helpers.py new file mode 100644 index 0000000..dc39681 --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/_helpers.py @@ -0,0 +1,92 @@ +"""Anchor-based source substitution helper for agent-side monkey-patches. + +Pattern: each monkey-patch module defines its target upstream function +and a list of ``(old, new)`` substitutions where ``old`` is an EXACT +fragment of the upstream function source. ``substitute_function`` reads +the upstream source via :func:`inspect.getsource`, applies the +substitutions sequentially, and recompiles the result in the upstream +module's namespace so it can be assigned back as the new function. + +If any ``old`` anchor is missing or appears more than once, an +``AssertionError`` is raised at import time — the container fails to +boot with a precise error pointing at the upstream drift. Phase 9's +nightly upstream-watch CI catches this before users do. + +Idempotency: each patch sets a per-target sentinel attribute; re-running +``apply()`` (test setup, reload, etc.) is a no-op once the sentinel is +present. +""" +import inspect +import logging +from types import ModuleType +from typing import Any, Dict, Optional, Sequence, Tuple + +_log = logging.getLogger("fox_overlay.monkey_patch") + + +def substitute_function( + upstream_module: ModuleType, + function_name: str, + substitutions: Sequence[Tuple[str, str]], + sentinel: str, + extra_globals: Optional[Dict[str, Any]] = None, +) -> None: + """Replace ``upstream_module.`` with a patched version. + + Each ``(old, new)`` substitution is applied to the upstream source + in sequence. Each ``old`` MUST appear exactly once or this raises + ``AssertionError``. + + ``extra_globals`` injects additional names into the patched + function's ``__globals__`` so the patched body can reference modules + or symbols not imported in the upstream module (e.g., a ``traceback`` + module the Fox patch wants to call but upstream doesn't import). + These names are scoped to the patched function only — they do not + pollute the upstream module's namespace. + + Note: we deliberately do NOT call ``textwrap.dedent`` on the source — + ``dedent`` normalizes whitespace-only blank lines to a bare ``\\n``, + breaking anchors that match against the verbatim file content. For + top-level functions there is no common leading indent to remove + anyway. If a future caller needs to patch a class method (where the + body is indented under the class def), it must either dedent its + anchors itself or wrap this helper. + """ + upstream_fn = getattr(upstream_module, function_name) + if getattr(upstream_fn, sentinel, False): + return + + src = inspect.getsource(upstream_fn) + + for idx, (old, new) in enumerate(substitutions): + count = src.count(old) + if count != 1: + raise AssertionError( + "[fox-overlay] monkey-patch %s.%s substitution #%d: " + "anchor expected EXACTLY ONCE, found %dx. Upstream may have " + "refactored this function — refresh the anchor in the " + "fox-overlay monkey-patch module. Anchor:\n%s" + % (upstream_module.__name__, function_name, idx + 1, count, old) + ) + src = src.replace(old, new) + + namespace = dict(upstream_module.__dict__) + if extra_globals: + namespace.update(extra_globals) + code = compile( + src, + "" % (upstream_module.__name__, function_name), + "exec", + ) + exec(code, namespace) # noqa: S102 — namespace is the upstream module's globals + + patched_fn = namespace[function_name] + setattr(patched_fn, sentinel, True) + setattr(upstream_module, function_name, patched_fn) + + _log.info( + "[fox-overlay] patched %s.%s (%d substitutions)", + upstream_module.__name__, + function_name, + len(substitutions), + ) diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/auxiliary_client.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/auxiliary_client.py new file mode 100644 index 0000000..9a19eb5 --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/auxiliary_client.py @@ -0,0 +1,80 @@ +"""Fox patches in ``agent.auxiliary_client``. + +Two patches restoring Fox edits from hermes-agent fork commits +5e0d847cb and 026a12487: + +1. ``_resolve_task_provider_model``: when the auxiliary config provider + is ``"auto"`` (or unset) but an explicit model is specified, resolve + the provider explicitly via ``_read_main_provider()`` so the model + hint actually wins. Without this, ``_resolve_auto`` ignores the + model hint and always uses the main model. + +2. ``_get_auxiliary_task_config``: fall back to ``auxiliary.default`` + when no task-specific config exists. Task-specific keys win over + default keys (``{**default_config, **task_config}``). + +Phase 0 PR campaign candidates; if upstream accepts, retire in Phase 10. +""" +from agent import auxiliary_client as _u + +from ._helpers import substitute_function + + +def apply() -> None: + # 1) _resolve_task_provider_model: insert explicit-provider branch + substitute_function( + upstream_module=_u, + function_name="_resolve_task_provider_model", + substitutions=[ + ( + ' if cfg_provider and cfg_provider != "auto":\n' + ' return cfg_provider, resolved_model, None, None, resolved_api_mode\n' + '\n' + ' return "auto", resolved_model, None, None, resolved_api_mode\n', + ' if cfg_provider and cfg_provider != "auto":\n' + ' return cfg_provider, resolved_model, None, None, resolved_api_mode\n' + '\n' + ' # provider is "auto" (or unset) but config specified an explicit model\n' + ' # (e.g. auxiliary.default.model = us.anthropic.claude-haiku-...).\n' + ' # _resolve_auto ignores the model hint and always uses the main model,\n' + ' # so we must resolve the provider explicitly here to honour cfg_model.\n' + ' if resolved_model:\n' + ' explicit_provider = _read_main_provider() or "auto"\n' + ' if explicit_provider and explicit_provider != "auto":\n' + ' return explicit_provider, resolved_model, None, None, resolved_api_mode\n' + '\n' + ' return "auto", resolved_model, None, None, resolved_api_mode\n', + ), + ], + sentinel="_fox_patched_provider_auto_fallback", + ) + + # 2) _get_auxiliary_task_config: docstring + trailing logic + substitute_function( + upstream_module=_u, + function_name="_get_auxiliary_task_config", + substitutions=[ + ( + ' """Return the config dict for auxiliary., or {} when unavailable."""\n', + ' """Return the config dict for auxiliary., or {} when unavailable.\n' + '\n' + ' Falls back to auxiliary.default when no task-specific config exists.\n' + ' Task-specific keys win over default keys ({**default_config, **task_config}).\n' + ' """\n', + ), + ( + ' aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}\n' + ' task_config = aux.get(task, {}) if isinstance(aux, dict) else {}\n' + ' return task_config if isinstance(task_config, dict) else {}\n', + ' aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}\n' + ' if not isinstance(aux, dict):\n' + ' return {}\n' + ' default_config = aux.get("default", {})\n' + ' default_config = default_config if isinstance(default_config, dict) else {}\n' + ' task_config = aux.get(task, {}) if isinstance(aux, dict) else {}\n' + ' task_config = task_config if isinstance(task_config, dict) else {}\n' + ' return {**default_config, **task_config}\n', + ), + ], + sentinel="_fox_patched_auxiliary_default_fallback", + ) diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/cron_diagnostics.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/cron_diagnostics.py new file mode 100644 index 0000000..b44fe80 --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/cron_diagnostics.py @@ -0,0 +1,220 @@ +"""Fox cron failure-diagnostics patches across 3 upstream modules. + +Restores the Fox edit from hermes-agent fork commit edb16600b that +relocated to this overlay during the v0.6.0 upstream-separation +migration (Phase 3). Five substitutions across three modules: + +* ``cron.jobs.create_job`` — add ``failure_history`` to the job dict. +* ``cron.jobs.mark_job_run`` — maintain rolling last-5 failure entries. +* ``cron.scheduler.run_job`` — enrich the FAILED output with agent + diagnostics + traceback + session-log pointer. +* ``cron.scheduler.tick`` — replace the one-line failure delivery + message with a structured multi-line failure notification. +* ``tools.cronjob_tools._format_job`` — surface ``last_error`` + + ``failure_history`` + ``consecutive_failures`` to the tool API when + the job has failures. + +The scheduler patches need ``traceback`` (not imported upstream); it's +injected into the patched function's ``__globals__`` via the helper's +``extra_globals`` mechanism — does NOT pollute upstream namespaces. + +Phase 0 PR campaign candidate; if upstream accepts the PR, this +monkey-patch becomes dead code and is removed in Phase 10 cleanup. +""" +import traceback + +from cron import jobs as _u_jobs +from cron import scheduler as _u_scheduler +from tools import cronjob_tools as _u_cronjob_tools + +from ._helpers import substitute_function + + +def apply() -> None: + # 1) cron.jobs.create_job — add failure_history to dict + substitute_function( + upstream_module=_u_jobs, + function_name="create_job", + substitutions=[ + ( + ' "last_delivery_error": None,\n', + ' "last_delivery_error": None,\n' + ' "failure_history": [], # Rolling last-5 failures: [{at, error, session_id}]\n', + ), + ], + sentinel="_fox_patched_create_job_failure_history", + ) + + # 2) cron.jobs.mark_job_run — maintain rolling failure history + substitute_function( + upstream_module=_u_jobs, + function_name="mark_job_run", + substitutions=[ + ( + ' # Track delivery failures separately — cleared on successful delivery\n' + ' job["last_delivery_error"] = delivery_error\n' + ' \n' + ' # Increment completed count\n', + ' # Track delivery failures separately — cleared on successful delivery\n' + ' job["last_delivery_error"] = delivery_error\n' + '\n' + ' # Maintain rolling failure history (last 5 entries).\n' + ' # On success, clear it so the counter resets.\n' + ' if not success:\n' + ' _entry = {"at": now, "error": (error or "unknown")[:200]}\n' + ' _hist = list(job.get("failure_history") or [])\n' + ' _hist.append(_entry)\n' + ' job["failure_history"] = _hist[-5:] # keep last 5\n' + ' else:\n' + ' job["failure_history"] = []\n' + ' \n' + ' # Increment completed count\n', + ), + ], + sentinel="_fox_patched_mark_job_run_failure_history", + ) + + # 3) cron.scheduler.run_job — enrich FAILED output with diagnostics + traceback + substitute_function( + upstream_module=_u_scheduler, + function_name="run_job", + substitutions=[ + ( + ' except Exception as e:\n' + ' error_msg = f"{type(e).__name__}: {str(e)}"\n' + ' logger.exception("Job \'%s\' failed: %s", job_name, error_msg)\n' + ' \n' + ' output = f"""# Cron Job: {job_name} (FAILED)\n' + '\n' + '**Job ID:** {job_id}\n' + '**Run Time:** {_hermes_now().strftime(\'%Y-%m-%d %H:%M:%S\')}\n' + '**Schedule:** {job.get(\'schedule_display\', \'N/A\')}\n' + '\n' + '## Prompt\n' + '\n' + '{prompt}\n' + '\n' + '## Error\n' + '\n' + '```\n' + '{error_msg}\n' + '```\n' + '"""\n' + ' return False, output, "", error_msg\n', + ' except Exception as e:\n' + ' error_msg = f"{type(e).__name__}: {str(e)}"\n' + ' logger.exception("Job \'%s\' failed: %s", job_name, error_msg)\n' + '\n' + ' # Collect agent activity diagnostics if available\n' + ' _diag_lines: list[str] = []\n' + ' if agent is not None:\n' + ' try:\n' + ' _act = agent.get_activity_summary() if hasattr(agent, "get_activity_summary") else {}\n' + ' _api_calls = _act.get("api_call_count", "?")\n' + ' _max_iter = _act.get("max_iterations", "?")\n' + ' _last_act = _act.get("last_activity_desc", "unknown")\n' + ' _idle = _act.get("seconds_since_activity", 0)\n' + ' _diag_lines.append(f"**Iterations:** {_api_calls}/{_max_iter}")\n' + ' _diag_lines.append(f"**Last activity:** {_last_act} ({int(_idle)}s ago)")\n' + ' if _act.get("current_tool"):\n' + ' _diag_lines.append(f"**Stuck on tool:** `{_act[\'current_tool\']}`")\n' + ' except Exception:\n' + ' pass\n' + '\n' + ' _tb = traceback.format_exc()\n' + ' _diag_block = ("\\n".join(_diag_lines) + "\\n\\n") if _diag_lines else ""\n' + '\n' + ' output = f"""# Cron Job: {job_name} (FAILED)\n' + '\n' + '**Job ID:** {job_id}\n' + '**Session:** `{_cron_session_id}`\n' + '**Run Time:** {_hermes_now().strftime(\'%Y-%m-%d %H:%M:%S\')}\n' + '**Schedule:** {job.get(\'schedule_display\', \'N/A\')}\n' + '\n' + '## Error\n' + '\n' + '```\n' + '{error_msg}\n' + '```\n' + '\n' + '## Agent Diagnostics\n' + '\n' + '{_diag_block}**Session log:** `~/.hermes/sessions/session_{_cron_session_id}.json`\n' + '\n' + '## Traceback\n' + '\n' + '```\n' + '{_tb}\n' + '```\n' + '\n' + '## Prompt\n' + '\n' + '{prompt}\n' + '"""\n' + ' return False, output, "", error_msg\n', + ), + ], + sentinel="_fox_patched_run_job_diagnostics", + extra_globals={"traceback": traceback}, + ) + + # 4) cron.scheduler.tick — structured multi-line failure delivery + substitute_function( + upstream_module=_u_scheduler, + function_name="tick", + substitutions=[ + ( + ' deliver_content = final_response if success else f"⚠️ Cron job \'{job.get(\'name\', job[\'id\'])}\' failed:\\n{error}"\n', + ' if success:\n' + ' deliver_content = final_response\n' + ' else:\n' + ' # Build a structured failure notification with enough\n' + ' # context to diagnose without opening logs manually.\n' + ' _fail_lines = [\n' + ' f"❌ **Cron job failed:** `{job.get(\'name\', job[\'id\'])}`",\n' + ' f"**Error:** {error}",\n' + ' ]\n' + ' # Surface consecutive failure count if available\n' + ' _history = job.get("failure_history") or []\n' + ' if len(_history) > 1:\n' + ' _fail_lines.append(f"**Consecutive failures:** {len(_history)} (first: {_history[0].get(\'at\', \'?\')[:16]})")\n' + ' # Include session log path for easy debugging\n' + ' _session_files = sorted(\n' + ' (f for f in (Path(_hermes_home) / "sessions").glob(f"session_cron_{job[\'id\']}_*.json") if f.is_file()),\n' + ' key=lambda p: p.stat().st_mtime,\n' + ' reverse=True,\n' + ' )\n' + ' if _session_files:\n' + ' _fail_lines.append(f"**Session log:** `{_session_files[0]}`")\n' + ' deliver_content = "\\n".join(_fail_lines)\n', + ), + ], + sentinel="_fox_patched_tick_structured_failure", + ) + + # 5) tools.cronjob_tools._format_job — surface failure history when present + substitute_function( + upstream_module=_u_cronjob_tools, + function_name="_format_job", + substitutions=[ + ( + ' "paused_at": job.get("paused_at"),\n' + ' "paused_reason": job.get("paused_reason"),\n' + ' }\n' + ' if job.get("script"):\n', + ' "paused_at": job.get("paused_at"),\n' + ' "paused_reason": job.get("paused_reason"),\n' + ' }\n' + ' # Surface failure history and last error only when there are failures —\n' + ' # keeps the list output clean for healthy jobs.\n' + ' if job.get("last_status") == "error" or job.get("failure_history"):\n' + ' result["last_error"] = job.get("last_error")\n' + ' _hist = job.get("failure_history") or []\n' + ' if _hist:\n' + ' result["failure_history"] = _hist\n' + ' result["consecutive_failures"] = len(_hist)\n' + ' if job.get("script"):\n', + ), + ], + sentinel="_fox_patched_format_job_failure_history", + ) diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/runtime_provider.py b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/runtime_provider.py new file mode 100644 index 0000000..b11eb96 --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/monkey_patches/runtime_provider.py @@ -0,0 +1,29 @@ +"""Fox patch: use ``target_model`` for Bedrock api_mode routing. + +Restores the Fox edit from hermes-agent fork commit f24911136 that +relocated to this overlay during the v0.6.0 upstream-separation +migration (Phase 3). Phase 0 PR campaign candidate; if upstream accepts +the PR, this monkey-patch becomes dead code and is removed in Phase 10. + +Single-line substitution inside ``hermes_cli.runtime_provider.resolve_runtime_provider`` +so the Bedrock dual-path routing honours the explicit ``target_model`` +argument instead of always falling back to the auxiliary config's +default. +""" +from hermes_cli import runtime_provider as _u + +from ._helpers import substitute_function + + +def apply() -> None: + substitute_function( + upstream_module=_u, + function_name="resolve_runtime_provider", + substitutions=[ + ( + '_current_model = str(model_cfg.get("default") or "").strip()', + '_current_model = target_model or str(model_cfg.get("default") or "").strip()', + ), + ], + sentinel="_fox_patched_target_model", + ) diff --git a/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/plugin.yaml b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/plugin.yaml new file mode 100644 index 0000000..3d2acbd --- /dev/null +++ b/packages/fox-overlay/fox_overlay/agent_plugins/fox_overlay_plugin/plugin.yaml @@ -0,0 +1,4 @@ +name: fox_overlay +version: 0.0.1 +kind: backend +description: "Fox in the Box overlay — agent-side monkey-patches that restore Fox bug fixes (target_model Bedrock fix, auxiliary.default fallback, cron failure diagnostics) on top of virgin upstream hermes-agent. v0.6.0 upstream-separation migration." diff --git a/packages/fox-overlay/pyproject.toml b/packages/fox-overlay/pyproject.toml index 45d4919..eb53de9 100644 --- a/packages/fox-overlay/pyproject.toml +++ b/packages/fox-overlay/pyproject.toml @@ -6,7 +6,11 @@ requires-python = ">=3.11" dependencies = [] [project.entry-points."hermes_agent.plugins"] -# fox_overlay = "fox_overlay:register" # commented until Phase 3 +# Discovered by hermes_cli.plugins.PluginManager.discover_and_load via +# importlib.metadata.entry_points(group="hermes_agent.plugins"). Each +# entry-point points at a `register(ctx: PluginContext) -> None` callable +# that hermes-agent invokes once per process at plugin-load time. +fox_overlay = "fox_overlay.agent_plugins:register" [build-system] requires = ["setuptools>=68", "wheel"] diff --git a/packages/fox-overlay/tests/test_agent_overlay.py b/packages/fox-overlay/tests/test_agent_overlay.py new file mode 100644 index 0000000..1dd439d --- /dev/null +++ b/packages/fox-overlay/tests/test_agent_overlay.py @@ -0,0 +1,74 @@ +"""Phase 3 monkey-patch regression tests. + +Each test verifies that the corresponding upstream symbol carries its +fox-overlay sentinel attribute after ``register()`` runs. Anchor drift +in upstream → ``substitute_function`` raises AssertionError at register +time → fixture fails → these tests fail loudly with the offending +target named in the traceback. + +Requires hermes-agent installed (so upstream modules are importable). +The container CI/QA smoke environment satisfies this; the local +development venv typically does not — those runs are skipped via +``pytest.importorskip``. + +Anti-regression Rule 3: this file is the seed of the agent-side +regression suite for Phase 3. Phases 4-6 add webui-side tests +alongside. +""" +import pytest + +# Skip the whole module if hermes-agent isn't installed (local dev without +# the full stack). Container CI/QA invokes this with hermes-agent present. +pytest.importorskip("hermes_cli.runtime_provider") +pytest.importorskip("agent.auxiliary_client") +pytest.importorskip("cron.jobs") +pytest.importorskip("cron.scheduler") +pytest.importorskip("tools.cronjob_tools") + +from agent import auxiliary_client # noqa: E402 +from cron import jobs, scheduler # noqa: E402 +from hermes_cli import runtime_provider # noqa: E402 +from tools import cronjob_tools # noqa: E402 + +from fox_overlay.agent_plugins import register # noqa: E402 + + +SENTINELS = [ + (runtime_provider, "resolve_runtime_provider", "_fox_patched_target_model"), + (auxiliary_client, "_resolve_task_provider_model", "_fox_patched_provider_auto_fallback"), + (auxiliary_client, "_get_auxiliary_task_config", "_fox_patched_auxiliary_default_fallback"), + (jobs, "create_job", "_fox_patched_create_job_failure_history"), + (jobs, "mark_job_run", "_fox_patched_mark_job_run_failure_history"), + (scheduler, "run_job", "_fox_patched_run_job_diagnostics"), + (scheduler, "tick", "_fox_patched_tick_structured_failure"), + (cronjob_tools, "_format_job", "_fox_patched_format_job_failure_history"), +] + + +@pytest.fixture(scope="module", autouse=True) +def _apply_patches(): + """Invoke fox-overlay register() once for the whole module run.""" + register(None) + + +@pytest.mark.parametrize( + "module, function_name, sentinel", + SENTINELS, + ids=[f"{m.__name__}.{fn}" for m, fn, _ in SENTINELS], +) +def test_monkey_patch_sentinel_set(module, function_name, sentinel): + """Each monkey-patch sets its sentinel attribute on the upstream symbol.""" + fn = getattr(module, function_name) + assert getattr(fn, sentinel, False) is True, ( + f"{module.__name__}.{function_name} missing {sentinel} sentinel — " + "monkey-patch did not apply (anchor may have drifted in upstream)" + ) + + +def test_register_is_idempotent(): + """Repeat register() calls are safe — sentinel short-circuits each apply().""" + register(None) + register(None) + for module, function_name, sentinel in SENTINELS: + fn = getattr(module, function_name) + assert getattr(fn, sentinel, False) is True diff --git a/packages/integration/Dockerfile b/packages/integration/Dockerfile index d45881b..371f5b9 100644 --- a/packages/integration/Dockerfile +++ b/packages/integration/Dockerfile @@ -138,16 +138,20 @@ RUN set -eux; \ fi; \ chown -R foxinthebox:foxinthebox /app/hermes-agent /app/hermes-webui -# ── Fox overlay: static files (Phase 2 of v0.6.0 migration) ─────────────────── -# webui_static/ holds Fox-only assets relocated out of the fork's static/ tree. -# Upstream's api/extensions.py serves these under /extensions/ and injects the -# CSS/JS tags into index.html at request time from the ENV-configured URL lists. -# Cap is 32 URLs per env var; comma-separated. Order preserved from the pre-Phase-2 -# fork index.html script tags. +# ── Fox overlay: install package + serve static via api/extensions.py ───────── +# Phase 3 of v0.6.0 migration: COPY the whole overlay (Python modules + static +# webui assets) and pip install -e it. The pip install registers fox-overlay +# for import — the hermes_agent.plugins entry-points in fox-overlay's +# pyproject.toml wire into hermes-agent's PluginManager so register(ctx) fires +# at agent startup (applies the 3 monkey-patches: target_model fix, auxiliary +# default fallback, cron failure diagnostics). The same COPY makes +# webui_static/ available for the Phase-2 HERMES_WEBUI_EXTENSION_DIR-based +# static-asset serving. # -# Placed AFTER `pip install` so static-asset edits (frequent during Phase 7 UI -# work) do not invalidate the slow pip install layer cache. -COPY packages/fox-overlay/webui_static /app/fox-overlay/webui_static +# Placed AFTER the upstream pip install so static-asset OR overlay-code edits +# don't invalidate the (slow) upstream pip install layer cache. +COPY packages/fox-overlay /app/fox-overlay +RUN pip install --no-cache-dir -e /app/fox-overlay ENV HERMES_WEBUI_EXTENSION_DIR=/app/fox-overlay/webui_static \ HERMES_WEBUI_EXTENSION_SCRIPT_URLS=/extensions/onboarding-preview.js,/extensions/fox-overlay.js,/extensions/hostname-prompt.js,/extensions/fallback-polish.js \ HERMES_WEBUI_EXTENSION_STYLESHEET_URLS=/extensions/fox-in-the-box.css