hermes(agent): preserve Codex fallback context
All checks were successful
Tests / Declarative: Post Actions passed: 228

This commit is contained in:
jenkins 2026-08-10 23:19:42 -03:00
parent b5d778fc9b
commit e277e43633
3 changed files with 216 additions and 4 deletions

View File

@ -24,7 +24,7 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
ai.bstein.dev/config-rev: "20260811-codex-app-server-permissions"
ai.bstein.dev/config-rev: "20260811-codex-cross-provider-fallback"
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-agent
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
@ -253,6 +253,10 @@ spec:
- /patched/codex_app_server_session.py
- /opt/hermes/agent/codex_runtime.py
- /patched/codex_runtime.py
- /opt/hermes/agent/chat_completion_helpers.py
- /patched/chat_completion_helpers.py
- /opt/hermes/agent/conversation_loop.py
- /patched/conversation_loop.py
securityContext:
allowPrivilegeEscalation: false
runAsUser: 10000
@ -385,6 +389,8 @@ spec:
- {name: codex-runtime-patch, mountPath: /opt/hermes/hermes_cli/runtime_provider.py, subPath: runtime_provider.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/transports/codex_app_server_session.py, subPath: codex_app_server_session.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/codex_runtime.py, subPath: codex_runtime.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/chat_completion_helpers.py, subPath: chat_completion_helpers.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py}
- {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
@ -533,6 +539,8 @@ spec:
- {name: codex-runtime-patch, mountPath: /opt/hermes/hermes_cli/runtime_provider.py, subPath: runtime_provider.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/transports/codex_app_server_session.py, subPath: codex_app_server_session.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/codex_runtime.py, subPath: codex_runtime.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/chat_completion_helpers.py, subPath: chat_completion_helpers.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py}
- {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}

View File

@ -86,6 +86,143 @@ TURN_AFTER = ''' reasoning = getattr(agent, "reasoning_config", None)
)
'''
FALLBACK_RESOLUTION_BEFORE = ''' # Use centralized router for client construction.
# raw_codex=True because the main agent needs direct responses.stream()
# access for Codex providers.
try:
from agent.auxiliary_client import resolve_provider_client
'''
FALLBACK_RESOLUTION_AFTER = ''' # The Codex subscription is exposed through the authenticated local CLI,
# not as a bearer token that the OpenAI-compatible fallback resolver can
# consume. Activate that runtime directly and let conversation_loop hand
# the already-built turn context to codex app-server.
try:
if fb_provider == "openai-codex":
from hermes_cli.runtime_provider import resolve_runtime_provider
runtime = resolve_runtime_provider(
requested=fb_provider,
target_model=fb_model,
)
if runtime.get("api_mode") == "codex_app_server":
try:
from hermes_cli.model_normalize import normalize_model_for_provider
fb_model = normalize_model_for_provider(fb_model, fb_provider)
except Exception as _norm_err:
logger.warning(
"Could not normalize fallback model %r for provider %r: %s",
fb_model, fb_provider, _norm_err,
)
old_model = agent.model
agent._config_context_length = None
agent.model = fb_model
agent.provider = fb_provider
agent.base_url = str(runtime.get("base_url") or "")
agent.api_key = str(runtime.get("api_key") or "codex-cli-runtime")
agent.api_mode = "codex_app_server"
agent.client = None
agent._anthropic_client = None
agent._credential_pool = None
agent._fallback_activated = True
agent._codex_cross_provider_fallback = True
if hasattr(agent, "_transport_cache"):
agent._transport_cache.clear()
rewrite_prompt_model_identity(agent, fb_model, fb_provider)
agent._buffer_status(
f"🔄 Primary model failed — switching to fallback: "
f"{fb_model} via {fb_provider}"
)
logger.info(
"Fallback activated through Codex app-server: %s%s (%s)",
old_model, fb_model, fb_provider,
)
_reset_stale_streak(agent)
return True
from agent.auxiliary_client import resolve_provider_client
'''
FALLBACK_DISPATCH_BEFORE = ''' while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
# Reset per-turn checkpoint dedup so each iteration can take one snapshot
'''
FALLBACK_DISPATCH_AFTER = ''' while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
# A provider failure can activate the CLI-backed Codex runtime after
# this function's initial runtime dispatch. Re-enter the native Codex
# adapter here instead of attempting an OpenAI-compatible request with
# credentials that deliberately do not exist in Hermes' auth store.
if agent.api_mode == "codex_app_server":
fallback_user_message = user_message
if getattr(agent, "_codex_cross_provider_fallback", False):
from agent.codex_runtime import build_cross_provider_codex_prompt
fallback_user_message = build_cross_provider_codex_prompt(
messages,
user_message,
)
agent._codex_cross_provider_fallback = False
return agent._run_codex_app_server_turn(
user_message=fallback_user_message,
original_user_message=original_user_message,
messages=messages,
effective_task_id=effective_task_id,
should_review_memory=_should_review_memory,
)
# Reset per-turn checkpoint dedup so each iteration can take one snapshot
'''
FALLBACK_CONTEXT_BEFORE = '''def run_codex_app_server_turn(
'''
FALLBACK_CONTEXT_AFTER = '''def build_cross_provider_codex_prompt(
messages: List[Dict[str, Any]],
user_message: str,
*,
max_chars: int = 120_000,
) -> str:
"""Bridge recent user/assistant context into a new Codex fallback thread."""
def _text(value: Any) -> str:
if isinstance(value, str):
return value
if isinstance(value, list):
parts = []
for item in value:
if isinstance(item, dict):
candidate = item.get("text") or item.get("content")
if isinstance(candidate, str):
parts.append(candidate)
return "\\n".join(parts)
return ""
exchanges = []
for message in messages:
if not isinstance(message, dict):
continue
role = str(message.get("role") or "").strip().lower()
if role not in {"user", "assistant"}:
continue
content = _text(message.get("content")).strip()
if content:
exchanges.append(f"{role.upper()}: {content}")
transcript = "\\n\\n".join(exchanges)
if len(transcript) > max_chars:
transcript = "[Earlier conversation omitted for context size.]\\n\\n" + transcript[-max_chars:]
if not transcript:
transcript = f"USER: {user_message}"
return (
"Continue this Hermes conversation after the previously selected "
"provider exhausted its capacity. Preserve the objective and answer "
"the latest user request; do not restart or discard completed work.\\n\\n"
f"<hermes_conversation>\\n{transcript}\\n</hermes_conversation>"
)
def run_codex_app_server_turn(
'''
def _replace_once(content: str, before: str, after: str, label: str) -> str:
"""Apply one exact replacement and fail closed when upstream drifts."""
@ -129,8 +266,14 @@ def patch_session(source: Path, destination: Path) -> None:
def patch_turn(source: Path, destination: Path) -> None:
"""Forward live Hermes route metadata into the Codex session adapter."""
"""Forward route metadata and preserve context on Codex failover."""
content = source.read_text(encoding="utf-8")
content = _replace_once(
content,
FALLBACK_CONTEXT_BEFORE,
FALLBACK_CONTEXT_AFTER,
"Codex fallback context",
)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(
_replace_once(content, TURN_BEFORE, TURN_AFTER, "Codex turn"),
@ -138,6 +281,36 @@ def patch_turn(source: Path, destination: Path) -> None:
)
def patch_fallback(source: Path, destination: Path) -> None:
"""Activate the CLI-backed Codex runtime in Hermes' fallback chain."""
content = source.read_text(encoding="utf-8")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(
_replace_once(
content,
FALLBACK_RESOLUTION_BEFORE,
FALLBACK_RESOLUTION_AFTER,
"Codex fallback activation",
),
encoding="utf-8",
)
def patch_loop(source: Path, destination: Path) -> None:
"""Dispatch a mid-turn Codex fallback through app-server."""
content = source.read_text(encoding="utf-8")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(
_replace_once(
content,
FALLBACK_DISPATCH_BEFORE,
FALLBACK_DISPATCH_AFTER,
"Codex fallback dispatch",
),
encoding="utf-8",
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("provider_source", type=Path)
@ -146,10 +319,16 @@ def main() -> int:
parser.add_argument("session_destination", type=Path)
parser.add_argument("turn_source", type=Path)
parser.add_argument("turn_destination", type=Path)
parser.add_argument("fallback_source", type=Path)
parser.add_argument("fallback_destination", type=Path)
parser.add_argument("loop_source", type=Path)
parser.add_argument("loop_destination", type=Path)
args = parser.parse_args()
patch_provider(args.provider_source, args.provider_destination)
patch_session(args.session_source, args.session_destination)
patch_turn(args.turn_source, args.turn_destination)
patch_fallback(args.fallback_source, args.fallback_destination)
patch_loop(args.loop_source, args.loop_destination)
return 0

View File

@ -881,10 +881,35 @@ def test_codex_runtime_patch_uses_cli_and_forwards_route(tmp_path: Path):
assert '"sandboxPolicy": {"type": "dangerFullAccess"}' in session_content
turn = tmp_path / "codex_runtime.py"
turn.write_text(codex_runtime_patch.TURN_BEFORE, encoding="utf-8")
turn.write_text(
codex_runtime_patch.FALLBACK_CONTEXT_BEFORE
+ codex_runtime_patch.TURN_BEFORE,
encoding="utf-8",
)
turn_out = tmp_path / "patched/codex_runtime.py"
codex_runtime_patch.patch_turn(turn, turn_out)
assert "model=str(getattr(agent" in turn_out.read_text()
turn_content = turn_out.read_text()
assert "model=str(getattr(agent" in turn_content
assert "build_cross_provider_codex_prompt" in turn_content
fallback = tmp_path / "chat_completion_helpers.py"
fallback.write_text(
codex_runtime_patch.FALLBACK_RESOLUTION_BEFORE,
encoding="utf-8",
)
fallback_out = tmp_path / "patched/chat_completion_helpers.py"
codex_runtime_patch.patch_fallback(fallback, fallback_out)
fallback_content = fallback_out.read_text()
assert 'agent.api_mode = "codex_app_server"' in fallback_content
assert "agent._codex_cross_provider_fallback = True" in fallback_content
loop = tmp_path / "conversation_loop.py"
loop.write_text(codex_runtime_patch.FALLBACK_DISPATCH_BEFORE, encoding="utf-8")
loop_out = tmp_path / "patched/conversation_loop.py"
codex_runtime_patch.patch_loop(loop, loop_out)
loop_content = loop_out.read_text()
assert 'if agent.api_mode == "codex_app_server"' in loop_content
assert "build_cross_provider_codex_prompt" in loop_content
def test_codex_runtime_patch_fails_closed_on_upstream_drift(tmp_path: Path):