refactor(agent/turn_context): per-turn reset as a data table, _str_attr route reads, compact TurnContext field docs

This commit is contained in:
Teknium
2026-09-02 18:35:16 -07:00
parent e7d4b2123e
commit d61bafcbd1
2 changed files with 60 additions and 75 deletions
+58 -73
View File
@@ -28,6 +28,11 @@ from agent.model_metadata import (
logger = logging.getLogger(__name__)
def _str_attr(agent: Any, name: str) -> str:
"""``getattr(agent, name, "") or ""`` — route facts read off partial agents/doubles."""
return getattr(agent, name, "") or ""
def _preflight_request_tokens(
agent: Any, messages: List[Dict[str, Any]], system_prompt: str
) -> int:
@@ -64,8 +69,7 @@ def _agent_stale_thinking_on_wire(agent: Any) -> bool:
from agent.message_sanitization import stale_thinking_reaches_wire
return stale_thinking_reaches_wire(
getattr(agent, "api_mode", "") or "", getattr(agent, "provider", "") or "",
getattr(agent, "model", "") or "", getattr(agent, "base_url", "") or "",
*(_str_attr(agent, k) for k in ("api_mode", "provider", "model", "base_url"))
)
except Exception:
return True
@@ -167,8 +171,9 @@ def _maybe_title_session_at_turn_start(agent: Any, messages: List[Any]) -> None:
return
# Snapshot runtime identity so the background titler can skip if the user
# switches models before it fires.
_model = getattr(agent, "model", None)
_provider = getattr(agent, "provider", None)
main_runtime = {
k: getattr(agent, k, None) for k in ("model", "provider", "base_url", "api_key", "api_mode")
}
maybe_auto_title(
session_db,
session_id,
@@ -178,17 +183,11 @@ def _maybe_title_session_at_turn_start(agent: Any, messages: List[Any]) -> None:
getattr(agent, "_title_failure_callback", None)
or getattr(agent, "_emit_auxiliary_failure", None)
),
main_runtime={
"model": _model,
"provider": _provider,
"base_url": getattr(agent, "base_url", None),
"api_key": getattr(agent, "api_key", None),
"api_mode": getattr(agent, "api_mode", None),
},
main_runtime=main_runtime,
title_callback=getattr(agent, "_on_session_title", None),
runtime_validator=lambda: (
getattr(agent, "model", None) == _model
and getattr(agent, "provider", None) == _provider
getattr(agent, "model", None) == main_runtime["model"]
and getattr(agent, "provider", None) == main_runtime["provider"]
),
)
except Exception:
@@ -304,29 +303,18 @@ def _should_idle_compact(
class TurnContext:
"""Values produced by the turn prologue and consumed by the turn loop."""
# Sanitized inbound message (surrogates stripped).
user_message: str
# Clean message preserved for transcripts / memory queries (no nudge injection).
original_user_message: Any
# Working message list for this turn (loop appends to it).
messages: List[Dict[str, Any]]
# May be reset to None by preflight compression (new session created).
conversation_history: Optional[List[Dict[str, Any]]]
# Cached system prompt active for this turn (may be rebuilt by compression).
active_system_prompt: Optional[str]
# Task / turn identifiers.
user_message: str # sanitized inbound message (surrogates stripped)
original_user_message: Any # clean text for transcripts / memory queries (no nudges)
messages: List[Dict[str, Any]] # working list for this turn (loop appends to it)
conversation_history: Optional[List[Dict[str, Any]]] # None after rotation
active_system_prompt: Optional[str] # may be rebuilt by compression
effective_task_id: str
turn_id: str
# Index of the current user turn within ``messages``.
current_turn_user_idx: int
# Whether the post-turn memory review should fire.
should_review_memory: bool = False
# Context contributed by ``pre_llm_call`` plugins (appended to user message).
plugin_user_context: str = ""
# External-memory prefetch result, reused across loop iterations.
ext_prefetch_cache: str = ""
# Turn-start preflight already proved an immediate retry ineffective.
preflight_compression_blocked: bool = False
current_turn_user_idx: int # index of the current user turn within ``messages``
should_review_memory: bool = False # post-turn memory review should fire
plugin_user_context: str = "" # ``pre_llm_call`` context (appended to user message)
ext_prefetch_cache: str = "" # external-memory prefetch, reused across iterations
preflight_compression_blocked: bool = False # immediate retry proved ineffective
def _persist_under_lock(agent: Any, fn, failure_msg: str, pending_cli_message: Any) -> None:
@@ -358,13 +346,11 @@ def _publish_runtime_main(agent: Any) -> None:
# session uses the physical id until build_api_kwargs re-resolves.
_cache_scope = resolve_prompt_cache_scope_safe(agent) or ""
set_runtime_main(
getattr(agent, "provider", "") or "", getattr(agent, "model", "") or "",
requested_provider=getattr(agent, "requested_provider", "") or "",
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
auth_mode=getattr(agent, "auth_mode", "") or "",
session_id=getattr(agent, "session_id", "") or "", cache_scope=_cache_scope,
_str_attr(agent, "provider"), _str_attr(agent, "model"),
**{k: _str_attr(agent, k) for k in (
"requested_provider", "base_url", "api_key", "api_mode", "auth_mode", "session_id"
)},
cache_scope=_cache_scope,
)
except Exception:
pass
@@ -412,26 +398,30 @@ def _bind_turn_identity(
return effective_task_id, turn_id
# Per-turn agent state reset at turn start (retry counters, guardrail halt, file-mutation
# verifier). ``_turns_since_memory`` / ``_iters_since_skill`` are deliberately NOT reset.
_PER_TURN_RESET_STATE: Tuple[Tuple[str, Any], ...] = (
("_invalid_tool_retries", 0), ("_invalid_json_retries", 0), ("_empty_content_retries", 0),
("_incomplete_scratchpad_retries", 0), ("_codex_incomplete_retries", 0),
("_thinking_prefill_retries", 0), ("_post_tool_empty_retried", False),
("_last_content_with_tools", None), ("_last_content_tools_all_housekeeping", False),
("_mute_post_response", False), ("_unicode_sanitization_passes", 0),
("_tool_guardrail_halt_decision", None), ("_vision_supported", True),
("_run_budget_wrapup_injected", False), ("_verification_stop_nudges", 0),
("_pre_verify_nudges", 0),
)
def _reset_per_turn_agent_state(agent: Any) -> None:
"""Reset retry counters, guardrails, iteration and run budgets at turn start.
``_turns_since_memory`` / ``_iters_since_skill`` are deliberately NOT reset."""
agent._invalid_tool_retries = 0
agent._invalid_json_retries = 0
agent._empty_content_retries = 0
agent._incomplete_scratchpad_retries = 0
agent._codex_incomplete_retries = 0
agent._thinking_prefill_retries = 0
agent._post_tool_empty_retried = False
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
agent._unicode_sanitization_passes = 0
"""Reset retry counters, guardrails, iteration and run budgets at turn start."""
for name, value in _PER_TURN_RESET_STATE:
setattr(agent, name, value)
agent._turn_failed_file_mutations = {}
agent._turn_file_mutation_paths = set()
agent._tool_guardrails.reset_for_turn()
agent._tool_guardrail_halt_decision = None
_reset_consol = getattr(agent._memory_store, "reset_consolidation_failures", None)
if callable(_reset_consol):
_reset_consol()
agent._vision_supported = True
# Pre-turn connection health check: clean up dead TCP connections.
if agent.api_mode != "anthropic_messages":
@@ -450,20 +440,10 @@ def _reset_per_turn_agent_state(agent: Any) -> None:
agent._compression_warning = None # send once
agent.iteration_budget = IterationBudget(agent.max_iterations)
# Wall-clock run budget: stamped only when configured; the wrap-up latch resets per
# turn (one notice per run).
# Wall-clock run budget: stamped only when configured (one wrap-up notice per run).
agent._run_budget_started_at = (
time.time() if getattr(agent, "run_budget_seconds", None) else None
)
agent._run_budget_wrapup_injected = False
# Per-turn file-mutation verifier state.
agent._turn_failed_file_mutations = {}
agent._turn_file_mutation_paths = set()
agent._verification_stop_nudges = 0
agent._pre_verify_nudges = 0
# Reset the streaming context / think scrubbers at the top of each turn.
for name in ("_stream_context_scrubber", "_stream_think_scrubber"):
scrubber = getattr(agent, name, None)
@@ -519,6 +499,7 @@ def _hydrate_from_history(agent: Any, conversation_history: Optional[List[Any]])
return
if not agent._todo_store.has_items():
agent._hydrate_todo_store(conversation_history)
# Hydrate per-session nudge counters from persisted history.
if agent._user_turn_count == 0:
prior_user_turns = sum(1 for m in conversation_history if m.get("role") == "user")
if prior_user_turns > 0:
@@ -587,7 +568,6 @@ def _collect_pre_llm_call_context(
parent_session_id=getattr(agent, "_parent_session_id", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
)
_ctx_parts: list[str] = []
try:
from tools.hook_output_spill import (
get_spill_config as _spill_cfg, spill_if_oversized as _spill_if_oversized
@@ -596,6 +576,7 @@ def _collect_pre_llm_call_context(
except Exception:
_spill_if_oversized = None # type: ignore[assignment]
_spill_config_cached = None
_ctx_parts: list[str] = []
for r in _pre_results:
if isinstance(r, dict) and r.get("context"):
_piece = str(r["context"])
@@ -612,8 +593,7 @@ def _collect_pre_llm_call_context(
except Exception as _spill_exc:
logger.warning("hook context spill failed: %s", _spill_exc)
_ctx_parts.append(_piece)
if _ctx_parts:
return "\n\n".join(_ctx_parts)
return "\n\n".join(_ctx_parts)
except Exception as exc:
logger.warning("pre_llm_call hook failed: %s", exc)
return ""
@@ -637,7 +617,9 @@ def _merge_gateway_notes(
if isinstance(_gw_turn_content, list):
append_notes_to_multimodal_content(_gw_turn_content, _gateway_notes)
return plugin_user_context
return plugin_user_context + "\n\n" + _gateway_notes if plugin_user_context else _gateway_notes
return (
plugin_user_context + "\n\n" + _gateway_notes if plugin_user_context else _gateway_notes
)
def _bind_interrupt_scope(agent: Any, ra) -> None:
@@ -780,7 +762,7 @@ def build_turn_context(
_reset_per_turn_agent_state(agent)
_preview_text = summarize_user_message_for_log(user_message)
_msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text
_msg_preview = _preview_text[:80] + ("..." if len(_preview_text) > 80 else "")
logger.info(
"conversation turn: session=%s model=%s provider=%s platform=%s history=%d msg=%r",
agent.session_id or "none", agent.model, agent.provider or "unknown",
@@ -946,7 +928,10 @@ def build_api_messages(
)
if _composed is not None:
api_msg["content"] = _composed
elif isinstance(_api_content, str) and _api_content and msg.get("role") in ("user", "assistant"):
elif (
isinstance(_api_content, str) and _api_content
and msg.get("role") in ("user", "assistant")
):
# Historical row: replay the exact bytes sent live so the prompt-cache
# prefix stays byte-stable. User rows carry the injection sidecar; user
# and assistant rows may carry a sanitize-divergence sidecar.
+2 -2
View File
@@ -37,7 +37,7 @@ class CompactionOutcome:
blocked: bool = False
# ── Helpers shared by every compression-attempt site ──────────────────────────
# ── Helpers shared by every compression-attempt site ──
def _clear_overflow_warn(agent: Any) -> None:
@@ -100,7 +100,7 @@ def _reanchor(agent: Any, messages: List[Any], user_message: Any) -> int:
return idx
# ── Turn-start passes ─────────────────────────────────────────────────────────
# ── Turn-start passes ──
def run_turn_start_compaction(