File size: 21,254 Bytes
a1e3907
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
"""Tool-calling agent loop against a berth-planning server, for three provider APIs.

    anthropic:<model>[@low]     Anthropic Messages API (streamed; thinking blocks kept in the history)
    openai:<model>[@low|@high]  OpenAI Responses API (reasoning models only accept function tools here)
    hf:<org/model[:provider]>   Hugging Face router, OpenAI-compatible chat completions (streamed)

Every tool call goes through the env server over OpenEnv's WebSocket session, so the reward recorded is the server's
rubric. One episode: the system prompt is the task's rules, the first user message its situation; the model gets
`max_turns` turns (a turn may hold several tool calls); each tool result says how many turns are left; the last
turn asks it to submit. Two turns in a row without a tool call end the episode. An episode without `submit_plan`
scores 0.

`run_episode` returns the rollout record the viewer reads (messages with reasoning, every plan the agent sent with the
server's answer, the final grade, token usage).
"""

from __future__ import annotations

import json
import os
import time
import uuid
from dataclasses import dataclass, field
from typing import Any

from openenv.core.env_server.mcp_types import CallToolAction
from openenv.core.mcp_client import MCPToolClient

REQUEST_TIMEOUT = 900.0
NOTES_CHARS = 8000
OPENING = ("{situation}\n\nMake the new berth plan. Use check_plan to test drafts, then call submit_plan with your final "
           "plan.")


# ================================================================ env session

class EnvSession:
    """One WebSocket session on a berth-planning server (one episode at a time)."""

    def __init__(self, base_url: str):
        self.client = MCPToolClient(base_url.rstrip("/"), message_timeout_s=300.0).sync()
        self._tools = None

    def reset(self, task_id: str, episode_id: str | None = None) -> dict:
        kw = {"task_id": task_id}
        if episode_id:
            kw["episode_id"] = episode_id
        return dict(self.client.reset(**kw).observation.metadata or {})

    @property
    def tools(self):
        if self._tools is None:
            self._tools = self.client.list_tools()
        return self._tools

    def call(self, name: str, arguments: dict) -> tuple[str, bool, float | None, dict]:
        step = self.client.step(CallToolAction(tool_name=name, arguments=arguments))
        obs = step.observation
        err = getattr(obs, "error", None)
        res = getattr(obs, "result", None)
        text = None
        if isinstance(res, dict):
            blocks = res.get("content") or []
            text = "\n".join(b.get("text", "") for b in blocks if b.get("type") == "text") or None
            if text is None and res.get("data") is not None:
                text = res["data"] if isinstance(res["data"], str) else json.dumps(res["data"])
        elif res is not None:
            data = getattr(res, "data", None)
            text = data if isinstance(data, str) else json.dumps(data) if data is not None else str(res)
        if err:
            msg = err.get("message") if isinstance(err, dict) else getattr(err, "message", str(err))
            text = json.dumps({"error": str(msg)})
        return text or "", bool(step.done or obs.done), step.reward if step.reward is not None else obs.reward, \
            dict(obs.metadata or {})

    def close(self):
        try:
            self.client.close()
        except Exception:
            pass


# ================================================================ model adapters

@dataclass
class Call:
    id: str
    name: str
    arguments: dict
    error: str | None = None
    raw: str = ""


@dataclass
class Turn:
    text: str
    reasoning: str
    calls: list[Call]
    stop: str
    usage: dict = field(default_factory=dict)


def plan_value(plan: Any) -> Any:
    """A plan as sent (a list, or a JSON string of one) -> the list, for the record the viewer reads."""
    if isinstance(plan, str):
        try:
            plan = json.loads(plan)
        except json.JSONDecodeError:
            return plan
    if isinstance(plan, dict) and isinstance(plan.get("plan"), list):
        plan = plan["plan"]
    return plan


def _args(raw: str) -> tuple[dict, str | None]:
    try:
        v = json.loads(raw or "{}")
    except json.JSONDecodeError as e:
        return {}, f"arguments are not valid JSON: {e.msg}"
    return (v, None) if isinstance(v, dict) else ({}, "arguments must be a JSON object")


class AnthropicAgent:
    def __init__(self, model: str, tools, system: str, max_tokens: int, effort: str = "default"):
        import anthropic

        self.client = anthropic.Anthropic(max_retries=4, timeout=REQUEST_TIMEOUT)
        self.model, self.system, self.max_tokens = model, system, max_tokens
        self.extra = {"extra_body": {"output_config": {"effort": effort}}} if effort in ("low", "medium", "high") else {}
        self.tools = [{"name": t.name, "description": t.description or "",
                       "input_schema": t.input_schema or {"type": "object", "properties": {}}} for t in tools]
        self.messages: list[dict] = []
        self._pending: list[dict] = []

    def user(self, text: str):
        self._pending.append({"type": "text", "text": text})

    def notes(self, text: str):
        self._pending.append({"type": "text", "text": f"(Your notes from the cut-off turn)\n{text}"})

    def results(self, pairs: list[tuple[Call, str]]):
        blocks = [{"type": "tool_result", "tool_use_id": c.id, "content": [{"type": "text", "text": out}]}
                  for c, out in pairs]
        self._pending = blocks + self._pending

    def step(self) -> Turn:
        if self._pending:
            self.messages.append({"role": "user", "content": self._pending})
            self._pending = []
        with self.client.messages.stream(model=self.model, system=self.system, messages=self.messages,
                                         tools=self.tools, max_tokens=self.max_tokens, **self.extra) as s:
            msg = s.get_final_message()
        self.messages.append({"role": "assistant", "content": [b.model_dump(exclude_none=True) for b in msg.content]})
        text, reasoning, calls = "", "", []
        for b in msg.content:
            if b.type == "text":
                text += b.text
            elif b.type == "thinking":
                reasoning += getattr(b, "thinking", "") or ""
            elif b.type == "tool_use":
                args = b.input if isinstance(b.input, dict) else {}
                calls.append(Call(b.id, b.name, args, None, json.dumps(b.input)))
        u = msg.usage
        usage = {"input": (u.input_tokens or 0) + (getattr(u, "cache_read_input_tokens", 0) or 0)
                 + (getattr(u, "cache_creation_input_tokens", 0) or 0), "output": u.output_tokens or 0}
        return Turn(text, reasoning, calls, msg.stop_reason or "", usage)


class OpenAIResponsesAgent:
    def __init__(self, model: str, tools, system: str, max_tokens: int, effort: str = "medium"):
        from openai import OpenAI

        self.client = OpenAI(max_retries=3, timeout=REQUEST_TIMEOUT)
        self.model, self.system, self.max_tokens, self.effort = model, system, max_tokens, effort
        self.tools = [{"type": "function", "name": t.name, "description": t.description or "",
                       "parameters": t.input_schema or {"type": "object", "properties": {}}} for t in tools]
        self.input: list = []
        self._texts: list[str] = []

    def user(self, text: str):
        self._texts.append(text)

    def notes(self, text: str):
        self._texts.append(f"(Your notes from the cut-off turn)\n{text}")

    def results(self, pairs):
        for c, out in pairs:
            self.input.append({"type": "function_call_output", "call_id": c.id, "output": out})

    def step(self) -> Turn:
        if self._texts:
            self.input.append({"role": "user", "content": [{"type": "input_text", "text": "\n\n".join(self._texts)}]})
            self._texts = []
        resp = self.client.responses.create(model=self.model, instructions=self.system, input=self.input,
                                            tools=self.tools, max_output_tokens=self.max_tokens, store=False,
                                            include=["reasoning.encrypted_content"],
                                            reasoning={"effort": self.effort, "summary": "auto"})
        self.input.extend(resp.output)
        text, reasoning, calls = "", "", []
        for item in resp.output:
            if item.type == "message":
                text += "".join(getattr(c, "text", "") for c in item.content)
            elif item.type == "reasoning":
                reasoning += "\n".join(getattr(s, "text", "") for s in (getattr(item, "summary", None) or []))
            elif item.type == "function_call":
                args, err = _args(item.arguments)
                calls.append(Call(item.call_id, item.name, args, err, item.arguments or ""))
        u = resp.usage
        return Turn(text, reasoning, calls, resp.status or "", {"input": getattr(u, "input_tokens", 0) or 0,
                                                                 "output": getattr(u, "output_tokens", 0) or 0})


class ChatAgent:
    """OpenAI-compatible chat completions, streamed (long generations stall behind the HF router otherwise)."""

    def __init__(self, model: str, tools, system: str, max_tokens: int, base_url: str, api_key: str,
                 effort: str = "default"):
        from openai import OpenAI

        self.client = OpenAI(base_url=base_url, api_key=api_key, max_retries=3, timeout=REQUEST_TIMEOUT)
        # "org/model:prov1|prov2" -> try prov1, fall back to prov2 on a rejected request
        name, _, provs = model.rpartition(":") if ":" in model else (model, "", "")
        chain = [f"{name}:{p}" for p in provs.split("|")] if provs and "|" in provs else [model]
        self.model, self.fallbacks, self.provider_switches = chain[0], chain[1:], []
        self.max_tokens = max_tokens
        self.extra = {"reasoning_effort": effort} if effort in ("low", "medium", "high") else {}
        self.tools = [{"type": "function", "function": {"name": t.name, "description": t.description or "",
                                                        "parameters": t.input_schema or {"type": "object", "properties": {}}}}
                      for t in tools]
        self.messages: list[dict] = [{"role": "system", "content": system}]
        self._texts: list[str] = []

    def _create(self):
        """Stream a completion; on a request the provider rejects or fails (context too long, 4xx, 5xx - not 429), move on
        to the next provider in the spec (`org/model:prov1|prov2`) for the rest of the episode."""
        import openai

        while True:
            try:
                return self.client.chat.completions.create(
                    model=self.model, messages=self.messages, tools=self.tools, max_tokens=self.max_tokens,
                    stream=True, stream_options={"include_usage": True}, **self.extra)
            except (openai.BadRequestError, openai.UnprocessableEntityError, openai.APIStatusError) as e:
                status = getattr(e, "status_code", 0) or 0
                if not self.fallbacks or status == 429:
                    raise
                self.provider_switches.append(f"{self.model} -> {self.fallbacks[0]} ({status}: {str(e)[:120]})")
                self.model = self.fallbacks.pop(0)

    def user(self, text: str):
        self._texts.append(text)

    def notes(self, text: str):
        # The cut-off turn left an empty assistant message; its reasoning becomes that message's content.
        if self.messages and self.messages[-1]["role"] == "assistant" and not self.messages[-1].get("content"):
            self.messages[-1]["content"] = f"(notes so far, cut off by the output limit)\n{text}"
        else:
            self._texts.append(f"(Your notes from the cut-off turn)\n{text}")

    def results(self, pairs):
        for c, out in pairs:
            self.messages.append({"role": "tool", "tool_call_id": c.id, "content": out})

    def step(self) -> Turn:
        if self._texts:
            self.messages.append({"role": "user", "content": "\n\n".join(self._texts)})
            self._texts = []
        stream = self._create()
        content, reasoning, calls, stop, usage = [], [], {}, "", {"input": 0, "output": 0}
        # Some providers ignore max_tokens (Novita served 65-75k-token turns for Qwen3.8); cap it here by characters.
        budget, seen = self.max_tokens * 5, 0
        for chunk in stream:
            if seen > budget:
                stop = "length (harness cap)"
                stream.close()
                break
            if getattr(chunk, "usage", None):
                usage = {"input": chunk.usage.prompt_tokens or 0, "output": chunk.usage.completion_tokens or 0}
            if not chunk.choices:
                continue
            ch = chunk.choices[0]
            d = ch.delta
            if ch.finish_reason:
                stop = ch.finish_reason
            if d is None:
                continue
            if d.content:
                content.append(d.content)
                seen += len(d.content)
            extra = getattr(d, "model_extra", None) or {}
            r = getattr(d, "reasoning_content", None) or extra.get("reasoning_content") or extra.get("reasoning")
            if isinstance(r, str):
                reasoning.append(r)
                seen += len(r)
            for tc in d.tool_calls or []:
                e = calls.setdefault(tc.index or 0, {"id": "", "name": "", "args": ""})
                if tc.id:
                    e["id"] = tc.id
                if tc.function and tc.function.name:
                    e["name"] = tc.function.name if not e["name"] else e["name"]
                if tc.function and tc.function.arguments:
                    e["args"] += tc.function.arguments
        text = "".join(content)
        think = "".join(reasoning)
        if not think and "</think>" in text:
            think, text = text.split("</think>", 1)
            think = think.replace("<think>", "")
        out = []
        for i in sorted(calls):
            e = calls[i]
            args, err = _args(e["args"])
            out.append(Call(e["id"] or f"call_{uuid.uuid4().hex[:8]}", e["name"], args, err, e["args"]))
        msg: dict[str, Any] = {"role": "assistant", "content": text}
        if out:
            msg["tool_calls"] = [{"id": c.id, "type": "function", "function": {"name": c.name, "arguments": c.raw or "{}"}}
                                 for c in out]
        self.messages.append(msg)
        return Turn(text, think, out, stop, usage)


def make_agent(spec: str, tools, system: str, max_tokens: int):
    base, _, effort = spec.partition("@")
    provider, _, model = base.partition(":")
    effort = effort or "default"
    if provider == "anthropic":
        return AnthropicAgent(model, tools, system, max_tokens, effort)
    if provider == "openai":
        return OpenAIResponsesAgent(model, tools, system, max_tokens, "medium" if effort == "default" else effort)
    if provider == "hf":
        return ChatAgent(model, tools, system, max_tokens, os.environ.get("HF_ROUTER_URL", "https://router.huggingface.co/v1"),
                         os.environ["HF_TOKEN"], effort)
    if provider == "vllm":
        return ChatAgent(model, tools, system, max_tokens, os.environ["VLLM_BASE_URL"],
                         os.environ.get("VLLM_API_KEY", "none"), effort)
    raise ValueError(f"unknown provider in {spec!r}: use anthropic:, openai:, hf: or vllm:")


# ================================================================ episode

def run_episode(spec: str, base_url: str, task_id: str, max_turns: int = 12, max_tokens: int = 24000,
                wall_clock_s: float = 1800.0) -> dict:
    env = EnvSession(base_url)
    started = time.time()
    episode_id = uuid.uuid4().hex[:12]
    record: dict[str, Any] = {"model": spec, "task_id": task_id, "episode_id": episode_id,
                              "started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(started)),
                              "messages": [], "steps": [], "final": {"submitted": False, "plan": None, "grade": None},
                              "reward": 0.0, "usage": {"input_tokens": 0, "output_tokens": 0}, "turns": 0,
                              "end_reason": None, "errors": []}
    try:
        meta = env.reset(task_id, episode_id)
        system = meta["instructions"]
        opening = OPENING.format(situation=meta["situation"])
        record["messages"] += [{"role": "system", "content": system}, {"role": "user", "content": opening}]
        agent = make_agent(spec, env.tools, system, max_tokens)
        agent.user(opening)
        empty = 0
        for turn in range(max_turns):
            if time.time() - started > wall_clock_s:
                record["end_reason"] = "wall_clock"
                break
            last = turn == max_turns - 1
            if last:
                note = "This is your last turn: call submit_plan now with your best plan."
                agent.user(note)
                record["messages"].append({"role": "user", "content": note})
            t = agent.step()
            record["turns"] += 1
            record["usage"]["input_tokens"] += t.usage.get("input", 0)
            record["usage"]["output_tokens"] += t.usage.get("output", 0)
            record["messages"].append({"role": "assistant", "content": t.text, "reasoning": t.reasoning, "stop": t.stop,
                                       "tool_calls": [{"id": c.id, "name": c.name, "arguments": c.raw or json.dumps(c.arguments)}
                                                      for c in t.calls]})
            if not t.calls:
                empty += 1
                if empty >= 2 or last:
                    record["end_reason"] = "no_tool_call"
                    break
                if t.stop.startswith("length") and t.reasoning:
                    # A turn cut off mid-thought loses its reasoning; hand the tail back as notes so the next turn
                    # continues instead of starting over.
                    agent.notes(t.reasoning[-NOTES_CHARS:])
                    nudge = ("Your last turn ran out of output tokens before calling a tool. Your notes from it are above. "
                             "Keep reasoning short now and call check_plan with your current draft, or submit_plan.")
                else:
                    nudge = ("No tool was called. Plans only count through the tools: call check_plan to test a draft "
                             "or submit_plan with your final plan.")
                agent.user(nudge)
                record["messages"].append({"role": "user", "content": nudge})
                continue
            empty = 0
            pairs, done = [], False
            for c in t.calls:
                if done:
                    out = json.dumps({"error": "the episode is over"})
                elif c.error:
                    out = json.dumps({"error": c.error})
                else:
                    out, done, reward, meta = env.call(c.name, c.arguments)
                    if c.name in ("check_plan", "submit_plan"):
                        try:
                            res = json.loads(out)
                        except json.JSONDecodeError:
                            res = {"text": out}
                        record["steps"].append({"turn": turn + 1, "tool": c.name, "plan": plan_value(c.arguments.get("plan")),
                                                "result": res})
                    if done:
                        record["reward"] = float(reward or 0.0)
                        if c.name == "submit_plan":
                            record["final"] = {"submitted": True, "plan": plan_value(c.arguments.get("plan")),
                                               "grade": meta.get("grade"), "rubric": meta.get("rubric")}
                            record["end_reason"] = "submitted"
                        else:
                            record["end_reason"] = meta.get("end_reason", "server_ended")
                left = max_turns - turn - 1
                text = out if done else f"{out}\n(turns left: {left})"
                pairs.append((c, text))
                record["messages"].append({"role": "tool", "tool_call_id": c.id, "name": c.name, "content": out})
            agent.results(pairs)
            if done:
                break
        else:
            record["end_reason"] = record["end_reason"] or "turn_limit"
        record["end_reason"] = record["end_reason"] or "turn_limit"
    except Exception as exc:  # provider or transport failure: recorded, scored as it stands
        record["errors"].append(f"{type(exc).__name__}: {str(exc)[:800]}")
        record["end_reason"] = record["end_reason"] or "error"
    finally:
        env.close()
        switches = getattr(locals().get("agent"), "provider_switches", None)
        if switches:
            record["provider_switches"] = switches
    record["seconds"] = round(time.time() - started, 1)
    return record