File size: 6,146 Bytes
cb15568
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
"""Handoff ablation โ€” hand a model a partially-played game.

A handoff episode is split: a `prefix` controller plays the first `k`
turns, then the model inherits the live game state and finishes it.
It is a PURE STATE handoff โ€” the model gets no transcript of the
prefix, only the board it produced ("take over from here").

Sweeping the prefix QUALITY decomposes two capabilities:

* a **good** prefix (a winning trajectory) โ†’ can the model *capitalize
  on an advantage*? A flat-low outcome curve means it derails even a
  won position.
* a **bad** prefix (a losing trajectory, or `stall`) โ†’ can the model
  *recover from a deficit*? This is the controlled measurement of the
  freeze-and-panic failure mode: handed a losing board, does the model
  fight (retreat / redirect) or sit on `observe`/`stop`? The
  `passivity` stat on the result quantifies exactly that.

The prefix is a recorded run replayed turn-for-turn. Because engine
actor ids are seed-deterministic, a replayed trajectory MUST come from
the same `pack:level:seed` as the handoff episode.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from .controller import BaseController, as_controller, introspection_source

# A turn is "passive" when the model issued nothing but these โ€” the
# freeze-and-panic tell (low-commitment default instead of an active
# retreat / redirect).
_PASSIVE_TOOLS = {"observe", "stop"}


def stall_policy(render_state: dict, Command: Any) -> list:
    """The canonical losing prefix: do nothing, every turn. Synthesises
    a guaranteed-deficit handoff with no recorded trajectory needed."""
    return [Command.observe()]


def _load_trajectory(source: Any) -> list[list[dict]]:
    """Per-turn tool-call lists from a recorded run. `source` may be a
    ready list, a Playback directory, or a path to its messages.json."""
    if isinstance(source, list):
        return source
    p = Path(source)
    if p.is_dir():
        p = p / "messages.json"
    msgs = json.loads(p.read_text())
    turns: list[list[dict]] = []
    for m in msgs:
        if m.get("role") != "assistant":
            continue
        calls: list[dict] = []
        for tc in m.get("tool_calls") or []:
            fn = tc.get("function") or {}
            args = fn.get("arguments")
            if isinstance(args, str):
                try:
                    args = json.loads(args)
                except (ValueError, TypeError):
                    args = {}
            calls.append({"name": fn.get("name"), "arguments": args or {}})
        turns.append(calls)
    return turns


class TrajectoryController(BaseController):
    """Replays a recorded run: turn N re-issues the commands the
    recorded agent issued on its turn N. Past the recording's end it
    falls back to `observe()`. Used as a deterministic handoff prefix โ€”
    a `win`-outcome run is a good prefix, a `loss` is a bad one."""

    def __init__(self, source: Any, name: str | None = None) -> None:
        super().__init__(name or "trajectory")
        self._turns = _load_trajectory(source)
        self._i = 0

    def reset(self, ctx: Any) -> None:
        self._i = 0

    def act(self, observation: dict, Command: Any) -> list:
        from .agent import _to_commands

        if self._i < len(self._turns):
            calls = self._turns[self._i]
            self._i += 1
            return _to_commands(calls, Command) or [Command.observe()]
        return [Command.observe()]


def _is_passive(cmds: list, _cmd_name) -> bool:
    """A turn with no command other than observe/stop (or no command)."""
    if not cmds:
        return True
    return all((_cmd_name(c) or "") in _PASSIVE_TOOLS for c in cmds)


class HandoffController(BaseController):
    """`prefix` plays turns 0..k-1, then `main` inherits the live state
    and finishes the episode. Pure state handoff โ€” `main` carries no
    transcript of the prefix.

    `handoff_stats` accumulates, over the MAIN agent's turns only:
    `main_turns`, `passive_turns` (observe/stop-only), and `passivity`
    (their ratio) โ€” the freeze-and-panic signal. When the prefix handed
    `main` a losing position, `passivity` IS passivity-under-pressure."""

    def __init__(
        self, prefix: Any, main: Any, k: int, name: str | None = None
    ) -> None:
        super().__init__(name or f"handoff-k{int(k)}")
        self._prefix = as_controller(prefix)
        self._main = as_controller(main)
        self._k = max(0, int(k))
        self._turn = 0
        # Playback should record the MAIN agent's transcript, not this
        # wrapper's โ€” expose it as the introspection source.
        self.source = introspection_source(self._main)
        self.handoff_stats = self._fresh_stats()

    def _fresh_stats(self) -> dict:
        return {
            "k": self._k, "main_turns": 0,
            "passive_turns": 0, "passivity": 0.0,
        }

    def reset(self, ctx: Any) -> None:
        self._turn = 0
        self._prefix.reset(ctx)
        self._main.reset(ctx)
        self.handoff_stats = self._fresh_stats()

    def act(self, observation: dict, Command: Any) -> list:
        if self._turn < self._k:
            self._turn += 1
            return self._prefix.act(observation, Command)
        cmds = self._main.act(observation, Command)
        self._turn += 1
        from .eval_core import _cmd_tool_name

        st = self.handoff_stats
        st["main_turns"] += 1
        if _is_passive(cmds, _cmd_tool_name):
            st["passive_turns"] += 1
        st["passivity"] = st["passive_turns"] / st["main_turns"]
        return cmds


def run_handoff(
    compiled: Any, main: Any, prefix: Any, k: int,
    seed: int = 0, playback: Any = None,
):
    """Run a handoff episode: `prefix` plays the first `k` turns, `main`
    finishes. Returns the `EpisodeResult` with `.handoff_stats` attached
    (k, main_turns, passive_turns, passivity)."""
    from .eval_core import run_level

    ctrl = HandoffController(prefix, main, k)
    res = run_level(compiled, ctrl, seed, playback)
    res.handoff_stats = dict(ctrl.handoff_stats)
    return res