Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/fox-overlay/MANIFEST.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
45 changes: 45 additions & 0 deletions packages/fox-overlay/fox_overlay/agent_plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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.<function_name>`` 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,
"<fox-overlay %s.%s>" % (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),
)
Original file line number Diff line number Diff line change
@@ -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.<task>, or {} when unavailable."""\n',
' """Return the config dict for auxiliary.<task>, 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",
)
Loading
Loading