diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index c7426fe4..a68a4777 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -28,6 +28,7 @@ from backend.apps.agents.manager.RunSupport import RunSupport from backend.apps.agents.manager.run.handle_run_error import handle_run_error from backend.apps.agents.manager.run.TurnRunner import TurnRunner from backend.apps.agents.manager.run.RunOptions import RunOptions +from backend.apps.agents.manager.ttft_probe import ttft_probe logger = logging.getLogger(__name__) @@ -51,13 +52,15 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr session = self.sessions.get(session_id) if not session: return - + ttft_probe(session_id, "loop_start", fork=fork_session, model=session.model, msgs=len(session.messages)) + from backend.apps.agents.providers.registry import get_api_type as p_get_api_type p_api = p_get_api_type(session.model) prompt_content = self.build_prompt_content( prompt, images, context_paths, forced_tools, attached_skills, api_type=p_api, model=session.model, ) + ttft_probe(session_id, "prompt_built") try: # SDK presence check: fall to mock mode here, before the options build, so a missing SDK is a clean mock run, not an error card. The real use is in run_options / turn_runner (lazy-imported there). @@ -88,6 +91,7 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr session, session_id, prompt, prompt_content, builtin_perms, selected_browser_ids, selected_app_output_ids, selected_setting_ids, fork_session, p_router_model_id, p_api_type_for_session) + ttft_probe(session_id, "options_built") resolved_model = p_router_model_id api_type = p_api_type_for_session diff --git a/backend/apps/agents/manager/run/TurnRunner.py b/backend/apps/agents/manager/run/TurnRunner.py index fe8029db..8a979c02 100644 --- a/backend/apps/agents/manager/run/TurnRunner.py +++ b/backend/apps/agents/manager/run/TurnRunner.py @@ -16,6 +16,7 @@ from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState from backend.apps.agents.manager.streaming.handle_stream_event import handle_stream_event from backend.apps.agents.manager.streaming.handle_assistant_message import handle_assistant_message from backend.apps.agents.manager.streaming.handle_result_message import handle_result_message +from backend.apps.agents.manager.ttft_probe import ttft_probe from backend.apps.agents.manager.streaming import thinking as thinking_mod from backend.apps.settings.models import AppSettings @@ -35,6 +36,7 @@ class TurnRunner(AgentManagerProtocol): global_settings: AppSettings) -> None: from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, ResultMessage from claude_agent_sdk.types import StreamEvent, SystemMessage + ttft_probe(session_id, "query_enter") async def prompt_stream(): yield { @@ -88,6 +90,7 @@ class TurnRunner(AgentManagerProtocol): logger.exception("pre-emit thinking pill failed; continuing") if turn.first_event: + ttft_probe(session_id, "first_event", type=type(message).__name__) logger.info(f"[MCP-DEBUG] First event received: {type(message).__name__}") turn.first_event = False diff --git a/backend/apps/agents/manager/ttft_probe.py b/backend/apps/agents/manager/ttft_probe.py new file mode 100644 index 00000000..3472d453 --- /dev/null +++ b/backend/apps/agents/manager/ttft_probe.py @@ -0,0 +1,25 @@ +"""Temporary time-to-first-token phase probe for the send->first-token A/B sweep. A no-op unless +OSW_TTFT_PROBE=1, so it never spams a normal run. Strip once the persistent-client work lands.""" + +import logging +import os +import time + +from typeguard import typechecked + +logger = logging.getLogger(__name__) + +P_TTFT_ENABLED = os.environ.get("OSW_TTFT_PROBE") == "1" + + +@typechecked +def ttft_probe(session_id: str, phase: str, **extra: object) -> None: + """One monotonic phase stamp for the TTFT breakdown; the A/B parser reads `phase= mono=`. + A no-op unless OSW_TTFT_PROBE=1, and it swallows any error so instrumentation can NEVER break a turn.""" + if not P_TTFT_ENABLED: + return + try: + tail = " ".join(f"{k}={v}" for k, v in extra.items()) + logger.warning(f"[TTFT] sid={session_id} phase={phase} mono={time.monotonic():.4f} {tail}".rstrip()) + except Exception: + pass