hermes(agent): preserve Codex fallback context
All checks were successful
Tests / Declarative: Post Actions passed: 228
All checks were successful
Tests / Declarative: Post Actions passed: 228
This commit is contained in:
parent
b5d778fc9b
commit
e277e43633
@ -24,7 +24,7 @@ spec:
|
|||||||
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
|
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/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/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/agent-inject: "true"
|
||||||
vault.hashicorp.com/role: hermes-agent
|
vault.hashicorp.com/role: hermes-agent
|
||||||
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
|
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
|
- /patched/codex_app_server_session.py
|
||||||
- /opt/hermes/agent/codex_runtime.py
|
- /opt/hermes/agent/codex_runtime.py
|
||||||
- /patched/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:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
runAsUser: 10000
|
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/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/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/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: 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: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
||||||
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, 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/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/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/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: 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: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
||||||
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
||||||
|
|||||||
@ -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:
|
def _replace_once(content: str, before: str, after: str, label: str) -> str:
|
||||||
"""Apply one exact replacement and fail closed when upstream drifts."""
|
"""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:
|
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 = 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.parent.mkdir(parents=True, exist_ok=True)
|
||||||
destination.write_text(
|
destination.write_text(
|
||||||
_replace_once(content, TURN_BEFORE, TURN_AFTER, "Codex turn"),
|
_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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("provider_source", type=Path)
|
parser.add_argument("provider_source", type=Path)
|
||||||
@ -146,10 +319,16 @@ def main() -> int:
|
|||||||
parser.add_argument("session_destination", type=Path)
|
parser.add_argument("session_destination", type=Path)
|
||||||
parser.add_argument("turn_source", type=Path)
|
parser.add_argument("turn_source", type=Path)
|
||||||
parser.add_argument("turn_destination", 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()
|
args = parser.parse_args()
|
||||||
patch_provider(args.provider_source, args.provider_destination)
|
patch_provider(args.provider_source, args.provider_destination)
|
||||||
patch_session(args.session_source, args.session_destination)
|
patch_session(args.session_source, args.session_destination)
|
||||||
patch_turn(args.turn_source, args.turn_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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -881,10 +881,35 @@ def test_codex_runtime_patch_uses_cli_and_forwards_route(tmp_path: Path):
|
|||||||
assert '"sandboxPolicy": {"type": "dangerFullAccess"}' in session_content
|
assert '"sandboxPolicy": {"type": "dangerFullAccess"}' in session_content
|
||||||
|
|
||||||
turn = tmp_path / "codex_runtime.py"
|
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"
|
turn_out = tmp_path / "patched/codex_runtime.py"
|
||||||
codex_runtime_patch.patch_turn(turn, turn_out)
|
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):
|
def test_codex_runtime_patch_fails_closed_on_upstream_drift(tmp_path: Path):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user