from dataclasses import dataclass, replace
from typing import Callable, ClassVar

from engine.events.agents import AgentReported
from engine.events.engine import ClockTicked
from features import trigger
from features.parts import ANY_BUT_POST_TOOL_USE, AgentContext, Handler


@dataclass(frozen=True)
class Sent:
    key: str
    values: dict


@dataclass(frozen=True)
class Nudge:
    line: str
    behaviour: str
    about: Callable
    private: bool = True
    pace: Callable | None = None
    once: bool = False
    most: int = 0
    first: bool = False

    def __post_init__(self) -> None:
        if self.first and not self.most:
            raise ValueError(f"nudge {self.line} offers the first row under a cap, so it needs most")

    def cadence(self, context) -> trigger.Trigger:
        cadence = context.feature.cadence(context.record, self.behaviour)
        return cadence if self.pace is None else replace(cadence, every=self.pace(context))

    def due(self, context, agent) -> list[Sent]:
        if not context.feature.on(context.record, self.behaviour):
            return []
        if self.once:
            return [found for found in self.about(context, agent) if context.once(self.line, found.key)]
        cadence = self.cadence(context)
        found = self.about(context, agent)
        if self.first:
            found = [one for one in found if not context.used_up(self.line, one.key, self.most)][:1]
        due = [one for one in found if context.every(self.behaviour, f"{self.line}:{one.key}", cadence)]
        return self.capped(context, due) if self.most and due else due

    def capped(self, context, due: list[Sent]) -> list[Sent]:
        return [found for found in due if context.at_most(self.line, found.key, self.most)]


def send(context, nudges: tuple) -> None:
    speaking = context.to_primary()
    if not speaking:
        return
    for nudge in nudges:
        for found in nudge.due(speaking, speaking.agent.row):
            speaking.agent.say(nudge.line, private=nudge.private, **found.values)


@dataclass
class SendOnTheClock(Handler):
    nudges: tuple

    def handle(self, context: AgentContext, event: ClockTicked) -> None:
        send(context, self.nudges)


@dataclass
class SendOnToolUse(Handler):
    hooks: ClassVar[tuple[str, ...]] = ANY_BUT_POST_TOOL_USE
    nudges: tuple

    def handle(self, context: AgentContext, event: AgentReported) -> None:
        send(context, self.nudges)
from pathlib import Path

from agents.seat import HookBinding
from controllers.types import Agents
from engine import bus, runtime
from engine.gates import HookCall, responded
from engine.record import Record
from providers.base import asking_row
from providers.payload import PERMISSION, STATUS, Hook, HookEvent
from resources.base import SYSTEM
from resources.types import AgentRow
from runner import chat_mirror
from runner.gate import refusal
from runner.hook_report import report


def answer(provider, root: Path, raw: dict, pid: int, prefer: str = "") -> dict:
    hook = Hook.read(raw, provider.tool_kinds)
    return handle(provider, root, HookBinding(root, provider, pid).environment(hook, prefer), hook)


def handle(provider, root: Path, env: str, hook) -> dict:
    hook = Hook.read(hook, provider.tool_kinds) if isinstance(hook, dict) else hook
    if hook.event not in STATUS or runtime.off(root):
        return {}
    record = Record(root, env, memo=True)
    if provider.is_subagent(hook):
        return subagent_answer(provider, record, hook)
    report(provider, record, hook)
    call = HookCall(provider, record, hook, Agents(record, actor=SYSTEM)._shared(hook.session))
    return ANSWERS.get(hook.event, quiet)(call)


def subagent_answer(provider, record: Record, hook: Hook) -> dict:
    agents = Agents(record, actor=SYSTEM)
    row = agents.by_session(hook.session)
    parent = provider.session(hook.transcript).get(AgentRow.parent, "") if not row.parent else ""
    if parent:
        row = agents.update(row.n, parent=parent)
    if hook.event == PERMISSION or row.asking:
        agents.update(row.n, asking=asking_row(provider.asking(hook)))
    if hook.event != HookEvent.PRE_TOOL_USE:
        return {}
    return refused(HookCall(provider, record, hook, agents._shared(hook.session)))


def quiet(call: HookCall) -> dict:
    return {}


def refused(call: HookCall) -> dict:
    why = refusal(call)
    return {} if why is None else call.provider.blocking(why)


def started(call: HookCall) -> dict:
    return call.provider.response(call.hook.event, responded(call))


def ended(call: HookCall) -> dict:
    bus.defer(lambda: chat_mirror.turn_ended(call.record.root, call.session, call.row, call.hook.last_message))
    return {}


def prompted(call: HookCall) -> dict:
    bus.defer(lambda: chat_mirror.turn_began(call.record.root, call.session, call.row))
    return {}


ANSWERS = {HookEvent.PRE_TOOL_USE: refused, HookEvent.SESSION_START: started, HookEvent.STOP: ended, HookEvent.USER_PROMPT_SUBMIT: prompted}
import time
from dataclasses import dataclass

from engine.command_line import command_line
from engine import bus
from engine.journal_calls import JournalCall, pieces
from engine.gates import AFTERWARDS, DISPATCHING, POLICIES, HookCall, cancelled
from engine.reach import Reach
from resources.base import AGENT

PAUSED = "The user paused the agent: wait, and carry on only once you are resumed."
PAUSE = Reach.BOTH
EXPANDING = ("$", "`", "<<")
ANSWERED, KEPT_ANSWERED = "answers_ran", 200
ANSWERING = frozenset({("message", "reply"), ("message", "react"), ("message", "read"), ("message", "process"), ("message", "processed"), ("todo", "create")})


def answers(journal: JournalCall) -> bool:
    return journal.command in ANSWERING and journal.plain and not journal.options


@dataclass(frozen=True)
class ShellLine:
    calls: tuple[JournalCall, ...]
    others: int
    runnable: bool

    @classmethod
    def of(cls, shells: tuple) -> "ShellLine":
        found = [piece for shell in shells for piece in pieces(shell)]
        calls = tuple(JournalCall(piece) for piece in found if piece[0] == "journal")
        return cls(calls, len(found) - len(calls), not any(mark in shell for shell in shells for mark in EXPANDING))

    @property
    def is_answering_only(self) -> bool:
        return self.runnable and bool(self.calls) and not self.others and all(answers(call) for call in self.calls)


def serving(policy, call: HookCall) -> bool:
    return policy.guard.reaches(call.subagent)


def paused(call: HookCall) -> bool:
    return bool(call.row.paused) and PAUSE.reaches(call.subagent)


def gated(call: HookCall) -> str | None:
    dispatch = call.provider.dispatch(call.hook.tool)
    reason = cancelled(DISPATCHING, call, dispatch) if dispatch else None
    bus.defer(lambda: [policy(call) for policy in AFTERWARDS.each() if serving(policy, call)])
    return reason or next((reason for policy in POLICIES.each() if serving(policy, call) and (reason := policy(call))), None)


def refusal(call: HookCall) -> str | None:
    if paused(call):
        return PAUSED
    why = gated(call)
    if why is None:
        return None
    line = ShellLine.of(call.hook.tool.commands)
    if line.is_answering_only:
        return None
    return why if call.subagent else f"{why}{alongside(call, line)}"


def run_answer(call: HookCall, journal: JournalCall) -> bool:
    cwd = ("--cwd", call.hook.cwd) if call.hook.cwd else ()
    _, code = command_line().captured(["--env", call.record.env, "--session", call.session, "--as", AGENT, *cwd, *journal.words[1:]], call.record.root)
    return code == 0


def first_try(call: HookCall) -> bool:
    return call.record.state(ANSWERED, call.session).claim(call.hook.tool_use, time.time(), keep=KEPT_ANSWERED)


def alongside(call: HookCall, line: ShellLine) -> str:
    answering = [journal for journal in line.calls if line.runnable and answers(journal) and call.hook.tool_use]
    retried = bool(answering) and not first_try(call)
    done = [journal for journal in answering if retried or run_answer(call, journal)]
    left = [journal for journal in line.calls if journal not in done]
    return "".join([f" — these journal commands on the same line ran, so do not run them again: {'; '.join(j.line for j in done)}" if done else "",
                    f" — and these were on the same line, so they did not run either: {'; '.join(j.line for j in left)}" if left else ""])
import time
from dataclasses import asdict, dataclass, field, replace

from pathlib import Path

from controllers.types import CONTROLLERS, Agents, Messages, Notices, Notifications
import features
from engine import bus, chat, clock, ran, runtime
from agents.actors import Actor, Agent, System, User, event_data
from resources.types import BUSY, FAILED, IDLE, STOPPED, WORKING
from providers.base import asking_row
from providers.drivers import AGENT_COMMAND
from engine.inputs import BACKGROUND, FORCE, PAUSE, PERMIT, RESUME, SHELL, take, waiting_commands
from engine.record import Record
from controllers.faults import STEADY_AFTER, steady, threw
from providers import PROVIDERS
from providers.base import Provider
from providers.payload import HookEvent
from resources.base import AGENT, SYSTEM, USER, VIEW_ONLY, titled
from resources.types import TYPES, priority
from agents.seat import SeatReport
from engine.wording import plural
from engine.transcript import PEER
from providers.turns import turns
from engine.stored import Growth, read_json, write_json
from engine.fields import Loaded

CLOCK_EVERY = 5.0


def emit_clock(record: Record, session: str) -> None:
    row = Agents(record, actor=SYSTEM).by_session(session)
    clock.tick(record, row.n)

TICK = 1.0
STAMPED = "stamped"
SETTLE, STEP = 3.0, 0.1
OUTPUT_WAIT = 5.0
TYPING_HOLD = 10.0

SILENT_AFTER = 120.0
FORCE_AFTER = 30.0
PROBE_WAIT = 5.0


CARRY_ON = "Carry on with what you were doing; the model or effort change you were interrupted for is done."
MOVED_ON = "Moved a long command to the background"
RESUMED = "The user paused you and has resumed you now: carry on with what you were doing."


def delivered(record, sessions: set[str], action: str, label: str) -> None:
    notices = Notices(record, actor=SYSTEM)
    for notice in notices.rows.every():
        if not notice.completed and notice.data.get("action") == action and notice.data.get("session") in sessions:
            notices.complete(notice.n, how="delivered")
    Notifications(record, actor=SYSTEM)._logged(f"{action.capitalize()} set to {label.lower()}", brief=f"The {action} change was typed into the agent.")


def asking(row) -> bool:
    return bool(row and row.asking)


@dataclass(frozen=True)
class PeerLog(Loaded):
    at: float = 0.0
    names: dict = field(default_factory=dict)


@dataclass(frozen=True)
class Reading:
    row: object
    provider: Provider
    transcript: Path


class Engine:
    def __init__(self, record: Record, driver):
        self.record = record
        self.agent = Agent(record, driver)
        driver.waiting = self.waiting
        self.actors: list[Actor] = [User(record), self.agent, System(record)]
        self.report = SeatReport(record, self.agent)
        self.born = time.time()
        self.relayed = None
        self.peer_growth = Growth()
        self.failure_growth = Growth()
        self.typed_at = 0.0
        self.ticked_at = 0.0
        self.probed_at = 0.0
        self.controlled_at = 0.0
        self.carry_on = False
        self.paused = False
        self.held_at = 0.0
        self.echoed_at = time.time()
        self.why = ""
        self.clean = 0

    def start(self) -> None:
        features.load(self.record.root)
        row = self.agent.driver.last_report()
        self.paused = row is not None and bool(row.paused)

    def tick(self) -> str:
        self.agent.driver.pump()
        self.relay()
        self.relay_peers()
        self.ran()
        self.echoed()
        if not self.agent.driver.DISPLAY_HOOK:
            self.announce_written()
        self.screen_asks()
        self.why = (self.permitted() or self.pausing() or self.backgrounded() or self.failed() or self.probe() or self.forced() or self.typing() or self.shelled()
                    or self.control() or self.deliver() or self.nudge())
        self.report.write(self.why)
        self.clock()
        return self.why

    def reading(self) -> Reading | None:
        row = self.agent.driver.last_report()
        provider = PROVIDERS.get(row.provider) if row else None
        if provider is None or not row.transcript:
            return None
        return Reading(row, provider(), Path(row.transcript))

    def relay(self) -> None:
        if self.relayed is None:
            self.relayed = self.record.event_log.last_id()
        for e in self.record.event_log.events(self.relayed):
            self.relayed = e.id
            features.passed(e, self.record)
            if not e.handled:
                bus.emit(e, self.record)

    def relay_peers(self) -> None:
        reading = self.reading()
        if reading is None or not reading.row.title or not self.peer_growth.grew(reading.transcript):
            return
        row = reading.row
        f = runtime.session_file(self.record.root, row.title, "peers.json")
        seen = read_json(f, dict, None)
        log = PeerLog.from_json(seen)
        recent = sorted((t for t in reading.provider.last_turns(reading.transcript) if t.peer is not None), key=lambda t: t.at)
        newest = max([t.at for t in recent] + [log.at])
        names = dict(log.names)
        for t in recent if seen is not None else []:
            if t.at <= log.at:
                continue
            if t.peer.direction == PEER:
                names[t.peer.address] = t.peer.name
                Messages(self.record, actor=AGENT).create(titled(t.text), brief=t.text, peer=t.peer.name)
            else:
                Messages(self.record, actor=AGENT).create(titled(t.text), brief=t.text, sent_to=names.get(t.peer.address, t.peer.address))
        write_json(f, asdict(PeerLog(newest, names)))

    def announce_written(self) -> None:
        row = self.agent.driver.last_report()
        if not row or not row.title:
            return
        written = turns(row)
        f = runtime.announced_file(self.record.root, row.title)
        announced = read_json(f, dict, None)
        now = {"line": written[-1].line if written else -1, "last_message": row.last_message or ""}
        if announced == now:
            return
        write_json(f, now)
        if announced is None:
            return
        stopped = [row.last_message] if row.last_message and row.event == HookEvent.STOP and row.last_message != announced.get("last_message") else []
        known = next((i for i, turn in enumerate(written) if turn.line == announced["line"]), None)
        fresh = written[known + 1:] if known is not None else written[-1:]
        for turn in fresh:
            chat.send(self.record, row, turn.text, turn=turn.key)
        for text in [text for text in stopped if text not in {turn.text for turn in fresh}]:
            chat.send(self.record, row, text)

    def elsewhere(self, e) -> bool:
        meant = event_data(self.record, e).get("session")
        return bool(meant) and meant not in self.names()

    def failed(self) -> str:
        reading = self.reading()
        if reading is None or self.agent.state() not in (BUSY, WORKING) or not self.failure_growth.grew(reading.transcript):
            return ""
        failure = reading.provider.failure(reading.transcript)
        if failure is None or failure.at < float(reading.row.at):
            return ""
        self.agent.mark(IDLE, FAILED, failure=failure.message)
        self.noted(f"The turn ended in an error: {failure.message}")
        return f"the turn failed: {failure.message}"

    def probe(self) -> str:
        driver = self.agent.driver
        last = driver.last_report()
        if last is None or not driver.alive():
            return ""
        reported = float(last.at)
        silent = time.time() - max(reported, self.typed_at) >= SILENT_AFTER and driver.quiet_for() >= SILENT_AFTER
        if self.probed_at > reported:
            if time.time() - self.probed_at < PROBE_WAIT:
                return "probed, waiting"
            if driver.at_prompt():
                self.agent.mark(IDLE, "probe")
                return "probe: at the prompt, idle"
            if driver.quiet_for() >= PROBE_WAIT:
                self.agent.mark(STOPPED, "probe")
                return "probe: nothing came back, stopped"
            self.probed_at = 0.0
            return "probe: working"
        if silent and self.agent.state() in (BUSY, WORKING) and not driver.asking():
            driver.interrupt()
            self.probed_at = time.time()
            return "silent for two minutes: probing with Ctrl-C"
        return ""

    def passed_over(self, actor, e) -> None:
        if actor is self.agent and TYPES[e.type].typed_as_title:
            CONTROLLERS[e.type](self.record, actor=AGENT).read(e.n)
        actor.notified(e)

    def addressed(self, e) -> bool:
        return event_data(self.record, e).get("session") in self.names()

    def names(self) -> set[str]:
        return {self.agent.driver.session, self.agent.driver.last_title()} - {""}

    def typing(self) -> str:
        driver = self.agent.driver
        if driver.user_typing(TYPING_HOLD):
            return "holding: the user is typing in the terminal"
        if driver.typed.exists():
            driver.clear_input()
            driver.typed.unlink(missing_ok=True)
        return ""

    def screen_asks(self) -> None:
        driver = self.agent.driver
        row = driver.last_report() if driver.ASKS_ON_SCREEN else None
        if row is None:
            return
        asked = asking_row(driver.asked())
        if asked.get("call") == row.asking.get("call"):
            return
        Agents(self.record, actor=SYSTEM).update(row.n, asking=asked)

    def permitted(self) -> str:
        queued = take(self.record.root, self.names(), PERMIT)
        if not queued:
            return ""
        self.agent.driver.permit(queued.value == "allow")
        return f"permission: {queued.value}"

    def shelled(self) -> str:
        if asking(self.agent.driver.last_report()):
            return ""
        queued = take(self.record.root, self.names(), SHELL)
        if not queued:
            return ""
        driver = self.agent.driver
        typed = driver.run_command(queued.value) if queued.value.startswith(AGENT_COMMAND) else driver.run_shell(queued.value)
        agents = Agents(self.record, actor=SYSTEM)
        row = agents.by_session(queued.session)
        waiting = waiting_commands(row)
        kept = [replace(c, typed=time.time()) if c.at == queued.at else c for c in waiting if typed or c.at != queued.at]
        agents.update(row.n, queued_commands=[asdict(c) for c in kept])
        if typed:
            self.typed(row, [queued.value])
        return f"typed in the terminal: {queued.value}" if typed else ""

    def typed(self, row, commands: list[str]) -> None:
        provider = PROVIDERS.get(row.provider)
        if provider and provider.echoes_typed:
            return
        for command in commands:
            ran.announce(self.record, row.n, ran.TYPED, command)

    def echoed(self) -> None:
        reading = self.reading()
        if reading is None or not reading.provider.echoes_typed:
            return
        row = reading.row
        for run in reading.provider.typed_runs(reading.transcript):
            if run.at <= self.echoed_at:
                continue
            if run.output is None and time.time() - run.at < OUTPUT_WAIT:
                return
            if run.output is None:
                ran.announce(self.record, row.n, ran.TYPED, run.command, at=run.at)
            else:
                ran.announce(self.record, row.n, ran.TYPED, run.command, run.output, at=run.at)
            self.echoed_at = run.at

    def noted(self, line: str, tool: str = ran.NOTED) -> None:
        row = self.agent.driver.last_report()
        if row is not None:
            ran.announce(self.record, row.n, tool, line)

    def held(self, line: str) -> str:
        self.agent.driver.stop_turn()
        self.held_at = time.time()
        self.noted(line)
        return line

    def ran(self) -> None:
        row = self.agent.driver.last_report()
        waiting = waiting_commands(row)
        reading = self.reading()
        if not any(c.typed for c in waiting) or reading is None:
            return
        runs = reading.provider.shell_runs(reading.transcript)
        left = []
        for c in waiting:
            run = next((r for r in runs if c.typed and r[1] == c.command and r[0] >= c.typed - 1), None)
            if run:
                runs.remove(run)
            else:
                left.append(c)
        if len(left) != len(waiting):
            Agents(self.record, actor=SYSTEM).update(row.n, queued_commands=[asdict(c) for c in left])

    def pausing(self) -> str:
        if take(self.record.root, self.names(), PAUSE):
            self.paused = True
            self.agent.mark("", "", paused=time.time())
            return self.held("Paused")
        if take(self.record.root, self.names(), RESUME):
            self.paused = False
            self.agent.mark("", "", paused=0)
            self.noted("Continued")
            self.agent.driver.send(RESUMED, now=True)
            return "resumed"
        if not self.paused:
            return ""
        if self.agent.state() in (BUSY, WORKING) and time.time() - self.held_at > SETTLE:
            return self.held("Interrupted: the agent is paused")
        return "paused"

    def backgrounded(self) -> str:
        if not take(self.record.root, self.names(), BACKGROUND):
            return ""
        return "moved the running command to the background" if self.agent.driver.move_to_background() else ""

    def forced(self) -> str:
        if not take(self.record.root, self.names(), FORCE) or self.agent.state() == IDLE:
            return ""
        self.agent.driver.stop_turn()
        self.noted("Interrupted")
        for _ in range(int(SETTLE / STEP)):
            if self.agent.driver.at_prompt():
                break
            time.sleep(STEP)
        self.carry_on = True
        self.controlled_at = 0.0
        return self.control(stopped=True) or "forced: stopped the turn, nothing was waiting"

    def control(self, stopped: bool = False) -> str:
        if not stopped and time.time() - self.controlled_at < TICK:
            return ""
        last = self.agent.driver.last_report()
        at_once = PROVIDERS.get(self.agent.driver.name, Provider).applies_at_once
        idle = stopped or self.agent.state() == IDLE
        if not idle and (not at_once or asking(last)):
            return ""
        queued = take(self.record.root, self.names()) if idle else take(self.record.root, self.names(), among=at_once)
        if not queued:
            return ""
        self.agent.driver.press(queued.keys)
        self.controlled_at = time.time()
        if self.carry_on:
            self.carry_on = False
            self.agent.driver.send(CARRY_ON, now=True)
        if queued.action and queued.label:
            delivered(self.record, self.names(), queued.action, queued.label)
        row = Agents(self.record, actor=SYSTEM).by_session(last.title if last else self.agent.driver.session)
        self.typed(row, [key for key in queued.keys if key.strip() and key.isprintable()])
        if queued.action in row.pending:
            self.agent.mark(row.status, row.event, pending={k: v for k, v in row.pending.items() if k != queued.action})
        return f"controlled: {queued.label}"

    def deliver(self) -> str:
        if self.agent.driver.last_report() is None and not self.idle_without_report():
            return "waiting for the agent's first report"
        count = 0
        for actor in self.actors:
            fresh = actor.cursor() == 0
            for e in self.record.event_log.events(actor.delivered_until()):
                if e.type not in TYPES:
                    actor.notified(e)
                    continue
                if fresh and e.at < self.born and not self.addressed(e):
                    self.passed_over(actor, e)
                    continue
                caused = e.data.get("cause") == AGENT and not TYPES[e.type].addressed_to_agent
                mine = actor is self.agent and ((e.actor != USER and e.action not in TYPES[e.type].notify_actions) or caused)
                viewed = e.actor == USER and bool(e.data.get("fields")) and set(e.data["fields"]) <= set(VIEW_ONLY)
                if e.action == STAMPED or viewed or e.actor == actor.name or actor.name not in TYPES[e.type].notified or self.elsewhere(e) or "seen" in e.data or mine:
                    actor.notified(e)
                    continue
                actor.notify(e)
                count += 1
        waiting = len(self.agent.pending)
        flushed = time.time()
        line = self.agent.flush()
        if line:
            self.typed_at = time.time()
            self.noted(line, ran.DELIVERED)
            if self.agent.driver.sent_now >= flushed:
                self.moved_on()
            return f"typed {waiting} in one line"
        return f"delivered {count}" if count else ""

    def moved_on(self) -> None:
        row = self.agent.driver.last_report()
        if row is not None:
            Agents(self.record, actor=SYSTEM).card(row.n, label=MOVED_ON, icon="terminal", detail="Your message went in while the command runs on")

    def idle_without_report(self) -> bool:
        driver = self.agent.driver
        return driver.at_prompt() and driver.quiet_for() >= FORCE_AFTER

    def nudge(self) -> str:
        if self.agent.state() != IDLE:
            return self.agent.state()
        last = self.agent.driver.last_report()
        if last and self.typed_at and float(last.at) < self.typed_at:
            return "typed, waiting for the hooks"
        line = self.owed()
        if not line:
            return "nothing owed"
        self.agent.driver.send(line)
        self.typed_at = time.time()
        self.noted(line, ran.DELIVERED)
        return f"typed: {line[:60]}"

    def waiting(self) -> bool:
        from features.journal import waiting
        last = self.agent.driver.last_report()
        return bool(last) and waiting(self.record, last)

    def clock(self) -> None:
        if time.time() - self.ticked_at < CLOCK_EVERY:
            return
        self.ticked_at = time.time()
        emit_clock(self.record, self.agent.driver.last_title() or self.agent.driver.session)

    def owed(self) -> str:
        waiting = []
        for type_ in priority():
            if AGENT not in TYPES[type_].notified or TYPES[type_].typed_as_title:
                continue
            unread = [row["n"] for row in CONTROLLERS[type_](self.record, actor=AGENT).rows.summaries()
                      if AGENT not in row["seen"] and not row["completed"] and not row["deleted"]
                      and (type_ != "worktree" or row.get("environment") == self.record.env)]
            if unread:
                waiting.append(f"{plural(len(unread), f'unread {type_}')} {', '.join(map(str, unread[-5:]))}")
        return "waiting: " + "; ".join(waiting) if waiting else ""

    def step(self) -> None:
        try:
            self.tick()
            self.clean += 1
            if self.clean == STEADY_AFTER:
                steady(self.record)
        except Exception:
            self.clean = 0
            threw(self.record.root, self.record.env, "the engine", self.agent.driver)
