"""TurnEngine — the owned agent loop. Async, but with blocking provider/tool calls wrapped in `asyncio.to_thread` so the loop (and any UI consuming its events) stays responsive. One user turn spans many model↔tool iterations until the model stops requesting tools, a rail trips, or it's interrupted. When the model requests several tool calls in one turn, low-risk ones (reads, searches) execute concurrently; writes/shell stay strictly ordered. Approvals are handled out-of-band via an injected async `approver`: when the permission engine says `needs_user`, the engine emits `PERMISSION_REQUIRED` and awaits the approver. """ from __future__ import annotations import asyncio import json import time from dataclasses import dataclass, replace from enum import Enum from typing import Any, AsyncIterator, Awaitable, Callable, Optional from . import compaction as _compaction from . import provenance from . import session_facts from . import toolchain as _toolchain from .events import Event, EventType # §8.4 retry guard: the reviewer pauses for the rest of the turn after this many denials # IN A ROW (2→5 + streak semantics, owner ruling 2026-08-24 — a cumulative 2 silently # downgraded long agentic turns to hand-approval after one over-strict pair). _REVIEWER_TRIP = 5 _REVIEWER_PAUSED_TEXT = ( "Auto-approve is paused for the rest of this turn — the reviewer blocked " f"{_REVIEWER_TRIP} actions in a row, so approvals now come to you." ) from .permissions import Mode, PermissionEngine from .providers import AssistantTurn, ProviderClient, ToolCall from .providers.errors import friendly_model_error from .providers.openai_provider import looks_like_unparsed_tool_call from .tools import ToolRegistry class ApprovalOutcome(str, Enum): ONCE = "once" ALWAYS_TOOL = "always_tool" ALWAYS_COMMAND = "always_command" ALWAYS_DOMAIN = "always_domain" # Session-wide grant for classifier-approved read-only shell commands (readonly.py). READONLY_SESSION = "readonly_session" # OPE-136 durable trust: persist a per-tool "don't ask" rule for an MCP tool — # survives sessions, revocable on the server's detail page. MCP-only (validated # server-side in manager._grant_offered, like every other grant). ALWAYS_TRUST = "always_trust" # OPE-136 run grant ("Allow for this request"): cover this exact tool for the # remainder of the CURRENT run only — in-memory, cleared at the run boundary, # nothing persisted. EXTERNAL-risk tools only (validated server-side). THIS_RUN = "this_run" DENY = "deny" def _readonly_ok(arguments: dict) -> bool: command = str((arguments or {}).get("command", "") or "") if not command: return False from .readonly import is_readonly_command return is_readonly_command(command) @dataclass class PermissionRequest: tool_name: str arguments: dict[str, Any] metadata: Any reason: str tool_call_id: Optional[str] = None # for durable resume (idempotent inbox item) # Where an MCP call actually goes ({transport, host}, from the server DEF at # registration) — carried on the request so a PARKED approval shows the same # destination evidence as the live card (§35 parity). None for non-MCP tools. mcp_destination: Optional[dict] = None Approver = Callable[[PermissionRequest], Awaitable[ApprovalOutcome]] async def _deny_all(_request: PermissionRequest) -> ApprovalOutcome: return ApprovalOutcome.DENY class TurnEngine: def __init__( self, *, provider: ProviderClient, registry: ToolRegistry, permissions: PermissionEngine, model: str, instructions: Optional[str] = None, approver: Optional[Approver] = None, max_iterations: int = 12, model_settings: Optional[dict[str, Any]] = None, messages: Optional[list[dict[str, Any]]] = None, audit_sink: Optional[Callable[[dict[str, Any]], None]] = None, context_provider: Optional[Callable[[], str]] = None, directory_requester: Optional[ Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] ] = None, plan_approver: Optional[ Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] ] = None, question_asker: Optional[ Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] ] = None, tool_requester: Optional[ Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] ] = None, team_approver: Optional[ Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] ] = None, items_approver: Optional[ Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] ] = None, # Called (thread-safe, best-effort) when the user stops the turn — e.g. the # executor's kill for a running shell command. interrupt_hooks: Optional[list[Callable[[], None]]] = None, ) -> None: self.provider = provider self.registry = registry self.permissions = permissions self.model = model self.approver = approver or _deny_all self.max_iterations = max_iterations self.model_settings = dict(model_settings or {}) self.messages: list[dict[str, Any]] = list(messages or []) self.audit_sink = audit_sink # Returns an ephemeral `` block appended to the LAST user message at # send-time only (never persisted). We can't reliably inject system messages mid-thread # across providers, so dynamic per-turn context (e.g. the live directory list) rides on # the latest user turn. Returns "" when there's nothing to add. self.context_provider = context_provider # Handles the `request_directory` tool: emits a DIRECTORY_REQUESTED prompt, waits for the # user to grant/decline a folder out-of-band, applies the grant to this live session, and # returns the outcome. None on surfaces that can't prompt (the tool then no-ops). self.directory_requester = directory_requester # Handles the `request_tool` tool: emits TOOL_REQUESTED, waits for the user to install # the pinned build or decline. None on surfaces that can't prompt (the tool then # no-ops, and the agent is told so it can fall back openly rather than skip silently). self.tool_requester = tool_requester # Handles the `propose_plan` tool: emits PLAN_PROPOSED, waits for the user's decision. # An approving result flips the live PermissionEngine out of plan mode (same session, # context kept). None on surfaces that can't prompt (the tool then no-ops). self.plan_approver = plan_approver # Handles the `propose_team` tool (the staffing gate): emits TEAM_PROPOSED, waits # for the user's decision; approval pre-spawns the worker sessions and the result # carries the roster (actor ids). None on surfaces that can't prompt. self.team_approver = team_approver # Handles `propose_work_items` (the decomposition gate): emits ITEMS_PROPOSED, # waits; approval creates the items on the board. Mode-independent by design — # unlike propose_plan it carries no permission-mode semantics: propose_plan is # an IMPLEMENTATION plan (steps/files, plan-mode exit); this is a team # decomposition onto the board. self.items_approver = items_approver # Handles the `ask_user` tool: turns a question into an Inbox item and waits for the answer # (answerable inline in a live session or from the Inbox when unattended). None on surfaces # that can't ask (the tool then no-ops). self.question_asker = question_asker # Auto-compaction (OPE-27) — set post-construction by the surface/manager so the # constructor footprint stays put. `compaction_settings` is a live getter (Settings # changes apply without a rebuild); `is_attended` gates the failure prompt (None → # treat as unattended: never park a background run on internal bookkeeping). self.compaction_state: Optional[_compaction.CompactionState] = None self.compaction_settings: Optional[Callable[[], dict[str, Any]]] = None self.is_attended: Optional[Callable[[], bool]] = None # Session facts (spec Part 0 / §2.4) — the known world frozen at session start, plus # the per-turn ingestion record. Set post-construction by the surface, same as # compaction above, so the constructor footprint stays put. None ⇒ nothing recorded # and behaviour is byte-identical; NOTHING consumes it in v1 either way. self.session_facts: Optional[session_facts.SessionFacts] = None # Auto-Approve reviewer (spec Part 8). Set post-construction; None ⇒ Mode.AUTO_APPROVE # behaves exactly like INTERACTIVE. Consulted only on decisions the gate marked # needs_user, only in AUTO_APPROVE mode, only when the session is attended (an # unset is_attended counts as NOT attended here — automations never set it), and # only until _REVIEWER_TRIP denials IN A ROW (§8.4 retry guard). Consecutive, not # cumulative: an allow/unsure verdict or an ask_user answer resets the streak — # the owner-hit 2026-08-24 was a 2-denial cumulative trip silently downgrading a # long agentic turn to hand-approval for everything after one over-strict pair. self.reviewer: Optional[Any] = None self._reviewer_denials = 0 self._reviewer_verdicts: dict[str, Any] = {} # (c) How each consequential call got cleared, keyed by tool_call id: # {"origin": "reviewer"|"bypass"|"user", "note": , "grant": # }. Consumed by _record_result into the TOOL_FINISHED event AND # into the tool message's `_display` sidecar, so the quiet provenance chips # survive reload (owner ruling 2026-08-24) — display-only, never provider-visible. self._approval_origins: dict[str, dict[str, str]] = {} # Shadow evaluation (spec Part 6 step 3): when True and a reviewer is attached, the # reviewer records what it WOULD have decided on each approval card while the human # still decides. Fire-and-forget — the card is never delayed, no decision is ever # touched, and the verdict lands in the audit log (stage="reviewer_shadow", joined # to the human's approval_resolved row by call_id). self.reviewer_shadow = False self._shadow_tasks: set[asyncio.Task] = set() # One-shot "Allow anyway" grants (§8.4): minted ONLY by a human clicking the deny # card, keyed on the exact tool + canonical arguments, consumed on first match. A # re-proposal with even slightly different arguments does not match and goes back # through the reviewer/card — deliberately narrow, deliberately not standing. self._allow_anyway: set[tuple[str, str]] = set() # ask_user answers for the reviewer's history (§8.2 — the missing third of the # reply-tag feature: render_history prints the tag and the §8.3 instructions say to # weigh it lower; this is the extractor that finally delivers the data). Captured at # the moment the asker returns — the one point where the engine KNOWS the text came # from the human, whichever authenticated surface answered (inline card, Inbox, or a # bound channel; the same trust approval clicks already carry). ANSWERS ONLY, never # the agent's question: agent-authored text stays out of the judge's view — showing # the question too is step 2, evidence-gated on shadow data. Each entry is # (anchor, text) where anchor = how many user messages existed at capture, so the # merge in `_user_history` stays chronological. Runtime-only on purpose: a restart # costs the reviewer context (more cards), never correctness. self._ask_replies: list[tuple[int, str, str]] = [] # (anchor, answer, question) # Extra user-facing fields for a tool's approval card, merged into the # PERMISSION_REQUIRED payload — e.g. web_search's live provider name, so the card # can say where queries actually go (§1.9). Set post-construction by the surface # (the engine itself knows nothing about providers); None ⇒ no extras. Called at # card time, not session start, so a mid-session Settings change shows through. self.approval_extras: Optional[ Callable[[str, dict[str, Any]], dict[str, Any]] ] = None # What the agent itself created this session (OPE-114 §1). The reviewer never sees # file contents, so `python scripts/setup.py` is unjudgeable from its text — but the # engine knows whether it wrote or downloaded that file moments ago, and says so on # the card and in the reviewer's request. Runtime-only, like `_ask_replies`: a # restart costs context (more cards), never correctness. self._agent_files = provenance.SessionFiles(permissions.workspace_root) # Completed tool calls so far, so a fact can say how many steps back the write was. self._step = 0 self._last_context_tokens: Optional[int] = None self.audit_context: dict[str, Any] = {} if instructions and not ( self.messages and self.messages[0].get("role") == "system" ): self.messages.insert(0, {"role": "system", "content": instructions}) self._cancel = asyncio.Event() # Whether the latest assistant turn hit the output-token limit — decides which # diagnosis a mangled (unparseable-args) tool call gets answered with. self._turn_truncated = False # Each pending steering message: (text, optional MessageSource sidecar dict). self._steering: list[tuple[str, Optional[dict[str, Any]]]] = [] # tool_call.id → the standing rule that auto-allowed it ("tool → target"), so the # TOOL_FINISHED event can carry the note to the tool card (§25). self._standing_notes: dict[str, str] = {} self._interrupt_hooks: list[Callable[[], None]] = list(interrupt_hooks or []) # -- external controls ------------------------------------------------------ def request_interrupt(self) -> None: """Stop the turn as soon as possible, from ANY state: mid-stream (the producer thread drops the stream between chunks), mid-tool (interrupt hooks kill the running command), awaiting an approval/question/plan (the await resolves as interrupted), or between iterations (the loop checkpoint). Every pending tool_call still gets a tool-error result so the history never carries orphans (hosted templates reject them, and durable-resume would re-prompt them).""" self._cancel.set() for hook in self._interrupt_hooks: try: hook() except Exception: pass # best-effort: a dead executor must not block the stop async def _interruptible(self, coro: Any, interrupted: Any) -> Any: """Await `coro`, but resolve early with `interrupted` if the user stops the turn. The pending task is cancelled so an answered-later Inbox card no-ops.""" task = asyncio.ensure_future(coro) cancel_wait = asyncio.ensure_future(self._cancel.wait()) try: done, _ = await asyncio.wait( {task, cancel_wait}, return_when=asyncio.FIRST_COMPLETED ) if task in done: return task.result() task.cancel() return interrupted finally: cancel_wait.cancel() def queue_steering( self, text: str, source: Optional[dict[str, Any]] = None ) -> None: self._steering.append((text, source)) # -- main loop -------------------------------------------------------------- async def run( self, user_input: "str | list", *, source: Optional[dict[str, Any]] = None, display: Optional[str] = None, ) -> AsyncIterator[Event]: # `user_input` is a string, or OpenAI content-parts (text + image_url) for attachments. # `source` (a MessageSource dict) is a display-only sidecar for connector messages: it # rides on the persisted user message + the TURN_START event, but is stripped before the # message reaches a provider (see `_outbound_messages`). `content` stays the framed text. # `display` is the same split for force-run skills (SKILLS-SPEC §4.1 #3): the user's # literal "/skill …" line for the transcript, while `content` carries the model-facing # framing. `ts` (unix seconds, stamped on every appended message) is the same kind of # sidecar. message: dict[str, Any] = { "role": "user", "content": user_input, "ts": time.time(), } if source is not None: message["source"] = source if display is not None: message["_display"] = display self.messages.append(message) self._cancel.clear() if self.session_facts is not None: self.session_facts.begin_turn() # §8.4 retry guard resets per user turn: two reviewer denials in one turn route # everything else that turn to the human. A fresh user message is a fresh brief. self._reviewer_denials = 0 self._reviewer_verdicts.clear() data: dict[str, Any] = {"input": user_input} if source is not None: data["source"] = source if display is not None: data["display"] = display # OPE-136 run grants: a fresh run starts with a clean slate (belt — the # finally below is the braces; an abandoned generator must not leak a # previous answer's "Allow for this request" into this one). self.permissions.clear_run_allowances() yield Event(EventType.TURN_START, data) try: async for event in self._loop(): yield event finally: # The run boundary IS the grant's expiry — normal finish, Stop, and # generator teardown (disconnect) all land here. self.permissions.clear_run_allowances() def switch_model(self, model: str) -> Optional[str]: """Rebind the session's model mid-conversation (roadmap item 3). History is canonical OpenAI shape and every provider converts per call, so the switch is just the field write — plus a persisted notice marking WHERE it happened, with a degradation warning when history carries images the new model can't see (those are sent as placeholders — see `_outbound_messages`). Returns the notice text, or None when nothing changed (same model, or first bind on a fresh session).""" if not model or model == self.model: return None had_history = any(m.get("role") != "system" for m in self.messages) self.model = model # The reviewer judges with the session's own model (§1.5: "if it's trusted to # drive the agent, it's strong enough to review it"). Bound once at session build, # it would otherwise keep the OLD model for the rest of the session after a # switch — silently reviewing with a model the user moved away from. if self.reviewer is not None: self.reviewer.model = model if not had_history: return None from .providers.matrix import model_labels text = f"Model switched to {model_labels().get(model, model)}" try: caps = self.provider.capabilities(model) except Exception: caps = None if ( caps is not None and not getattr(caps, "vision", False) and self._history_has_images() ): text += " — earlier images can't be read by this model" self._append_notice("model_switch", text) return text def _history_has_images(self) -> bool: return any( isinstance(p, dict) and p.get("type") == "image_url" for msg in self.messages if isinstance(msg.get("content"), list) for p in msg["content"] ) def _tail_is_retriable_error(self) -> bool: """True when the history tail is an error notice, looking through any model_switch notices appended after it (a switch must not consume the retry).""" for message in reversed(self.messages): if message.get("role") != "notice": return False if message.get("kind") == "model_switch": continue return message.get("kind") == "error" return False def _append_notice(self, kind: str, text: Optional[str] = None, **fields: Any) -> None: """Persist a turn-ending marker (error/interrupted) as a display-only `notice` message: it survives reload like the transcript does, but `_outbound_messages` drops the role so no provider ever sees it. Extra `fields` (e.g. the failing MCP server's name) persist on the message for structured rendering.""" notice: dict[str, Any] = {"role": "notice", "kind": kind, "ts": time.time()} if text: notice["text"] = text notice.update({k: v for k, v in fields.items() if v is not None}) self.messages.append(notice) async def retry(self) -> AsyncIterator[Event]: """Re-run the model loop after a provider error — no new user message; the failed turn's input is already the tail of history. Guarded on the tail being an error notice so a stray retry frame can't re-answer a completed turn. Trailing model_switch notices don't break the guard — switching models and THEN retrying is the intended recovery path (owner-hit 2026-07-23).""" if not self._tail_is_retriable_error(): return self._cancel.clear() yield Event(EventType.TURN_START, {"input": ""}) async for event in self._loop(): yield event async def resume(self) -> AsyncIterator[Event]: """Continue a turn that was suspended at a prompt and persisted — durable resume after a restart (or engine eviction). Re-process the trailing assistant message's UNANSWERED tool-calls (the prompt callbacks find the already-resolved Inbox item and return without re-prompting; answered calls are skipped, so nothing double-executes), then run the model loop to finish the turn.""" pending = self._unanswered_trailing_tool_calls() if not pending: return self._cancel.clear() yield Event(EventType.TURN_START, {"input": "(resumed)"}) async for event in self._handle_tool_calls(pending): yield event yield Event(EventType.ITERATION_END, {"iteration": 0}) if not self._cancel.is_set(): async for event in self._loop(): yield event def _unanswered_trailing_tool_calls(self) -> list[ToolCall]: """The tool-calls of the last assistant message that don't yet have a tool result — i.e. the prompt we suspended on (+ any after it). Reconstructed from the persisted thread. """ answered = { m.get("tool_call_id") for m in self.messages if m.get("role") == "tool" } for msg in reversed(self.messages): if msg.get("role") == "user": return [] if msg.get("role") == "assistant" and msg.get("tool_calls"): out: list[ToolCall] = [] for tc in msg["tool_calls"]: if tc.get("id") in answered: continue fn = tc.get("function") or {} try: args = json.loads(fn.get("arguments") or "{}") except Exception: args = {} out.append( ToolCall(id=tc.get("id"), name=fn.get("name"), arguments=args) ) return out return [] async def _loop(self) -> AsyncIterator[Event]: iterations = 0 while True: if iterations >= self.max_iterations: yield Event( EventType.TURN_END, {"status": "max_iterations_exceeded", "iterations": iterations}, ) return iterations += 1 # Auto-compaction checkpoint (OPE-27): between tool turns and before a new # turn's first call. Deliberately no "wrap up" warning to the model. The # COMPACTING signal precedes the (multi-second) summarizer call so surfaces # can show progress instead of a silent stall. notice = None if self._compaction_due(): yield Event(EventType.COMPACTING, {}) notice = await self._compact_now() if notice: self._append_notice("compacted", notice) yield Event(EventType.COMPACTED, {"text": notice}) turn: Optional[AssistantTurn] = None streamed: list[str] = [] streamed_reasoning: list[str] = [] def _partial_turn() -> AssistantTurn: # What the user watched arrive — text and thinking, NO tool calls (any # half-formed calls would either orphan or execute against the stop). return AssistantTurn( text="".join(streamed) or None, reasoning="".join(streamed_reasoning) or None, ) try: async for chunk in self._astream(): if chunk.reasoning_delta: streamed_reasoning.append(chunk.reasoning_delta) yield Event( EventType.REASONING_DELTA, {"text": chunk.reasoning_delta} ) if chunk.text_delta: streamed.append(chunk.text_delta) yield Event( EventType.ASSISTANT_DELTA, {"text": chunk.text_delta} ) if chunk.turn is not None: turn = chunk.turn except Exception as exc: # provider failure # A raw context-overflow 400 (compaction mispredicted, e.g. the estimate # path) routes into the compaction policy instead of surfacing. The retry # is progress-guarded: each pass moves the boundary forward or gives up, # so a model that keeps overflowing still terminates in the error path. if _compaction.is_context_overflow(exc) and not self._cancel.is_set(): yield Event(EventType.COMPACTING, {}) notice = await self._compact_now(force=True) if notice: self._append_notice("compacted", notice) yield Event(EventType.COMPACTED, {"text": notice}) continue # Same contract as the stop path below: the partial the user watched # arrive survives the failure. if streamed or streamed_reasoning: self.messages.append(_assistant_message(_partial_turn())) friendly = friendly_model_error(self.model, exc) payload = { "error": friendly or str(exc), "error_type": type(exc).__name__, } if friendly: payload["raw"] = str(exc) self._append_notice("error", friendly or str(exc)) yield Event(EventType.ERROR, payload) return if self._cancel.is_set() and turn is None: # Stopped mid-stream: persist exactly what the user watched arrive. if streamed or streamed_reasoning: self.messages.append(_assistant_message(_partial_turn())) self._append_notice("interrupted") yield Event(EventType.INTERRUPTED, {"iterations": iterations}) return if turn is None: turn = AssistantTurn() if turn.usage is not None: # The trigger signal: the prompt-side total that actually occupied the # window on this round-trip (estimate fallback when never reported). self._last_context_tokens = turn.usage.context_tokens self._turn_truncated = turn.finish_reason == "length" _sanitize_mangled_calls(turn) self.messages.append(_assistant_message(turn, model=self.model)) payload: dict[str, Any] = { "text": turn.text, "tool_calls": [tc.name for tc in turn.tool_calls], } if turn.reasoning: payload["reasoning"] = turn.reasoning if turn.usage is not None: payload["usage"] = {"model": self.model, **turn.usage.as_dict()} yield Event(EventType.ASSISTANT_MESSAGE, payload) if not turn.tool_calls: if self._steering: self._inject_steering() continue # The model tried to call a tool and the syntax never parsed — salvage already # had its go. Ending as "completed" here would present a half-written call as # the answer, which is indistinguishable from the model deciding it was done; # the user just sees narration trailing off into stray tags. Fail loudly # instead, on the error path so the GUI offers Retry — this is drift, not a # deterministic failure, so retrying the same model usually works. if looks_like_unparsed_tool_call(turn.text, self.registry.schemas() or None): message = ( f"{self.model} replied with a tool call this endpoint couldn't parse, " "so the turn was stopped rather than answered from a partial call. " "Retry, or switch to a larger model — smaller local models drift off " "the tool-call format, especially with many tools in play." ) self._append_notice("error", message) yield Event( EventType.ERROR, {"error": message, "error_type": "UnparsedToolCall"}, ) return yield Event( EventType.TURN_END, {"status": "completed", "iterations": iterations}, ) return async for event in self._handle_tool_calls(turn.tool_calls): yield event yield Event(EventType.ITERATION_END, {"iteration": iterations}) if self._cancel.is_set(): self._append_notice("interrupted") yield Event(EventType.INTERRUPTED, {"iterations": iterations}) return if self._steering: self._inject_steering() # -- auto-compaction (OPE-27) ------------------------------------------------ def _compaction_config(self) -> dict[str, Any]: cfg = dict(self.compaction_settings() or {}) if self.compaction_settings else {} if not cfg.get("context_window"): from .providers.matrix import model_context_windows cfg["context_window"] = model_context_windows().get(self.model) cfg.setdefault("threshold_pct", _compaction.DEFAULT_THRESHOLD_PCT) cfg.setdefault("cap_tokens", _compaction.DEFAULT_CAP_TOKENS) return cfg def _compaction_due(self) -> bool: """The trigger check alone — cheap and side-effect free, so the loop can emit the COMPACTING signal before committing to the (slow) summarizer call.""" cfg = self._compaction_config() if cfg.get("enabled") is False: return False signal = self._last_context_tokens or _compaction.estimate_tokens( self._outbound_messages() ) return _compaction.should_compact( signal, cfg.get("context_window"), threshold_pct=float(cfg["threshold_pct"]), cap_tokens=int(cfg["cap_tokens"]), ) async def _compact_now(self, *, force: bool = False) -> Optional[str]: """Run the compaction policy. Callers gate on `_compaction_due()` (or `force`, the overflow path). Returns the user-facing notice text when the outbound view changed, else None. Failure policy per spec: retry once (both modes); attended → Retry / Trim prompt; unattended → auto-trim and continue (never park a run on bookkeeping).""" cfg = self._compaction_config() pct = float(cfg["threshold_pct"]) cap = int(cfg["cap_tokens"]) window = cfg.get("context_window") keep = int( _compaction.KEEP_RECENT_FRACTION * _compaction.trigger_tokens(window, threshold_pct=pct, cap_tokens=cap) ) model = str(cfg.get("model") or "") or self.model def _build() -> Optional[_compaction.CompactionState]: return _compaction.build_state( self.messages, provider=self.provider, model=model, keep_tokens=keep, prior=self.compaction_state, ) state: Optional[_compaction.CompactionState] = None failed = False for _attempt in range(2): # first try + the unconditional single retry try: state = await asyncio.to_thread(_build) failed = False break except Exception: failed = True if failed and self.question_asker is not None and self.is_attended and self.is_attended(): while True: answer = await self._interruptible( self.question_asker( { "question": ( "Context compaction failed — the summarizer couldn't " "condense this session's history. How should I proceed?" ), "options": ["Retry", "Trim oldest 10%"], "allow_text": False, "header": "Compaction", }, None, ), interrupted=None, ) if not answer or answer.get("answer") != "Retry": break try: state = await asyncio.to_thread(_build) failed = False break except Exception: continue if state is not None: self.compaction_state = state self._last_context_tokens = None # stale once the outbound view shrank return "Context compacted — earlier turns were summarized" if failed or force: trimmed = _compaction.trim_state(self.messages, prior=self.compaction_state) if trimmed is not None: self.compaction_state = trimmed self._last_context_tokens = None return "Context trimmed — oldest turns dropped (summary unavailable)" return None # -- helpers ---------------------------------------------------------------- async def _astream(self): """Bridge the provider's blocking stream generator to the async loop via a thread + queue, so text deltas surface live without blocking the event loop.""" loop = asyncio.get_running_loop() queue: asyncio.Queue = asyncio.Queue() tools = self.registry.schemas() or None model, messages, settings = ( self.model, self._outbound_messages(), self.model_settings, ) provider = self.provider def produce(): try: for chunk in provider.stream( model=model, messages=messages, tools=tools, **settings ): # User pressed Stop: drop the stream between chunks (reading the # asyncio.Event's flag from a thread is safe; we only read). if self._cancel.is_set(): break loop.call_soon_threadsafe(queue.put_nowait, ("chunk", chunk)) except Exception as exc: # surfaced to the awaiting consumer loop.call_soon_threadsafe(queue.put_nowait, ("error", exc)) finally: loop.call_soon_threadsafe(queue.put_nowait, ("done", None)) loop.run_in_executor(None, produce) while True: # Race the queue against Stop so a stalled stream (no chunks arriving — # the pre-first-token wait, a wedged connection) can't hold the turn. get_task = asyncio.ensure_future(queue.get()) cancel_task = asyncio.ensure_future(self._cancel.wait()) done, _ = await asyncio.wait( {get_task, cancel_task}, return_when=asyncio.FIRST_COMPLETED ) cancel_task.cancel() if get_task not in done: get_task.cancel() return # interrupted — the producer exits on its own next chunk kind, payload = get_task.result() if kind == "chunk": yield payload elif kind == "error": raise payload else: return async def _handle_tool_calls( self, tool_calls: list[ToolCall] ) -> AsyncIterator[Event]: """Run one assistant turn's tool calls: authorize all of them first (sequentially — approval prompts are interactive), then execute. Low-risk calls (reads, searches) run concurrently; everything else runs one at a time in call order.""" # Auto-Approve: fire the reviewer for every call that will need it, all at once, # BEFORE the sequential authorize loop (spec §8.6 — one action per request, sent # concurrently; the wall-clock cost of reviewing N calls is one round-trip, and a # verdict physically cannot land on the wrong action). The loop below stays # sequential because approval cards are interactive and must reach the human one # at a time, in call order. await self._preconsult_reviewer(tool_calls) cleared: list[ToolCall] = [] for tool_call in tool_calls: if self._cancel.is_set(): # Stopped: every remaining call still gets an answer (no orphans). yield self._interrupted_tool(tool_call) continue yield Event( EventType.TOOL_PROPOSED, {"name": tool_call.name, "arguments": tool_call.arguments}, ) self._audit(tool_call, stage="proposed") if _is_mangled(tool_call): # The arguments never parsed as JSON (a `{"_raw": …}` fallback from the # provider). Executing would produce a bare parameter error the model # misreads — seen in the field as an endless "wrong parameter" retry # loop. Answer with the ACTUAL diagnosis instead. yield self._mangled_tool(tool_call) continue # `request_directory` and `propose_plan` are interactive: the user decides # out-of-band and that decision IS the consent, so they skip the # permission/registry path. if tool_call.name == "request_directory": async for event in self._handle_directory_request(tool_call): yield event continue if tool_call.name == "request_tool": async for event in self._handle_tool_request(tool_call): yield event continue if tool_call.name == "propose_plan": async for event in self._handle_plan_proposal(tool_call): yield event continue if tool_call.name == "propose_team": async for event in self._handle_team_proposal(tool_call): yield event continue if tool_call.name == "propose_work_items": async for event in self._handle_items_proposal(tool_call): yield event continue if tool_call.name == "ask_user": async for event in self._handle_ask_user(tool_call): yield event continue allowed = False async for item in self._authorize(tool_call): if isinstance(item, Event): yield item else: allowed = item if allowed: cleared.append(tool_call) concurrent = ( [tc for tc in cleared if self._parallel_safe(tc)] if len(cleared) > 1 else [] ) serial = [tc for tc in cleared if tc not in concurrent] if concurrent: for tool_call in concurrent: yield Event(EventType.TOOL_STARTED, {"name": tool_call.name}) self._audit(tool_call, stage="started") outcomes = await asyncio.gather( *[asyncio.to_thread(self._execute_sync, tc) for tc in concurrent] ) for tool_call, (result, status) in zip(concurrent, outcomes): yield self._record_result(tool_call, result, status) for tool_call in serial: if self._cancel.is_set(): yield self._interrupted_tool(tool_call) continue yield Event(EventType.TOOL_STARTED, {"name": tool_call.name}) self._audit(tool_call, stage="started") result, status = await asyncio.to_thread(self._execute_sync, tool_call) yield self._record_result(tool_call, result, status) def _mangled_tool(self, tool_call: ToolCall) -> Event: """Answer a tool call whose arguments never parsed, with the real diagnosis. Two causes, two different cures — and the model can only pick the right one if the error says which happened. Truncation (`finish_reason == "length"`) means "same content, smaller pieces"; plain bad JSON means "re-send with the declared parameters". Either way the raw text is NOT replayed into history: a stored `{"_raw": …}` call reads as a worked example and teaches the model to emit `_raw` on purpose (observed 2026-08-15), on top of re-sending the junk tokens every turn.""" if self._turn_truncated: reason = ( "your tool-call arguments were cut off by the output-token limit before " "they finished streaming — the tool never received them. Produce the same " "content in smaller pieces: several calls that each write or append a " "section, keeping each call's content well under the limit. Do not retry " "the identical oversized call." ) else: reason = ( "your tool-call arguments did not parse as a JSON object, so the tool " "received nothing. `_raw` is not a parameter — it is the unparsed text of " "the failed call. Re-issue the call using the tool's declared parameters." ) self.messages.append(_tool_error_message(tool_call, reason)) self._audit(tool_call, stage="finished", status="error", reason=reason) return Event( EventType.TOOL_FINISHED, {"name": tool_call.name, "status": "error", "reason": reason}, ) def _interrupted_tool(self, tool_call: ToolCall) -> Event: """The stop-path answer for a call that will not run: a tool-error result in the history (hosted chat templates reject orphaned tool_calls, and durable-resume would otherwise re-prompt it) + the finished event for the tool card.""" self.messages.append(_tool_error_message(tool_call, "interrupted by user")) self._audit( tool_call, stage="finished", status="interrupted", reason="user stop" ) return Event( EventType.TOOL_FINISHED, {"name": tool_call.name, "status": "interrupted", "reason": "stopped"}, ) def _parallel_safe(self, tool_call: ToolCall) -> bool: # Only metadata-declared low-risk tools (reads, searches, git queries) run # concurrently; writes, shell, and anything unannotated stay strictly ordered. spec = self.registry.get(tool_call.name) metadata = spec.metadata if spec else None return getattr(metadata, "risk_level", "") == "low" and not getattr( metadata, "requires_approval", False ) # -- Auto-Approve reviewer (spec Part 8) ---------------------------------------- def _reviewer_active(self) -> bool: """The reviewer is consulted only when ALL of these hold. Any miss ⇒ today's behaviour (the card). Attended is required explicitly: `is_attended` unset counts as NOT attended, so automations — which never set it — can never be reviewed (§1.5: the mode is attended-only).""" from .permissions import Mode return ( self.reviewer is not None and self.permissions.mode is Mode.AUTO_APPROVE and self.is_attended is not None and self.is_attended() and self._reviewer_denials < _REVIEWER_TRIP ) def _user_history(self) -> tuple[str, list[dict[str, Any]]]: """(current request, earlier user messages) — the user's own words only, extracted mechanically (§8.2). Never agent output, never tool results, never a summary. `ask_user` answers are merged in from `_ask_replies` (captured as they arrived, not parsed out of tool envelopes), tagged `is_reply` so `render_history` prints the "[reply to a question the agent asked]" marker the §8.3 instructions already know how to weigh. A reply is always HISTORY, never the current request — "ok proceed" must not become the headline the action is judged against. Attachments collapse to neutral markers via `reviewer_text` (§4.4): the reviewer learns a file was attached, never what it says — an attachment body is outside-authored text riding a user turn.""" from .attachments import reviewer_text texts: list[str] = [] for msg in self.messages: if msg.get("role") != "user": continue text = reviewer_text(msg.get("content")) if text: texts.append(text) if not texts: return "", [ {"text": t, "is_reply": True, **({"question": q} if q else {})} for _, t, q in self._ask_replies ] history: list[dict[str, Any]] = [] for i, t in enumerate(texts[:-1], start=1): history.append({"text": t}) history.extend( {"text": r, "is_reply": True, **({"question": q} if q else {})} for a, r, q in self._ask_replies if a == i ) # Replies captured during the current turn (anchor == len(texts)) — or after an # anchor message that was itself empty/skipped — land at the tail, so a same-turn # consent is already visible to the reviewer for the very next action. history.extend( {"text": r, "is_reply": True, **({"question": q} if q else {})} for a, r, q in self._ask_replies if a >= len(texts) ) return texts[-1], history def _downloaded_target(self, tool_call: ToolCall) -> Optional[Any]: """A file this call would run that the agent DOWNLOADED this session, or None. Fetch-then-execute has no quiet legitimate form, so it reaches a person over both the reviewer and any command allowlist (OPE-114 §1).""" match = self._agent_files.match( tool_call.name, tool_call.arguments, step=self._step ) return match if match is not None and match.downloaded else None def _provenance(self, tool_call: ToolCall) -> str: """One line naming a file this call would run that the agent itself created, or "" (§8.2). Fixed vocabulary — never file contents, never outside-authored text, so the no-untrusted-content rule holds.""" match = self._agent_files.match( tool_call.name, tool_call.arguments, step=self._step ) return match.render() if match else "" async def _preconsult_reviewer(self, tool_calls: list[ToolCall]) -> None: """Fire one reviewer request per call that will escalate, all concurrently, and park the verdicts for `_authorize` to consume. One action per request — there is no verdict list to pair back, so a verdict cannot land on the wrong action (§8.6). Skips calls the gate already decides (allow or hard-deny): the reviewer only ever sees what would otherwise become an approval card (§1.2).""" if not self._reviewer_active() or not tool_calls: return interactive = {"request_directory", "propose_plan", "ask_user"} pending: list[ToolCall] = [] for tool_call in tool_calls: if tool_call.name in interactive or tool_call.id in self._reviewer_verdicts: continue spec = self.registry.get(tool_call.name) if spec is None: continue decision = self.permissions.evaluate( tool_call.name, tool_call.arguments, spec.metadata ) # human_only asks never reach the reviewer — same rule as `_authorize`. if ( not decision.allowed and decision.needs_user and not decision.human_only and self._downloaded_target(tool_call) is None ): pending.append(tool_call) if not pending: return request, history = self._user_history() verdicts = await asyncio.gather( *[ self.reviewer.review( request=request, history=history, tool_name=tc.name, arguments=tc.arguments, provenance=self._provenance(tc), ) for tc in pending ] ) for tc, verdict in zip(pending, verdicts): self._reviewer_verdicts[tc.id] = verdict async def _consult_reviewer(self, tool_call: ToolCall) -> Any: """The parked verdict from `_preconsult_reviewer`, or a fresh single call.""" verdict = self._reviewer_verdicts.pop(tool_call.id, None) if verdict is not None: return verdict request, history = self._user_history() return await self.reviewer.review( request=request, history=history, tool_name=tool_call.name, arguments=tool_call.arguments, provenance=self._provenance(tool_call), ) @staticmethod def _action_key(tool_name: str, arguments: dict[str, Any] | None) -> tuple[str, str]: try: canon = json.dumps(arguments or {}, sort_keys=True, ensure_ascii=False) except (TypeError, ValueError): canon = str(arguments) return (tool_name, canon) def approve_action_once(self, tool_name: str, arguments: dict[str, Any] | None) -> None: """Register a one-shot human approval for this EXACT action (§8.4 "Allow anyway"). Called by the server when the user clicks the deny card — a human decision made with the full reviewer reason in front of them. The next proposal of the identical action (same tool, byte-identical canonical arguments) runs without the reviewer or a card; anything that differs at all still goes through the normal flow. Never standing: consumed on first use.""" self._allow_anyway.add(self._action_key(tool_name, arguments)) if self.audit_sink is not None: try: self.audit_sink( { **self.audit_context, "tool": tool_name, "arguments": arguments or {}, "stage": "allow_anyway_granted", "status": "granted", "reason": "user approved via the deny card (one-shot, exact action)", } ) except Exception: pass def _consume_allow_anyway(self, tool_call: ToolCall) -> bool: key = self._action_key(tool_call.name, tool_call.arguments) if key in self._allow_anyway: self._allow_anyway.discard(key) return True return False def _spawn_shadow_review(self, tool_call: ToolCall) -> None: """Shadow evaluation (spec Part 6 step 3): record what the reviewer WOULD have decided about this card, without touching anything. Fire-and-forget — the card renders immediately; the verdict lands in the audit log when the call returns, joined to the human's `approval_resolved` row by `call_id`. There is deliberately no code path from a shadow verdict to a decision.""" if self.reviewer is None or not self.reviewer_shadow: return request, history = self._user_history() prov = self._provenance(tool_call) async def _shadow() -> None: try: verdict = await self.reviewer.review( request=request, history=history, provenance=prov, tool_name=tool_call.name, arguments=tool_call.arguments, ) self._audit( tool_call, stage="reviewer_shadow", status=verdict.verdict, reason=verdict.reason, call_id=tool_call.id, tokens_in=verdict.tokens_in, tokens_out=verdict.tokens_out, cache_read=verdict.cache_read, cache_write=verdict.cache_write, ) except Exception: pass # shadow must never surface a failure task = asyncio.create_task(_shadow()) self._shadow_tasks.add(task) task.add_done_callback(self._shadow_tasks.discard) async def drain_shadow_reviews(self) -> None: """Await in-flight shadow verdicts (tests and orderly shutdown; never the hot path).""" if self._shadow_tasks: await asyncio.gather(*list(self._shadow_tasks), return_exceptions=True) async def _authorize(self, tool_call: ToolCall) -> "AsyncIterator[Event | bool]": """Permission flow for one call (TOOL_PROPOSED is emitted by the caller). Yields its events, then True/False (allowed) last. Denied/unknown calls get their tool-error message appended here.""" from .permissions import standing_rule_candidate spec = self.registry.get(tool_call.name) metadata = spec.metadata if spec else None decision = self.permissions.evaluate( tool_call.name, tool_call.arguments, metadata ) allowed = decision.allowed reason = decision.reason # OPE-114 §1: running something the agent DOWNLOADED this session is the classic # fetch-then-execute chain, and there is no quiet legitimate version of it — so it # goes to a person, over both the reviewer and any command allowlist that would # otherwise wave it through (a `python` prefix rule must not vouch for a script # pulled off the internet a moment ago). A hard deny is left untouched: this floor # only ever tightens an allow, never loosens a block. Agent-WRITTEN files are not # floored — "write this script and run it" is ordinary work — they travel as a fact # for the reviewer to weigh instead. provenance_note = self._provenance(tool_call) if self._downloaded_target(tool_call) is not None and ( decision.needs_user or allowed ): allowed = False reason = f"this file was downloaded by the agent this session — {provenance_note}" decision = replace( decision, allowed=False, reason=reason, needs_user=True, human_only=True, ) if allowed and decision.rule: # A task-scoped standing rule auto-allowed this call: audit the exact rule # (§25 invariant — every auto-allowed call cites its rule) and remember it so # the tool card can say "allowed by standing rule". self._standing_notes[tool_call.id] = decision.rule self._audit( tool_call, stage="auto_allowed", status="allowed", reason=reason ) # (c) Bypass mode ran a consequential call no other rule allowed: annotate it. # "full access" is the exact reason string of permissions.py's bypass branch. if allowed and decision.reason == "full access": self._approval_origins[tool_call.id] = {"origin": "bypass"} # OPE-136: a trusted-MCP allow ran cardless on standing config (a user trust # rule, or the legacy server flag) — audited and chip-annotated like every # other cardless origin ("recorded, never invisible"). Prefix-matched against # permissions.py's two trusted-branch reason strings — and the chip keeps the # two apart: "your trust rule" points at the tool page's Revoke, "server # trust" at the mcp.json flag. One generic label made a user believe the # SERVER had marked their own rule (owner-hit 2026-08-30). if allowed and decision.reason.startswith("trusted MCP tool"): origin = ( "trusted_rule" if "user trust rule" in decision.reason else "trusted_server" ) self._approval_origins[tool_call.id] = {"origin": origin} self._audit( tool_call, stage="auto_allowed", status="allowed", reason=reason ) # OPE-136 run grant: a covered call ran cardless under the user's in-run # "Allow for this request" click — silent to attention, never invisible to # the record (transcript chip + audit row, like every cardless origin). if allowed and decision.reason == "tool allowed for this request": self._approval_origins[tool_call.id] = {"origin": "run_grant"} self._audit( tool_call, stage="auto_allowed", status="allowed", reason=reason ) if not allowed and decision.needs_user and self._consume_allow_anyway(tool_call): # §8.4 "Allow anyway": the human already approved this exact action from the # deny card. One-shot — consumed above; a different action never matches. allowed = True reason = "approved by user (allow anyway)" self._audit(tool_call, stage="auto_allowed", status="allowed", reason=reason) consulted_live = False unsure_note = "" # the reviewer's hesitation, when an unsure verdict raised the card if ( not allowed and decision.needs_user and not decision.human_only and self._reviewer_active() ): # The one thing the reviewer may do: turn "ask the human" into "go ahead" — # never "blocked" into "go ahead" (§1.2; hard denies never reach this branch # because needs_user is False on them). `human_only` asks (git hooks, CI # configs, unscopable writes) skip the reviewer entirely: their floor is that # a PERSON sees them, and a verdict here would be that floor's bypass. consulted_live = True verdict = await self._consult_reviewer(tool_call) self._audit( tool_call, stage="reviewer_verdict", status=verdict.verdict, reason=verdict.reason, tokens_in=verdict.tokens_in, tokens_out=verdict.tokens_out, cache_read=verdict.cache_read, cache_write=verdict.cache_write, ) if verdict.verdict == "allow": allowed = True self._reviewer_denials = 0 # streak semantics: any non-deny resets self._approval_origins[tool_call.id] = { "origin": "reviewer", "note": verdict.reason } reason = f"allowed by reviewer: {verdict.reason}" elif verdict.verdict == "deny": # §8.4 deny asymmetry — full reason to the USER (event + audit above), # terse non-diagnostic refusal to the AGENT. The sanctioned way around a # deny is ask the human, never reshape the request. from .reviewer import AGENT_DENY_MESSAGE self._reviewer_denials += 1 tripped = self._reviewer_denials == _REVIEWER_TRIP if tripped: # (a) The breaker must never trip silently (owner catch 2026-08-24): # persist a notice so reloads see it too. self._append_notice("reviewer_paused", _REVIEWER_PAUSED_TEXT) yield Event( EventType.TOOL_FINISHED, { "name": tool_call.name, "status": "denied", "reason": "blocked by the safety reviewer", "reviewer_reason": verdict.reason, "allow_anyway": True, **({"reviewer_paused": _REVIEWER_PAUSED_TEXT} if tripped else {}), }, ) deny_msg = _tool_error_message(tool_call, AGENT_DENY_MESSAGE) deny_msg["_display"] = { "approval_origin": "reviewer_denied", "approval_note": verdict.reason, } self.messages.append(deny_msg) self._audit( tool_call, stage="finished", status="denied", reason=f"denied by reviewer: {verdict.reason}", ) yield False return # "unsure" falls through to today's card — the human decides. if verdict.verdict == "unsure": self._reviewer_denials = 0 # streak semantics: any non-deny resets unsure_note = verdict.reason if not allowed and decision.needs_user: # Shadow evaluation: record what the reviewer would have said about this card. # Skipped when the live path already consulted it (an `unsure` falling through # to the card is already audited as reviewer_verdict — no double spend). if not consulted_live: self._spawn_shadow_review(tool_call) yield Event( EventType.PERMISSION_REQUIRED, { "name": tool_call.name, "arguments": tool_call.arguments, "reason": decision.reason, # An `unsure` verdict raised this card: the reviewer's one-line reason # answers "why am I being asked?" in place (owner ask 2026-08-24). **( {"reviewer_unsure": verdict.reason} if consulted_live and verdict.verdict == "unsure" else {} ), "category": getattr(metadata, "category", ""), # The exact target a standing rule could pin, or None when the call # isn't eligible (no declared target arg / exec risk). Surfaces use it # to offer "Allow every time" on automation-run approval cards only. # OPE-114 §1: the fact neither the reviewer nor the human could get # from the command text alone. "provenance": provenance_note, "standing_target": standing_rule_candidate( tool_call.name, tool_call.arguments, metadata, self.permissions.risk_overrides, ), # True when this shell command classifies as read-only — the card # offers "Allow read-only commands for this session" only then. "readonly_ok": _readonly_ok(tool_call.arguments), # OPE-136 finding 4: where an MCP call actually goes, stamped at # registration (mcp/tools.py) from the server def — so the card's # scope chip can say "leaves this computer → host" instead of the # catch-all "stays on this computer". None for non-MCP tools. **( {"mcp_destination": dest} if ( dest := getattr( spec.func, "__coworker_mcp_destination__", None ) if spec else None ) else {} ), **( self.approval_extras(tool_call.name, tool_call.arguments) if self.approval_extras else {} ), }, ) self._audit( tool_call, stage="approval_requested", reason=decision.reason, call_id=tool_call.id, ) outcome = await self._interruptible( self.approver( PermissionRequest( tool_name=tool_call.name, arguments=tool_call.arguments, metadata=metadata, reason=decision.reason, tool_call_id=tool_call.id, mcp_destination=( getattr(spec.func, "__coworker_mcp_destination__", None) if spec else None ), ) ), interrupted=ApprovalOutcome.DENY, ) if outcome is ApprovalOutcome.DENY: allowed, reason = ( False, "interrupted by user" if self._cancel.is_set() else "denied by user", ) self._approval_origins[tool_call.id] = { "origin": "user", "grant": "deny", **({"note": unsure_note} if unsure_note else {}), } self._audit( tool_call, stage="approval_resolved", call_id=tool_call.id, status="denied", approval=outcome.value, reason=reason, ) else: if outcome is ApprovalOutcome.ALWAYS_TOOL: self.permissions.allow_tool_for_session(tool_call.name) elif outcome is ApprovalOutcome.ALWAYS_COMMAND: self.permissions.allow_command_for_session( str(tool_call.arguments.get("command", "")) ) elif outcome is ApprovalOutcome.ALWAYS_DOMAIN: self.permissions.allow_domain_for_session( str(tool_call.arguments.get("url", "")) ) elif outcome is ApprovalOutcome.READONLY_SESSION: self.permissions.allow_readonly_for_session() elif outcome is ApprovalOutcome.ALWAYS_TRUST: # Durable per-tool trust (OPE-136 §4): lands in the user-local # override store, so tomorrow's sessions stay quiet too. self.permissions.grant_trust_for_tool(tool_call.name) elif outcome is ApprovalOutcome.THIS_RUN: # Run grant: dies with the current answer (cleared in run()). self.permissions.allow_tool_for_run(tool_call.name) allowed, reason = True, "approved by user" self._approval_origins[tool_call.id] = { "origin": "user", "grant": outcome.value, **({"note": unsure_note} if unsure_note else {}), } self._audit( tool_call, stage="approval_resolved", call_id=tool_call.id, status="approved", approval=outcome.value, reason=reason, ) if not allowed: if spec is None: reason = f"unknown tool: {tool_call.name}" err_msg = _tool_error_message(tool_call, reason) origin = self._approval_origins.pop(tool_call.id, None) if origin: err_msg["_display"] = { "approval_origin": origin.get("origin", ""), **({"approval_note": origin["note"]} if origin.get("note") else {}), **({"approval_grant": origin["grant"]} if origin.get("grant") else {}), } self.messages.append(err_msg) yield Event( EventType.TOOL_FINISHED, {"name": tool_call.name, "status": "denied", "reason": reason}, ) self._audit(tool_call, stage="finished", status="denied", reason=reason) yield False return if spec is None: self.messages.append( _tool_error_message(tool_call, f"unknown tool: {tool_call.name}") ) yield Event( EventType.TOOL_FINISHED, {"name": tool_call.name, "status": "error", "reason": "unknown tool"}, ) yield False return yield True def _execute_sync(self, tool_call: ToolCall) -> tuple[Any, str]: """Execute one authorized call (runs in a worker thread).""" try: return self.registry.execute(tool_call.name, tool_call.arguments), "ok" except Exception as exc: return {"error": str(exc), "error_type": type(exc).__name__}, "error" def _record_result(self, tool_call: ToolCall, result: Any, status: str) -> Event: self._step += 1 if status == "ok": # Only successful calls: a write that raised left nothing on disk to run. self._agent_files.record( tool_call.name, tool_call.arguments, result, step=self._step ) # A `_display` key on a tool result is user-facing metadata the AGENT must # never see (e.g. how many gmail hits the privacy filters hid — a count # the model could probe around). Lift it onto the message as a sidecar # (like `source`), stripped from every provider feed in # `_outbound_messages` but persisted for the GUI's tool card. display: Optional[dict[str, Any]] = None if isinstance(result, dict) and "_display" in result: display = result.get("_display") or None result = {k: v for k, v in result.items() if k != "_display"} origin = self._approval_origins.pop(tool_call.id, None) if origin: # Provenance survives reload via the same display-only sidecar as the privacy # counts (owner ruling 2026-08-24) — `_outbound_messages` strips it, so no # provider ever sees it. display = { **(display or {}), "approval_origin": origin.get("origin", ""), **({"approval_note": origin["note"]} if origin.get("note") else {}), **({"approval_grant": origin["grant"]} if origin.get("grant") else {}), } message = _tool_result_message(tool_call, result) if display: message["_display"] = display self.messages.append(message) hidden = int((display or {}).get("hidden_by_filters") or 0) stripped = int((display or {}).get("hidden_fields") or 0) if hidden or stripped: # The out-of-band trace the user CAN see: rule class + count, never content. parts = [] if hidden: parts.append(f"{hidden} result(s) hidden") if stripped: parts.append(f"{stripped} field value(s) stripped") self._audit( tool_call, stage="filtered", status="hidden", reason=" · ".join(parts) + " by privacy filters", ) self._audit( tool_call, stage="finished", status=status, result=result, result_preview=_preview(result), ) self._note_ingestion(tool_call, status) rule = self._standing_notes.pop(tool_call.id, "") return Event( EventType.TOOL_FINISHED, { "name": tool_call.name, "status": status, "result_preview": _preview(result), **({"display": display} if display else {}), **({"standing_rule": rule} if rule else {}), # (c) quiet provenance chip — same fields the `_display` sidecar persists. **( { "approval_origin": origin.get("origin", ""), **({"approval_note": origin["note"]} if origin.get("note") else {}), **({"approval_grant": origin["grant"]} if origin.get("grant") else {}), } if origin else {} ), }, ) def _note_ingestion(self, tool_call: ToolCall, status: str) -> None: """Record that outside content entered this session, and from where. The fact and the source only — never the content, not even truncated. **Nothing consumes this in v1.** It exists so that when the reviewer is eventually offered the fact (v2, `PRV-1`), the question "would it have changed a verdict?" can be answered by replaying a shadow run instead of re-argued. See `session_facts.py` and the spec's Part 0. Failed calls are skipped: a fetch that errored brought nothing in. """ if self.session_facts is None or status != "ok": return spec = self.registry.get(tool_call.name) if not session_facts.is_ingesting(spec.metadata if spec else None): return record = self.session_facts.note(tool_call.name, tool_call.arguments) self._audit(tool_call, **record.to_audit()) def _audit(self, tool_call: ToolCall, **event: Any) -> None: if self.audit_sink is None: return payload = { **self.audit_context, "tool": tool_call.name, "arguments": tool_call.arguments, **event, } try: self.audit_sink(payload) except Exception: pass async def _handle_items_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]: """The decomposition gate: emit the proposed items, await the user's decision. Approval creates them on the board (server-side, inside the approver) and the result carries their ids; rejection returns feedback for a revised split.""" args = tool_call.arguments or {} items = args.get("items") or [] valid = [ i for i in items if isinstance(i, dict) and str(i.get("title", "")).strip() and str(i.get("criteria", "")).strip() ] if not valid or len(valid) != len(items): result: dict[str, Any] = { "approved": False, "error": "every proposed item needs a title and acceptance criteria", } elif self.items_approver is None: result = { "approved": False, "error": "item proposals aren't available in this surface", } else: yield Event( EventType.ITEMS_PROPOSED, {"items": valid, "note": str(args.get("note", ""))}, ) self._audit(tool_call, stage="items_proposed") result = await self._interruptible( self.items_approver(dict(args), tool_call.id), interrupted={"approved": False, "error": "interrupted by user"}, ) or {"approved": False, "error": "no response"} status = "ok" if result.get("approved") else "denied" self.messages.append(_tool_result_message(tool_call, result)) self._audit( tool_call, stage="finished", status=status, result=result, result_preview=_preview(result), ) yield Event( EventType.TOOL_FINISHED, { "name": tool_call.name, "status": status, "result_preview": _preview(result), }, ) async def _handle_team_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]: """The staffing gate: emit the proposed roster, await the user's out-of-band decision. Approval PRE-SPAWNS the worker sessions (server-side, inside the approver) and the result carries the roster with actor ids so the lead can assign; rejection returns the user's feedback for a revised proposal.""" args = tool_call.arguments or {} members = args.get("members") or [] if not isinstance(members, list) or not members: result: dict[str, Any] = { "approved": False, "error": "propose at least one member ({persona, model?, reason?})", } elif self.team_approver is None: result = { "approved": False, "error": "team staffing isn't available in this surface", } else: yield Event( EventType.TEAM_PROPOSED, { "members": members, "enable_chat": bool(args.get("enable_chat", False)), "note": str(args.get("note", "")), }, ) self._audit(tool_call, stage="team_proposed") result = await self._interruptible( self.team_approver(dict(args), tool_call.id), interrupted={"approved": False, "error": "interrupted by user"}, ) or {"approved": False, "error": "no response"} status = "ok" if result.get("approved") else "denied" self.messages.append(_tool_result_message(tool_call, result)) self._audit( tool_call, stage="finished", status=status, result=result, result_preview=_preview(result), ) yield Event( EventType.TOOL_FINISHED, { "name": tool_call.name, "status": status, "result_preview": _preview(result), }, ) async def _handle_plan_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]: """Emit the plan for review, await the user's out-of-band decision, and apply it: approval flips the live PermissionEngine out of plan mode (the same session keeps going, with all its exploration context); rejection keeps plan mode and returns the user's feedback so the agent can revise.""" args = tool_call.arguments or {} plan = str(args.get("plan", "")) if self.permissions.mode is not Mode.PLAN: # The tool is always registered (mode can flip mid-session), but proposing a # plan only means something while the session is actually in plan mode. The # right next step differs by mode: discuss stays read-only, so the agent # should talk through the change; write-capable modes should just do it. if self.permissions.mode is Mode.DISCUSS: error = ( "not in plan mode — this is discuss mode (read-only), so describe " "the proposed changes in chat instead" ) else: error = "not in plan mode — proceed with the work directly" result: dict[str, Any] = {"approved": False, "error": error} elif self.plan_approver is None: result = { "approved": False, "error": "plan approval isn't available here", } else: yield Event(EventType.PLAN_PROPOSED, {"plan": plan}) self._audit(tool_call, stage="plan_proposed") result = await self._interruptible( self.plan_approver(dict(args), tool_call.id), interrupted={"approved": False, "error": "interrupted by user"}, ) or { "approved": False, "error": "no response", } if result.get("approved"): # The approver may pick the post-plan mode ("interactive" asks per write, # "auto" executes the approved plan without further prompts). try: self.permissions.mode = Mode(str(result.get("mode", "interactive"))) except ValueError: self.permissions.mode = Mode.INTERACTIVE result = { **result, "mode": self.permissions.mode.value, "note": "plan approved — implement it now", } status = "ok" if result.get("approved") else "denied" self.messages.append(_tool_result_message(tool_call, result)) self._audit( tool_call, stage="finished", status=status, result=result, result_preview=_preview(result), ) yield Event( EventType.TOOL_FINISHED, { "name": tool_call.name, "status": status, "result_preview": _preview(result), }, ) async def _handle_tool_request(self, tool_call: ToolCall) -> AsyncIterator[Event]: """Emit the install prompt, await the user's decision, hand the outcome back. Declining is a normal outcome, not an error: the result tells the agent to fall back and disclose the gap, because a security report that quietly loses a check is worse than one that says which checks it couldn't run. """ args = tool_call.arguments or {} name = str(args.get("name", "")).strip() reason = str(args.get("reason", "")) if self.tool_requester is None or not name: result: dict[str, Any] = { "installed": False, "error": "tool requests aren't available here", "guidance": ( "Continue without it: use a fallback check if you have one, and say in " "your report which checks were degraded." ), } elif _toolchain.describe(name) is None: # Not in the pinned catalog: no card at all (owner-hit 2026-08-20 — agents # routed ordinary brew/pip installs through the install card, which could # only fail after approval). The agent has a shell with its own approval # flow; steer it there instead of at the user. catalog = ", ".join(sorted(_toolchain.MANAGED)) result = { "installed": False, "error": ( f"'{name}' is not in the pinned tool catalog ({catalog})." ), "guidance": ( "Install it yourself with the shell (brew/pip/…, subject to the " "normal command approval), or continue without it and say in your " "report which checks were degraded." ), } else: # The prompt must say up front whether WE can install this (pinned build for # this platform) — a card that offers Install for a tool we can't fetch turns # the user's approval into a guaranteed error. Absence of metadata means NO. info = _toolchain.describe(name) yield Event( EventType.TOOL_REQUESTED, { "name": name, "reason": reason, "installable": info is not None, "version": (info or {}).get("version", ""), "summary": (info or {}).get("summary", ""), "source": (info or {}).get("source", ""), }, ) self._audit(tool_call, stage="tool_requested", reason=reason) result = await self._interruptible( self.tool_requester(dict(args), tool_call.id), interrupted={"installed": False, "error": "interrupted by user"}, ) or {"installed": False, "error": "no response"} if not result.get("installed"): # The card says "or install it yourself and continue" — honor it. A user # who brewed the tool mid-prompt and clicked Continue has PROVIDED it, # not declined it; find their copy before treating this as a refusal. found = _toolchain.resolve(name) if found: result = { "installed": True, "path": found, "note": ( "the user provided their own copy instead of the managed " "install — use it from this path" ), } if not result.get("installed"): result.setdefault( "guidance", "Continue without it: use a fallback check if you have one, and say in " "your report which checks were degraded.", ) status = "ok" if result.get("installed") else "denied" self.messages.append(_tool_result_message(tool_call, result)) self._audit( tool_call, stage="finished", status=status, result=result, result_preview=_preview(result), ) yield Event( EventType.TOOL_FINISHED, { "name": tool_call.name, "status": status, "result_preview": _preview(result), }, ) async def _handle_directory_request( self, tool_call: ToolCall ) -> AsyncIterator[Event]: """Emit the grant prompt, await the user's out-of-band decision (which the requester also applies to this session's roots), and return the outcome as the tool result.""" args = tool_call.arguments or {} if self.directory_requester is None: result: dict[str, Any] = { "granted": False, "error": "directory requests aren't available here", } else: yield Event( EventType.DIRECTORY_REQUESTED, { "reason": str(args.get("reason", "")), "path": str(args.get("path", "")), "writable": bool(args.get("writable", False)), # Root promotion (workspace-scratch-design.md §5): the agent asks for # the folder to become the session's primary workspace — the consent # card must say so, it's a different grant than a plain extra root. "primary": bool(args.get("primary", False)), }, ) self._audit( tool_call, stage="directory_requested", reason=str(args.get("reason", "")), ) result = await self._interruptible( self.directory_requester(dict(args), tool_call.id), interrupted={"granted": False, "error": "interrupted by user"}, ) or { "granted": False, "error": "no response", } status = "ok" if result.get("granted") else "denied" self.messages.append(_tool_result_message(tool_call, result)) self._audit( tool_call, stage="finished", status=status, result=result, result_preview=_preview(result), ) yield Event( EventType.TOOL_FINISHED, { "name": tool_call.name, "status": status, "result_preview": _preview(result), }, ) async def _handle_ask_user(self, tool_call: ToolCall) -> AsyncIterator[Event]: """Emit the question, await the user's out-of-band answer (inline in the live session or from the Inbox when unattended), and return it as the tool result.""" args = tool_call.arguments or {} question = str(args.get("question", "")).strip() # Grouped form (OPE-51): `questions` alone is a valid call — the singular field may be # empty. The asker normalizes/validates the entries; here only "is anything asked?". if not question: for entry in args.get("questions") or []: if isinstance(entry, dict) and str(entry.get("question", "")).strip(): question = str(entry["question"]).strip() break if self.question_asker is None or not question: result: dict[str, Any] = { "answer": "", "error": ( "no question was asked" if not question else "asking isn't available here" ), } else: # The asker is mode-aware (attended → live inline prompt; unattended → Inbox), so it # owns surfacing the question. The engine just awaits the answer. self._audit(tool_call, stage="question_requested", reason=question) result = await self._interruptible( self.question_asker(dict(args), tool_call.id), interrupted={"answer": "", "error": "interrupted by user"}, ) or { "answer": "", "error": "no response", } status = "ok" if (result.get("answer") or result.get("answers")) else "denied" if status == "ok": self._note_ask_replies(result, question) self.messages.append(_tool_result_message(tool_call, result)) self._audit( tool_call, stage="finished", status=status, result=result, result_preview=_preview(result), ) yield Event( EventType.TOOL_FINISHED, { "name": tool_call.name, "status": status, "result_preview": _preview(result), }, ) def _note_ask_replies( self, result: dict[str, Any], question: str = "" ) -> None: """Record the user's ask_user answer(s) for the reviewer's history (§8.2), together with the agent's question — shown to the judge explicitly framed as agent-authored data (same Rule-3 discipline as tool arguments), so a structured answer counts as evidence for exactly the question's scope (owner ruling 2026-08-24). Anchored to the number of user messages present now, so the merge stays chronological however the session continues. A fresh answer also resets the §8.4 denial streak: the user is present and just gave direction — the reviewer deserves a fresh look at what follows.""" self._reviewer_denials = 0 anchor = sum(1 for m in self.messages if m.get("role") == "user") answers = result.get("answers") values = ( [str(v) for v in answers.values()] if isinstance(answers, dict) else [str(result.get("answer") or "")] ) q = (question or "").strip() for text in values: text = text.strip() if text: self._ask_replies.append((anchor, text, q)) def _inject_steering(self) -> None: for text, source in self._steering: message: dict[str, Any] = { "role": "user", "content": text, "ts": time.time(), } if source is not None: message["source"] = source self.messages.append(message) self._steering = [] def _outbound_messages(self) -> list[dict[str, Any]]: """`self.messages` prepared for the provider. The SOLE provider feed (see `_astream`). Every message is stripped of the display-only sidecars — `source`, `_display`, and `ts` — (providers reject unknown keys), unconditionally — whether or not a `` block is added. When a context provider yields a non-empty string, an ephemeral `` block is appended to the last user message. Never mutates `self.messages`, so neither the strip nor the block is persisted/replayed. """ # Strip the display-only sidecars — `source` (connector cards), `_display` # (e.g. filter-hidden counts), `ts` (append-time timestamps), `reasoning` # (thinking text), and `usage` (token counts) — copying only messages that carry # one. Whole `notice` messages (error/interrupted/model-switch markers) are # display-only too: dropped entirely. _SIDECARS = ("source", "_display", "ts", "reasoning", "usage") # Auto-compaction (OPE-27): everything before the boundary is represented by the # compacted block. Outbound-only — the canonical history stays intact — and the # block+tail are byte-stable between turns, so prompt caching keeps working. source_messages = _compaction.apply_to_outbound( self.messages, self.compaction_state ) out = [ ( {k: v for k, v in msg.items() if k not in _SIDECARS} if any(s in msg for s in _SIDECARS) else msg ) for msg in source_messages if msg.get("role") != "notice" ] # PDF attachments (stored as `file` parts) are adapted to the ACTIVE model right # here — never in the persisted history — so a mid-session model switch always # re-decides: native PDF models get the real document, the rest get the local # text-extract/page-image fallback (pdf_support.py). if any( isinstance(p, dict) and p.get("type") == "file" for msg in out if isinstance(msg.get("content"), list) for p in msg["content"] ): caps = self.provider.capabilities(self.model) if not getattr(caps, "pdf", False): from . import pdf_support out = [ ( { **msg, "content": pdf_support.adapt_content(msg["content"], caps), } if isinstance(msg.get("content"), list) else msg ) for msg in out ] # Images get the same per-turn treatment: a model without vision receives a visible # placeholder instead of a payload it would reject. Like the PDF path, this re-decides # per call, so a mid-session switch to/from a vision model always does the right thing. if any( isinstance(p, dict) and p.get("type") == "image_url" for msg in out if isinstance(msg.get("content"), list) for p in msg["content"] ): caps = self.provider.capabilities(self.model) if not getattr(caps, "vision", False): placeholder = { "type": "text", "text": "[image attachment — not viewable by this model]", } out = [ ( { **msg, "content": [ ( placeholder if isinstance(p, dict) and p.get("type") == "image_url" else p ) for p in msg["content"] ], } if isinstance(msg.get("content"), list) else msg ) for msg in out ] context = ( self.context_provider() if self.context_provider is not None else "" ) or "" if not context: return out block = f"\n\n\n{context}\n" for i in range(len(out) - 1, -1, -1): if out[i].get("role") != "user": continue msg = dict(out[i]) content = msg.get("content") if isinstance(content, str): msg["content"] = content + block elif isinstance(content, list): # content-parts (text + images) msg["content"] = [*content, {"type": "text", "text": block}] else: msg["content"] = block out[i] = msg break return out def _assistant_message(turn: AssistantTurn, model: Optional[str] = None) -> dict[str, Any]: message: dict[str, Any] = { "role": "assistant", "content": turn.text or "", "ts": time.time(), } if turn.usage is not None: # Display/aggregation sidecar (like `reasoning`): persisted with the message, # stripped before provider calls. Tagged with the model that produced it so # per-model rollups survive mid-session model switches. message["usage"] = {"model": model, **turn.usage.as_dict()} if turn.reasoning: # Display-only thinking text — rendered by the GUI, stripped for every provider # (`_outbound_messages`); provider-private replay blocks go via `extras` instead. message["reasoning"] = turn.reasoning if turn.extras: # Provider-private sidecars (e.g. `_gemini` thought signatures) persist with the # message; the owning provider reattaches them, the rest strip them (base.py). message.update(turn.extras) if turn.tool_calls: message["tool_calls"] = [ { "id": tc.id, "type": "function", "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)}, } for tc in turn.tool_calls ] return message _MANGLED_PREVIEW_CHARS = 200 def _is_mangled(tool_call: ToolCall) -> bool: """Provider arg-parsers fall back to `{"_raw": }` when a tool call's arguments aren't a JSON object (typically a stream truncated mid-arguments).""" return set(tool_call.arguments or {}) == {"_raw"} def _sanitize_mangled_calls(turn: AssistantTurn) -> None: """Shrink each mangled call's stored raw text to a short preview BEFORE the turn enters history. The full text is junk (half a JSON document): replaying it costs thousands of tokens per turn and, worse, teaches the model that `_raw` is a real parameter shape it should imitate.""" for tc in turn.tool_calls: if _is_mangled(tc): raw = str(tc.arguments.get("_raw") or "") if len(raw) > _MANGLED_PREVIEW_CHARS: tc.arguments = { "_raw": raw[:_MANGLED_PREVIEW_CHARS] + f"… [unparsed tool-call text, {len(raw)} chars, truncated in history]" } def _tool_result_message(tool_call: ToolCall, result: Any) -> dict[str, Any]: content = result if isinstance(result, str) else json.dumps(result, default=str) return { "role": "tool", "tool_call_id": tool_call.id, "content": content, "ts": time.time(), } def _tool_error_message(tool_call: ToolCall, reason: str) -> dict[str, Any]: return { "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps({"error": "tool call not executed", "reason": reason}), "ts": time.time(), } def _preview(value: Any, max_chars: int = 300) -> str: text = value if isinstance(value, str) else json.dumps(value, default=str) text = text.replace("\n", "\\n") return text if len(text) <= max_chars else text[: max_chars - 3] + "..."