Spaces:
Sleeping
Sleeping
| """Wire types for harbor_env. | |
| Two shapes matter. `HarborTaskRef` is what the Task API hands out during discovery, one per dataset | |
| item. `HarborRolloutResult` is what one `run_rollout` returns: the reward, and enough token detail to | |
| train on. | |
| Everything here is JSON-serialisable by construction — `run_rollout` returns | |
| `result.model_dump_json()` and the client re-validates, matching how `opencode_env` and `pi_env` do | |
| it. There is no shared memory between server and client. | |
| """ | |
| from __future__ import annotations | |
| from typing import Any | |
| from openenv.core.env_server.types import State | |
| from pydantic import BaseModel, Field | |
| class HarborTaskRef(BaseModel): | |
| """One task, as returned by the Task API. What a trainer's dataset holds per row.""" | |
| index: int | |
| task_id: str | |
| task_name: str | |
| dataset: str = "" | |
| instruction: str = "" | |
| class HarborTurn(BaseModel): | |
| """One model call, captured exactly. The unit a trainer consumes. | |
| `prompt_token_ids` is the engine's own tokenisation of everything before this turn, not a local | |
| re-render. That distinction is the whole point of the capture layer: re-tokenising a prompt | |
| offline drifts from what the model actually saw (measured at 0/6 exact on Qwen3.5 until thinking | |
| was disabled), and a drifted prompt silently forks a long conversation into short fragments. | |
| """ | |
| turn: int | |
| role: str = "agent" | |
| finish_reason: str | None = None | |
| prompt_token_ids: list[int] = Field(default_factory=list) | |
| completion_token_ids: list[int] = Field(default_factory=list) | |
| per_token_logps: list[float] = Field(default_factory=list) | |
| n_tools: int = 0 | |
| discarded: bool = False | |
| # What the model actually produced, in readable form. Only the assistant's own output is kept, | |
| # never the prompt side: the prompt is already present as token ids and repeating it as text | |
| # would roughly double the payload for no new information. This is what makes a result | |
| # inspectable without a tokenizer, and it is what a reward function keys on when it needs to | |
| # know which tool was called rather than how many tokens were spent. | |
| text: str = "" | |
| tool_calls: list[dict[str, Any]] = Field(default_factory=list) | |
| class HarborConversation(BaseModel): | |
| """One complete conversation from a rollout, exactly as the harness assembled it. | |
| A rollout can contain several: a root is a conversation that started from a fresh prompt, so | |
| subagents and auxiliary calls each get their own. `messages` is the full list including the | |
| system prompt and every tool result, which is what makes a finished rollout readable rather than | |
| a column of token counts. | |
| """ | |
| root_id: str = "" | |
| role: str = "agent" # agent | auxiliary | discarded | |
| n_turns: int = 0 | |
| messages: list[dict[str, Any]] = Field(default_factory=list) | |
| class HarborStepResult(BaseModel): | |
| """One step of a multi-step task. Harbor gates progression on `min_reward`.""" | |
| name: str = "" | |
| rewards: dict[str, float] = Field(default_factory=dict) | |
| passed: bool = True | |
| class HarborRolloutResult(BaseModel): | |
| """Everything one rollout produced. | |
| A failed rollout is still a valid result: `ok=False`, `error` set, `reward=None`. Nothing raises | |
| across the server boundary, because a rollout exception reaching a trainer is what hangs every | |
| rank at the NCCL barrier forever. | |
| """ | |
| # identity | |
| task_id: str = "" | |
| task_name: str = "" | |
| dataset: str = "" | |
| harness: str = "" | |
| sandbox: str = "" | |
| trial_name: str = "" | |
| session_id: str = "" | |
| # outcome — forwarded from Harbor's verifier, never recomputed here | |
| reward: float | None = None | |
| rewards: dict[str, float] = Field(default_factory=dict) | |
| reward_key: str = "" | |
| step_results: list[HarborStepResult] = Field(default_factory=list) | |
| # capture | |
| turns: list[HarborTurn] = Field(default_factory=list) | |
| conversations: list[HarborConversation] = Field(default_factory=list) | |
| n_turns: int = 0 | |
| n_roots: int = 0 | |
| n_trainable_tokens: int = 0 | |
| multi_turn: bool = False | |
| atif: str = "none" | |
| # Which independent record the capture was checked against: `atif` for a harness trajectory, | |
| # a reader name (e.g. `pi_session`) when the harness records the same thing under another | |
| # format, or empty when it records nothing comparable. | |
| trace_source: str = "" | |
| findings: list[str] = Field(default_factory=list) | |
| # timings and diagnostics. There is no metrics endpoint and no structured logging in the env | |
| # server, so observability has to ride back inside the payload or it does not exist. | |
| wall_s: float = 0.0 | |
| phase_timings: dict[str, float] = Field(default_factory=dict) | |
| agent_log_tail: str = "" | |
| # failure | |
| ok: bool = True | |
| error: str | None = None | |
| exception_type: str | None = None | |
| def solved(self) -> bool: | |
| """Graded AND positive. `reward is None` means the verifier never ran, which is not a zero.""" | |
| return self.reward is not None and self.reward > 0 | |
| class HarborState(State): | |
| """Per-session counters. Mutated inside the tool, since `step` only dispatches.""" | |
| rollouts_completed: int = 0 | |
| last_reward: float | None = None | |
| last_task_id: str | None = None | |
| last_trial_name: str | None = None | |
| llm_url: str = "" | |
| intercept_url: str = "" | |
| def _assistant_text(response: dict[str, Any]) -> str: | |
| """The assistant's own words, flattened across the shapes the four dialects produce.""" | |
| content = response.get("content") | |
| if isinstance(content, list): # anthropic / responses send block lists | |
| return " ".join( | |
| part.get("text", "") | |
| for part in content | |
| if isinstance(part, dict) and part.get("text") | |
| ) | |
| return content if isinstance(content, str) else "" | |
| def _tool_calls(response: dict[str, Any]) -> list[dict[str, Any]]: | |
| """Tool calls as `{name, arguments}`, normalised across dialects. | |
| Kept as data rather than a rendered string: a reward function that wants to check which tool ran | |
| should not have to parse a display format. | |
| """ | |
| out: list[dict[str, Any]] = [] | |
| for call in response.get("tool_calls") or []: | |
| function = call.get("function") or {} | |
| name = function.get("name") or call.get("name") | |
| if not name: | |
| continue | |
| out.append( | |
| { | |
| "name": str(name), | |
| "arguments": function.get("arguments", call.get("arguments", "")), | |
| } | |
| ) | |
| # Anthropic does not use `tool_calls`: it puts tool use in the content block list. Reading only | |
| # the chat-completions shape leaves claude-code's actions out of the result entirely, so | |
| # `contract.json` and the rendered conversation both show it as a stream of text that did | |
| # nothing. | |
| content = response.get("content") | |
| if isinstance(content, list): | |
| for block in content: | |
| if ( | |
| isinstance(block, dict) | |
| and block.get("type") == "tool_use" | |
| and block.get("name") | |
| ): | |
| out.append( | |
| {"name": str(block["name"]), "arguments": block.get("input", "")} | |
| ) | |
| return out | |
| def conversations_from_document(document: dict[str, Any]) -> list[HarborConversation]: | |
| """Rebuild the full conversations, system prompt and tool results included. | |
| The deepest node of a chain already carries the whole conversation in `request_messages`, since | |
| each call replays everything before it. So the last node per root plus its own response is the | |
| complete transcript, with no stitching and no risk of drifting from what was actually sent. | |
| """ | |
| by_node = {t["node_id"]: t for t in document.get("turns", [])} | |
| # One per root, not one per sequence. A fork produces several paths through the same root, and | |
| # each replays the same conversation up to the branch point, so emitting one per sequence shows | |
| # the reader near-identical transcripts and calls both of them the main conversation. The | |
| # longest path is the complete one. | |
| best: dict[str, HarborConversation] = {} | |
| for sequence in document.get("sequences", []): | |
| node_ids = sequence.get("node_ids") or [] | |
| if not node_ids: | |
| continue | |
| last = by_node.get(node_ids[-1], {}) | |
| messages = list(last.get("request_messages") or []) | |
| response = last.get("response_message") or {} | |
| if response: | |
| messages.append({**response, "role": response.get("role", "assistant")}) | |
| if not messages: | |
| continue | |
| root_id = str(sequence.get("root_id", "")) or node_ids[0] | |
| candidate = HarborConversation( | |
| root_id=root_id, | |
| role=str(sequence.get("role", "agent")), | |
| n_turns=int(sequence.get("n_turns", len(node_ids))), | |
| messages=messages, | |
| ) | |
| current = best.get(root_id) | |
| if current is None or len(candidate.messages) > len(current.messages): | |
| best[root_id] = candidate | |
| return list(best.values()) | |
| def turns_from_document(document: dict[str, Any]) -> list[HarborTurn]: | |
| """Flatten a capture document into per-turn training rows. | |
| Only `agent` sequences become turns. Auxiliary calls are dropped here rather than marked, | |
| because they are not the agent working on the task and must never carry its reward — a | |
| next-speaker classification credited with solving a task is a reward-hacking gift. | |
| """ | |
| by_node = {t["node_id"]: t for t in document.get("turns", [])} | |
| rows: list[HarborTurn] = [] | |
| # Forked paths share their prefix, and each live path is exported as its own sequence, so the | |
| # same node appears in more than one of them. Emit each node once: a duplicated row is the same | |
| # model call credited twice, which quietly doubles its weight in the gradient. | |
| seen: set[str] = set() | |
| index = 0 | |
| for sequence in document.get("sequences", []): | |
| if sequence.get("role") != "agent": | |
| continue | |
| input_ids = sequence["input_ids"] | |
| logprobs = sequence["logprobs"] | |
| for node_id in sequence["node_ids"]: | |
| if node_id in seen: | |
| continue | |
| seen.add(node_id) | |
| node = by_node.get(node_id, {}) | |
| response = node.get("response_message") or {} | |
| # Each turn's own counts, not a walk over runs of the loss mask. A sequence is built as | |
| # (context, sampled) per node, so the cumulative offset where a turn's sampled tokens | |
| # begin is exactly the length of that turn's prompt, which the document already records. | |
| # | |
| # The previous version zipped `node_ids` against `turn_lengths`, where `turn_lengths` | |
| # counts runs of mask-1. A turn whose logprobs were missing contributes mask-0 and so no | |
| # run at all, which made the two lists different lengths: `zip` then stopped early and | |
| # every turn after the bad one was dropped or attributed to the wrong node. Turns with | |
| # no context between them merged into one run for the same reason. | |
| n_prompt = int(node.get("n_prompt", 0)) | |
| n_sampled = int(node.get("n_sampled", 0)) | |
| end = n_prompt + n_sampled | |
| rows.append( | |
| HarborTurn( | |
| turn=index, | |
| finish_reason=node.get("finish_reason"), | |
| # Every turn, not just the first. This is the engine's own tokenisation of | |
| # everything the model saw before it generated, which is what the training | |
| # contract promises and what a per-turn trainer consumes. | |
| prompt_token_ids=input_ids[:n_prompt], | |
| completion_token_ids=input_ids[n_prompt:end], | |
| per_token_logps=logprobs[n_prompt:end], | |
| n_tools=node.get("n_tools", 0), | |
| discarded=bool(node.get("discarded")), | |
| text=_assistant_text(response), | |
| tool_calls=_tool_calls(response), | |
| ) | |
| ) | |
| index += 1 | |
| return rows | |