AdithyaSK's picture
AdithyaSK HF Staff
Upload folder using huggingface_hub
8450a08 verified
Raw
History Blame Contribute Delete
12.2 kB
"""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
@property
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