Files
openworker/coworker/engine.py
T
Rohit C Prasad f2b8fde428 Keep Retry offered across model switches
Switch notices no longer consume the retry guard or hide the button.
Error, switch model, Retry is the intended recovery path; retry runs on the new model.
2026-07-23 07:43:24 -07:00

1034 lines
44 KiB
Python

"""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
from enum import Enum
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
from .events import Event, EventType
from .permissions import Mode, PermissionEngine
from .providers import AssistantTurn, ProviderClient, ToolCall
from .providers.errors import friendly_model_error
from .tools import ToolRegistry
class ApprovalOutcome(str, Enum):
ONCE = "once"
ALWAYS_TOOL = "always_tool"
ALWAYS_COMMAND = "always_command"
DENY = "deny"
@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)
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,
# 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 `<system-context>` 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 `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 `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
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()
# 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
) -> 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.
# `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
self.messages.append(message)
self._cancel.clear()
data: dict[str, Any] = {"input": user_input}
if source is not None:
data["source"] = source
yield Event(EventType.TURN_START, data)
async for event in self._loop():
yield event
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
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) -> 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."""
notice: dict[str, Any] = {"role": "notice", "kind": kind, "ts": time.time()}
if text:
notice["text"] = text
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
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
# 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()
self.messages.append(_assistant_message(turn))
payload: dict[str, Any] = {
"text": turn.text,
"tool_calls": [tc.name for tc in turn.tool_calls],
}
if turn.reasoning:
payload["reasoning"] = turn.reasoning
yield Event(EventType.ASSISTANT_MESSAGE, payload)
if not turn.tool_calls:
if self._steering:
self._inject_steering()
continue
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()
# -- 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."""
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")
# `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 == "propose_plan":
async for event in self._handle_plan_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 _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
)
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
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
)
if not allowed and decision.needs_user:
yield Event(
EventType.PERMISSION_REQUIRED,
{
"name": tool_call.name,
"arguments": tool_call.arguments,
"reason": decision.reason,
"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.
"standing_target": standing_rule_candidate(
tool_call.name,
tool_call.arguments,
metadata,
self.permissions.risk_overrides,
),
},
)
self._audit(tool_call, stage="approval_requested", reason=decision.reason)
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,
)
),
interrupted=ApprovalOutcome.DENY,
)
if outcome is ApprovalOutcome.DENY:
allowed, reason = (
False,
"interrupted by user" if self._cancel.is_set() else "denied by user",
)
self._audit(
tool_call,
stage="approval_resolved",
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", ""))
)
allowed, reason = True, "approved by user"
self._audit(
tool_call,
stage="approval_resolved",
status="approved",
approval=outcome.value,
reason=reason,
)
if not allowed:
if spec is None:
reason = f"unknown tool: {tool_call.name}"
self.messages.append(_tool_error_message(tool_call, reason))
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:
# 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"}
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),
)
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 {}),
},
)
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_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_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)),
},
)
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()
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") 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),
},
)
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
`<system-context>` block is added. When a context
provider yields a non-empty string, an ephemeral `<system-context>` 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), and `reasoning`
# (thinking text) — 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")
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 self.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<system-context>\n{context}\n</system-context>"
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) -> dict[str, Any]:
message: dict[str, Any] = {
"role": "assistant",
"content": turn.text or "",
"ts": time.time(),
}
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
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] + "..."