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
8 changes: 8 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1311,6 +1311,14 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
# SQLite session store (optional -- provided by CLI or gateway)
agent._session_db = session_db
agent._parent_session_id = parent_session_id
# A close flush and the worker's turn-start flush can overlap. The durable
# marker is attached to each in-memory message dict, so its test-and-append
# sequence must be serialized per agent rather than relying on SQLite alone.
agent._session_persist_lock = threading.RLock()
# CLI retains its just-accepted user dict until turn setup can reuse it.
# This preserves the message-local durable marker if close persistence wins
# the race before the agent's normal early turn flush.
agent._pending_cli_user_message = None
agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes
agent._session_db_created = False # DB row deferred to run_conversation()
# Most agents own their session row and should finalize it on close().
Expand Down
4 changes: 2 additions & 2 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,12 +522,12 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt):

def run_conversation(
agent,
user_message: str,
user_message: Any,
system_message: str = None,
conversation_history: List[Dict[str, Any]] = None,
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
Expand Down
61 changes: 50 additions & 11 deletions agent/turn_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,12 @@ class TurnContext:

def build_turn_context(
agent,
user_message: str,
user_message: Any,
system_message: Optional[str],
conversation_history: Optional[List[Dict[str, Any]]],
task_id: Optional[str],
stream_callback,
persist_user_message: Optional[str],
persist_user_message: Optional[Any],
persist_user_timestamp: Optional[float] = None,
*,
restore_or_build_system_prompt,
Expand Down Expand Up @@ -271,6 +271,29 @@ def build_turn_context(
# Initialize conversation (copy to avoid mutating the caller's list).
messages = list(conversation_history) if conversation_history else []

# The CLI may already have staged this input outside the history passed to
# ``run_conversation``. Reuse it only when its clean transcript text matches
# this turn; a stale handoff from a failed prior turn must not replace a
# later, different user input. Voice turns compare against their explicit
# clean persistence override rather than the API-only prefixed payload.
pending_cli_message = getattr(agent, "_pending_cli_user_message", None)
expected_persist_content = (
persist_user_message if persist_user_message is not None else user_message
)
if (
isinstance(pending_cli_message, dict)
and pending_cli_message.get("content") == expected_persist_content
):
user_msg = pending_cli_message
# The CLI-staged value is the clean transcript text. Restore the
# API-facing variant (for example, a voice-mode prefix) while retaining
# the same dict and any close-path durable marker.
user_msg["content"] = user_message
else:
user_msg = {"role": "user", "content": user_message}
if isinstance(pending_cli_message, dict):
agent._pending_cli_user_message = None

# Hydrate todo store from conversation history.
if conversation_history and not agent._todo_store.has_items():
agent._hydrate_todo_store(conversation_history)
Expand All @@ -285,6 +308,13 @@ def build_turn_context(
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval

# Add the current user message after the prompt/session setup has made
# close persistence safe. The handoff above preserves any marker already
# stamped by an earlier close flush.
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx

# Track user turns for memory flush and periodic nudge logic.
agent._user_turn_count += 1
# Copilot x-initiator: the first API call of this user turn is
Expand Down Expand Up @@ -313,12 +343,6 @@ def build_turn_context(
should_review_memory = True
agent._turns_since_memory = 0

# Add user message.
user_msg = {"role": "user", "content": user_message}
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx

# Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot)
# and notify the host so it can play hearts. Token-free, never touches the
# conversation, and never fatal — a purely optional UI beat.
Expand Down Expand Up @@ -348,18 +372,33 @@ def build_turn_context(

# Create the DB session row now that _cached_system_prompt is populated, so
# the persisted snapshot is written non-NULL on the first turn (Issue
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
agent._ensure_db_session()
# #45499). Keep row creation and the marker-based append in the same
# per-agent critical section as CLI close persistence.
persist_lock = getattr(agent, "_session_persist_lock", None)

def _ensure_and_persist() -> None:
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)

# Crash-resilience: persist the inbound user turn as soon as the session row exists.
try:
agent._persist_session(messages, conversation_history)
if persist_lock is None:
_ensure_and_persist()
else:
with persist_lock:
_ensure_and_persist()
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
finally:
# Keep an unmarked staged input available to a later close retry if the
# normal persistence attempt failed. Once the marker is present, the
# close path must no longer treat it as a pre-worker UI input.
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None

# ── Preflight context compression ──
# Gate the (expensive) full token estimate behind a cheap pre-check.
Expand Down
9 changes: 9 additions & 0 deletions agent/turn_finalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@ def finalize_turn(
if _tail_role != "assistant":
messages.append({"role": "assistant", "content": final_response})

# The model has completed its request, so replace API-local
# voice/model/skill guidance with the clean user input before writing the
# final durable snapshot and returning the continuation history. Earlier
# turn-start flushes use the DB-only override because their messages are
# still needed for the API request; this finalizer runs after that request
# is complete (#48677 / #63766).
_apply_override = getattr(agent, "_apply_persist_user_message_override", None)
if callable(_apply_override):
_apply_override(messages)
agent._persist_session(messages, conversation_history)
except Exception as _persist_err:
_cleanup_errors.append(f"persist_session: {_persist_err}")
Expand Down
114 changes: 101 additions & 13 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12129,7 +12129,10 @@ def chat(self, message, images: list = None) -> Optional[str]:
request_overrides=turn_route.get("request_overrides"),
):
return None

agent = self.agent
if agent is None:
return None

# Route image attachments based on the active model's vision capability.
# "native" → pass pixels as OpenAI-style content parts (adapters
# translate for Anthropic/Gemini/Bedrock).
Expand Down Expand Up @@ -12217,8 +12220,31 @@ def chat(self, message, images: list = None) -> Optional[str]:
from run_agent import _sanitize_surrogates
message = _sanitize_surrogates(message)

# Add user message to history
self.conversation_history.append({"role": "user", "content": message})
# Keep the exact CLI input dict available until turn-start persistence.
# Copy the completed agent transcript before appending: otherwise this
# UI-only staging step mutates ``agent._session_messages`` and exposes a
# duplicate-prone intermediate snapshot to terminal-close persistence.
if self.conversation_history is getattr(agent, "_session_messages", None):
self.conversation_history = list(self.conversation_history)
# The prior turn's override applies only to its own user dict. Clear it
# before exposing the next staged input to close persistence; otherwise
# a shutdown before the worker prologue can write old API-local text as
# this new user message (#63766).
persist_lock = getattr(agent, "_session_persist_lock", None)

def _stage_user_message() -> None:
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
staged_user_message = {"role": "user", "content": message}
agent._pending_cli_user_message = staged_user_message
self.conversation_history.append(staged_user_message)

if persist_lock is None:
_stage_user_message()
else:
with persist_lock:
_stage_user_message()

ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
print(flush=True)
Expand Down Expand Up @@ -12355,13 +12381,20 @@ def run_agent():
self._pending_moa_config = None
if _moa_cfg is None:
_moa_cfg = None
# Model/skill notes and voice instructions are API-local. Keep
# the original staged input as the durable transcript value so a
# close-path marker follows the same dict into turn setup rather
# than producing a second noted user row (#63766).
_persist_clean_user_message = (
message if (_voice_prefix or agent_message != message) else None
)
try:
result = self.agent.run_conversation(
user_message=agent_message,
conversation_history=self.conversation_history[:-1], # Exclude the message we just added
stream_callback=stream_callback,
task_id=self.session_id,
persist_user_message=message if _voice_prefix else None,
persist_user_message=_persist_clean_user_message,
moa_config=_moa_cfg,
)
if getattr(self, "_pending_moa_disable_after_turn", False):
Expand Down Expand Up @@ -12824,20 +12857,75 @@ def _persist_active_session_before_close(self):
if not agent or not hasattr(agent, "_persist_session"):
return

messages = getattr(agent, "_session_messages", None)
if not isinstance(messages, list):
messages = getattr(self, "conversation_history", None)
if not isinstance(messages, list) or not messages:
return
persist_lock = getattr(agent, "_session_persist_lock", None)

def _snapshot_and_persist() -> None:
# This snapshot must share the staging lock with ``chat()``. Without
# it, close can retain a mutable history baseline just before chat
# appends its pending dict; the later flush then mistakes that dict
# for durable history and stamps it without writing a row (#63766).
messages = getattr(agent, "_session_messages", None)
pending_cli_message = getattr(agent, "_pending_cli_user_message", None)
if not isinstance(messages, list):
messages = getattr(self, "conversation_history", None)
if not isinstance(messages, list):
return
if isinstance(pending_cli_message, dict) and not any(
message is pending_cli_message for message in messages
):
# The UI has accepted a new input but the worker still exposes its
# prior snapshot. Include only that staged dict; the baseline below
# keeps any durable resumed prefix from being re-appended.
messages = [*messages, pending_cli_message]
if not messages:
return

conversation_history = getattr(self, "conversation_history", None)
if not isinstance(conversation_history, list):
conversation_history = messages
# A normal turn builds a new list that reuses the resumed-history dicts.
# Keep that CLI history as the baseline so a signal between assigning
# ``_session_messages`` and the turn's DB flush cannot append its durable
# prefix a second time. Once the CLI takes the turn result, however, both
# names can point at the same live list; passing that alias would mark an
# unflushed tail durable without writing it. Marker-only persistence is
# correct only in that alias case.
conversation_history = getattr(self, "conversation_history", None)
pending_cli_message = getattr(agent, "_pending_cli_user_message", None)
if (
isinstance(conversation_history, list)
and conversation_history
and conversation_history[-1] is pending_cli_message
):
# The UI accepted this user message before the agent finished its
# early persistence. Its dict can already be in ``messages`` but is
# not durable yet, so exclude it from the resumed-history baseline.
conversation_history = conversation_history[:-1]
elif not isinstance(conversation_history, list) or conversation_history is messages:
conversation_history = None

# A first-turn close can arrive before the worker builds its cached
# prompt. Build or restore it before the DB row is created so the
# durable transcript never leaves a NULL system_prompt cache entry.
if getattr(agent, "_cached_system_prompt", None) is None:
try:
from agent.conversation_loop import _restore_or_build_system_prompt

try:
_restore_or_build_system_prompt(agent, None, conversation_history)
except Exception:
logger.debug("Could not build system prompt during CLI close", exc_info=True)
return
if getattr(agent, "_cached_system_prompt", None) is None:
return

agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
if getattr(agent, "session_id", None):
self.session_id = agent.session_id

try:
if persist_lock is None:
_snapshot_and_persist()
else:
with persist_lock:
_snapshot_and_persist()
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not persist active CLI session before close: %s", e)

Expand Down
4 changes: 2 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -17123,7 +17123,7 @@ async def _run_agent(
event_message_id: Optional[str] = None,
channel_prompt: Optional[str] = None,
moa_config: Optional[dict] = None,
persist_user_message: Optional[str] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""Profile-scoping wrapper around the agent run.
Expand Down Expand Up @@ -17184,7 +17184,7 @@ async def _run_agent_inner(
event_message_id: Optional[str] = None,
channel_prompt: Optional[str] = None,
moa_config: Optional[dict] = None,
persist_user_message: Optional[str] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""
Expand Down
Loading
Loading