mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 13:00:37 +00:00
Imported from andrewyng/aisuite@1b4bbf303e (contents of its platform/ directory, hoisted to the repo root). Development history prior to this commit lives in that repository. Co-authored-by: Devika <devikaverma11@gmail.com>
805 lines
33 KiB
Python
805 lines
33 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,
|
|
) -> 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] = {}
|
|
|
|
# -- external controls ------------------------------------------------------
|
|
def request_interrupt(self) -> None:
|
|
self._cancel.set()
|
|
|
|
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
|
|
|
|
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
|
|
try:
|
|
async for chunk in self._astream():
|
|
if 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
|
|
friendly = friendly_model_error(self.model, exc)
|
|
payload = {
|
|
"error": friendly or str(exc),
|
|
"error_type": type(exc).__name__,
|
|
}
|
|
if friendly:
|
|
payload["raw"] = str(exc)
|
|
yield Event(EventType.ERROR, payload)
|
|
return
|
|
if turn is None:
|
|
turn = AssistantTurn()
|
|
|
|
self.messages.append(_assistant_message(turn))
|
|
yield Event(
|
|
EventType.ASSISTANT_MESSAGE,
|
|
{"text": turn.text, "tool_calls": [tc.name for tc in turn.tool_calls]},
|
|
)
|
|
|
|
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():
|
|
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
|
|
):
|
|
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:
|
|
kind, payload = await queue.get()
|
|
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:
|
|
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:
|
|
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 _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.approver(
|
|
PermissionRequest(
|
|
tool_name=tool_call.name,
|
|
arguments=tool_call.arguments,
|
|
metadata=metadata,
|
|
reason=decision.reason,
|
|
tool_call_id=tool_call.id,
|
|
)
|
|
)
|
|
if outcome is ApprovalOutcome.DENY:
|
|
allowed, reason = False, "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.plan_approver(dict(args), tool_call.id) 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.directory_requester(dict(args), tool_call.id) 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.question_asker(dict(args), tool_call.id) 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), and `ts` (append-time timestamps) — copying only
|
|
# messages that carry one.
|
|
_SIDECARS = ("source", "_display", "ts")
|
|
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
|
|
]
|
|
# 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
|
|
]
|
|
|
|
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.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] + "..."
|