adapter.py was a 3,493-line monolith. Split it into focused modules with clear separation of responsibilities, bringing it down to ~857 lines: - Module-level helpers: hooks, classify, pickers, commands, setup, defaults, secrets - Frame-handler mixins: inbound, tool_frames, push_frames, media_frames, picker_frames, channel_frames, query_frames - mixin_base: IrisAdapterBase (declaration-only base for shared attrs) - adapter.py now holds only IrisAdapter (the composition of the 7 mixins + BasePlatformAdapter), register(), and test-facing re-exports The mixins come before BasePlatformAdapter in the MRO so their methods override the base; super() calls (e.g. send_image) still resolve to BasePlatformAdapter. No circular imports; dispatch.py and http_server.py (instance-method callers) are unaffected. Ruff complexity ceilings (PLR0911/0912/0913/0915) restored to Ruff's built-in defaults (12/50/6/5) instead of "just above the current maxima", which ratchets the bar down as code grows. The existing genuinely-complex functions (frame builders mirroring the wire schema, the QR matrix builder, the dispatch table) carry an explicit `# noqa: PLR09xx` marking them as reviewed, frozen exceptions; new code is held to the default ceilings. All 125 tests green (94 test_android + 31 test_android_http); no new ruff errors introduced.
158 lines
6.0 KiB
Python
158 lines
6.0 KiB
Python
"""Tool-progress frame emission (M2): the tool.start / tool.end lifecycle.
|
|
|
|
Mixin for ``adapter.IrisAdapter``. The gateway accumulates tool lines in one
|
|
editable bubble; on an edit the full buffer is re-sent, so new lines are
|
|
diffed against ``seen_tool_lines`` and each new tool closes the previously
|
|
open one (attaching the output/duration captured by the ``post_tool_call``
|
|
hook).
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
from gateway.platforms.base import SendResult
|
|
|
|
from . import protocol
|
|
from .classify import (
|
|
_extract_code_block,
|
|
_extract_verbose_args,
|
|
_mint_message_id,
|
|
_parse_tool_line,
|
|
_short_preview_from_args,
|
|
_TurnState,
|
|
)
|
|
from .hooks import _reset_tool_results, _tool_emoji, _tool_end_fields
|
|
from .mixin_base import IrisAdapterBase
|
|
|
|
|
|
class ToolProgressHandlers(IrisAdapterBase):
|
|
"""Tool-progress lifecycle (see module docstring)."""
|
|
|
|
async def _emit_tool_lines(
|
|
self,
|
|
chat_id: str,
|
|
content: str,
|
|
state: _TurnState,
|
|
thread_id: str | None,
|
|
*,
|
|
is_edit: bool,
|
|
) -> SendResult:
|
|
"""Emit ``tool.start`` for each NEW tool line in *content*.
|
|
|
|
The gateway accumulates tool lines in one editable bubble; on an edit
|
|
the full buffer is re-sent, so we diff against ``seen_tool_lines`` to
|
|
emit only the new ones. A new tool closes the previously-open tool.
|
|
"""
|
|
message_id = state.tool_msg_id or _mint_message_id()
|
|
state.tool_msg_id = message_id
|
|
state.active = True
|
|
# Tool activity marks this lane as the turn in flight: the global
|
|
# post_tool_call hook (no chat id) routes todo emissions here.
|
|
self._active_lane = (chat_id, thread_id)
|
|
|
|
lines = [ln for ln in content.splitlines() if ln.strip()]
|
|
for line in lines:
|
|
key = line.strip()
|
|
if key in state.seen_tool_lines:
|
|
continue
|
|
state.seen_tool_lines.add(key)
|
|
parsed = self._parse_tool_line_or_block(line, content)
|
|
if parsed is None:
|
|
continue
|
|
name, preview, args = parsed
|
|
# A new tool begins: close the previously-open one, attaching the
|
|
# output/duration/ok captured by the post_tool_call hook.
|
|
if state.open_tool_index is not None:
|
|
extra = _tool_end_fields(state.open_tool_name or "")
|
|
await self._broadcast_or_log(
|
|
chat_id,
|
|
protocol.tool_end(
|
|
chat_id,
|
|
state.open_tool_index,
|
|
state.open_tool_name or "",
|
|
ok=extra.get("ok", True),
|
|
duration=extra.get("duration"),
|
|
output_preview=extra.get("output_preview"),
|
|
thread_id=thread_id,
|
|
),
|
|
)
|
|
state.tool_index += 1
|
|
state.open_tool_index = state.tool_index
|
|
state.open_tool_name = name
|
|
await self._broadcast_or_log(
|
|
chat_id,
|
|
protocol.tool_start(
|
|
chat_id,
|
|
state.tool_index,
|
|
name,
|
|
preview=preview,
|
|
args=args,
|
|
emoji=_tool_emoji(name),
|
|
thread_id=thread_id,
|
|
),
|
|
)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
@staticmethod
|
|
def _parse_tool_line_or_block(
|
|
line: str, content: str
|
|
) -> tuple[str, str | None, dict[str, Any] | None] | None:
|
|
"""Parse a tool line into ``(name, preview, args)``.
|
|
|
|
Expands a terminal code block to its command, and a verbose header
|
|
(``<emoji> <name>(keys)``) to its full args JSON (the JSON sits on the
|
|
following line). ``args`` is ``None`` unless the line is a verbose
|
|
header with a parseable JSON body.
|
|
"""
|
|
parsed = _parse_tool_line(line)
|
|
if parsed is None:
|
|
return None
|
|
name, preview = parsed
|
|
# Terminal code block: the command lives in the fenced lines that
|
|
# follow the "<emoji> terminal" head line.
|
|
if name == "terminal" and preview is None and "```" in content:
|
|
cmd = _extract_code_block(content)
|
|
if cmd:
|
|
return name, cmd, None
|
|
# Verbose mode: recover the full args from the following JSON line.
|
|
args = _extract_verbose_args(line, content)
|
|
if args is not None and preview is None:
|
|
preview = _short_preview_from_args(args)
|
|
return name, preview, args
|
|
|
|
async def _close_open_tool(
|
|
self, chat_id: str, state: _TurnState, thread_id: str | None
|
|
) -> None:
|
|
"""Emit ``tool.end`` for the currently-open tool, if any.
|
|
|
|
A tool is considered complete when the next tool starts OR a new
|
|
content segment begins (the model only produces content after the
|
|
tool it was waiting on has returned).
|
|
"""
|
|
if state.open_tool_index is not None:
|
|
extra = _tool_end_fields(state.open_tool_name or "")
|
|
await self._broadcast_or_log(
|
|
chat_id,
|
|
protocol.tool_end(
|
|
chat_id,
|
|
state.open_tool_index,
|
|
state.open_tool_name or "",
|
|
ok=extra.get("ok", True),
|
|
duration=extra.get("duration"),
|
|
output_preview=extra.get("output_preview"),
|
|
thread_id=thread_id,
|
|
),
|
|
)
|
|
state.open_tool_index = None
|
|
state.open_tool_name = None
|
|
|
|
def _reset_tool_state(self, state: _TurnState) -> None:
|
|
"""Clear per-turn tool bookkeeping (called at turn finalization)."""
|
|
state.tool_msg_id = None
|
|
state.seen_tool_lines = set()
|
|
state.tool_index = 0
|
|
state.open_tool_index = None
|
|
state.open_tool_name = None
|
|
# Drop any captured tool results not consumed by a tool.end this turn
|
|
# (e.g. tool_progress off) so they can't leak into the next turn.
|
|
_reset_tool_results()
|