Spaces:
Running
Running
File size: 6,669 Bytes
9d0fd45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | """Shared execution context carrier for phase, LLM, and tool calls.
Execution metadata is stored durably in pipeline state under the
``execution_context`` key and exposed at runtime via a ContextVar so
LLM/tool middleware can read it without changing every call site.
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from typing import Any
from frontier_agent.core.types import new_prompt_id, new_session_id, new_step_id
@dataclass
class ExecutionScope:
"""Runtime execution scope for the current phase."""
task_id: str = ""
phase_id: str = ""
role_id: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
_CURRENT_SCOPE: ContextVar[ExecutionScope | None] = ContextVar(
"frontier_agent_execution_scope", default=None
)
# Per-tool-call contextvar — set inside each parallel ``_run_one`` task
# in ``tool_exec.execute_tools``. ``asyncio.gather`` gives each task its
# own Context copy, so concurrent tools see distinct values. Tools fired
# from inside the loop (delegate_subtask / assign_task) read this to
# stamp ``spawn_context.spawned_by_tool_call_id`` on the new sub-agent.
_CURRENT_TOOL_CALL_ID: ContextVar[str] = ContextVar(
"frontier_agent_current_tool_call_id", default=""
)
# Seconds the CURRENT tool call may run before ``execute_tools``' outer
# ``asyncio.wait_for`` cancels it. Set per ``_run_one`` task, so a tool that
# also enforces its own deadline can read the loop's configured budget instead
# of a module constant and fail with its own diagnosis just inside the outer
# wait. ``None`` means "no loop budget in scope" (a tool invoked directly by a
# script or a test), and the tool keeps its own default.
_CURRENT_TOOL_BUDGET: ContextVar[float | None] = ContextVar(
"frontier_agent_current_tool_budget", default=None
)
# Whether the current async context runs *under* an outer provider-chain
# runner (a workflow's provider-chain wrapper) that will catch an exception
# escaping ``run_agent_loop`` and rotate to the next leg. Set narrowly
# around the chain's ``attempt_fn``
# invocation, so it is True exactly while the wrapped loop runs and False
# again by the time control returns to the chain's own except handler.
_CHAIN_FALLBACK_ACTIVE: ContextVar[bool] = ContextVar(
"frontier_agent_chain_fallback_active", default=False
)
def normalize_execution_context(value: Any) -> dict[str, Any]:
"""Return a mutable execution-context dict."""
if isinstance(value, dict):
return dict(value)
return {}
def build_execution_scope(
*,
task_id: str,
phase_id: str,
role_id: str,
state: dict[str, Any] | None = None,
) -> ExecutionScope:
"""Build a scope from task/phase identity plus state metadata."""
metadata = normalize_execution_context((state or {}).get("execution_context"))
metadata.setdefault("agent_id", role_id)
return ExecutionScope(
task_id=task_id,
phase_id=phase_id,
role_id=role_id,
metadata=metadata,
)
def set_current_execution_scope(scope: ExecutionScope) -> Token:
"""Set the current execution scope for this async context."""
return _CURRENT_SCOPE.set(scope)
def get_current_execution_scope() -> ExecutionScope | None:
"""Return the current execution scope if one is active."""
return _CURRENT_SCOPE.get()
def reset_current_execution_scope(token: Token) -> None:
"""Restore the previous execution scope."""
_CURRENT_SCOPE.reset(token)
def set_current_tool_call_id(tool_call_id: str) -> Token:
"""Stash the active tool_call_id on this asyncio Task's context.
``asyncio.gather`` runs each coroutine as its own Task with a copy of
the current Context, so each parallel tool sees its own id.
"""
return _CURRENT_TOOL_CALL_ID.set(tool_call_id)
def get_current_tool_call_id() -> str:
"""Return the active tool_call_id, or ``''`` outside tool execution."""
return _CURRENT_TOOL_CALL_ID.get()
def reset_current_tool_call_id(token: Token) -> None:
"""Restore the prior tool_call_id contextvar value."""
_CURRENT_TOOL_CALL_ID.reset(token)
def set_current_tool_budget(seconds: float | None) -> Token:
"""Publish the wall-clock budget for the tool call running in this Task.
Same per-Task isolation as :func:`set_current_tool_call_id`: parallel tool
calls each get their own value.
"""
return _CURRENT_TOOL_BUDGET.set(seconds)
def get_current_tool_budget() -> float | None:
"""Seconds the active tool call may run, or ``None`` outside the loop.
A tool that enforces its own internal deadline should prefer this over a
module constant, and must stay at or under it — overshooting only trades
the tool's own structured error for the loop's bare "timed out" cancel.
"""
return _CURRENT_TOOL_BUDGET.get()
def reset_current_tool_budget(token: Token) -> None:
"""Restore the prior tool-budget contextvar value."""
_CURRENT_TOOL_BUDGET.reset(token)
def chain_fallback_active() -> bool:
"""Whether the current async context runs under an outer provider-chain
runner that will catch a surfaced exception and rotate to the next leg.
``run_agent_loop`` reads this to decide its turn-1 exhaustion policy:
when ``True`` it re-raises so the outer chain can advance; when
``False`` (benchmark single-provider, or a caller whose own chain
rotation already finished *inside* ``call_llm``) it degrades gracefully
to an ``llm_error`` stop instead of crashing the run.
"""
return _CHAIN_FALLBACK_ACTIVE.get()
@contextmanager
def chain_fallback_scope() -> Iterator[None]:
"""Mark the current async context as running under an outer chain runner.
Nesting-safe via token reset, so the L3 recursion in ``run_with_chain``
can re-enter without clobbering the outer reset.
"""
token = _CHAIN_FALLBACK_ACTIVE.set(True)
try:
yield
finally:
_CHAIN_FALLBACK_ACTIVE.reset(token)
def ensure_trace_metadata(
metadata: dict[str, Any],
*,
default_step_id: str | None = None,
refresh_prompt_id: bool = False,
) -> dict[str, Any]:
"""Ensure trace-chain identifiers exist in execution metadata."""
metadata.setdefault("session_id", str(new_session_id()))
if default_step_id:
metadata.setdefault("step_id", default_step_id)
else:
metadata.setdefault("step_id", str(new_step_id()))
if refresh_prompt_id or not metadata.get("prompt_id"):
metadata["prompt_id"] = str(new_prompt_id())
return metadata
|