diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 05ec9437..11bf9d51 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -137,3 +137,14 @@ jobs:
exit 1
fi
grep -q "1 passed" /tmp/drift.txt
+
+ opencollab:
+ name: OpenCollab integration
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: pip install -e ".[opencollab,dev]"
+ - run: python -m pytest tests/worker/agents/opencollab -q -m "not slow"
diff --git a/README.md b/README.md
index d25eea47..8f208c73 100644
--- a/README.md
+++ b/README.md
@@ -132,6 +132,8 @@ worker of step 3. The worker grades
[SWE-rebench V2](https://github.com/SWE-rebench/SWE-rebench-V2) tasks with a port of its official
harness.
+OpenCollab Single2 is an optional agent slot; see [OpenCollab setup and RL integration](docs/OPENCOLLAB.md).
+
## Learn more
| Guide | What it covers |
diff --git a/docs/OPENCOLLAB.md b/docs/OPENCOLLAB.md
new file mode 100644
index 00000000..ce448be1
--- /dev/null
+++ b/docs/OPENCOLLAB.md
@@ -0,0 +1,134 @@
+# OpenCollab
+
+Sprout can run OpenCollab's Single2 agent through the same worker, sandbox,
+checkpoint, search and training-export interfaces as mini-swe-agent. The default
+slot remains `mini-swe-agent`. Select `opencollab-base` to run Single2.
+
+## Install
+
+```bash
+pip install -e ".[opencollab,harbor,runstore,rl,dev]"
+```
+
+The optional extra pins the public OpenCollab commit
+`c69a2076f4b54cc97891df432bf81273e3c5ebf1` (SDK version 0.9.3).
+It needs Git and network access at installation. The adapter does not modify the
+SDK. Without this extra, mini, oracle, nop and the service clients still import
+and run normally; SDK-dependent tests skip. CI installs the extra in a separate job.
+
+Use the normal host setup and services from [README](../README.md). Branching
+requires a snapshot backend, such as rootless Podman on a btrfs store.
+
+## Run and evaluate
+
+A direct run uses the worker's existing entry point:
+
+```bash
+export OPENAI_BASE_URL=http://127.0.0.1:8000/v1
+export OPENAI_API_KEY=local
+python -m sprout.worker run --slot opencollab-base --model MODEL \
+ --sandbox-image docker.io/library/python:3.11-slim --cwd /tmp \
+ --runtime-bin runtime/sprout-runtime --journal runs/opencollab.jsonl \
+ "Write and test fizzbuzz.py"
+```
+
+Evaluation goes through the Run Store and a running worker, as in
+[EVALUATION](EVALUATION.md). Add `--slot opencollab-base` to its existing command.
+Use the current upstream's fully prepared task images. For older Harbor bundles,
+prepare their declared environment setup and standard logging/artifact directories
+in the image before scheduling jobs; this adapter does not restore the removed
+Harbor trial runner.
+
+The task's workdir is resolved inside the sandbox before the agent starts, and
+recorded with its repository root. A branch restores that physical directory,
+not the host directory containing its logs.
+
+Single2 receives its native tools (`bash`, `file_read`, `file_write`, `apply_patch`,
+`git_diff`, `grep`). These are SDK tools backed by Sprout's `ToolAccess`; the Go
+runtime toolset is unchanged. Every environment operation runs in the owned
+sandbox, as the image's configured user. File writes use bounded, verified chunks.
+
+## Checkpoints and branches
+
+A recovery point pairs a completed runtime turn's disk snapshot with a
+checksummed native transcript prefix and the corresponding SDK session state.
+The worker restores the prefix and SDK state before executing the continuation.
+A different slot, protocol, checksum or workspace is rejected.
+
+The SDK uses non-streaming Chat Completions through Sprout's metered gateway.
+Native context shaping is recorded as the actual replacement context. Recovery
+point indexing only admits closed tool-call groups with matching snapshots.
+Cancellation waits for an owned runtime command to settle; unknown execution or
+checkpoint failure stops the run and remains an infrastructure failure.
+
+SPROUT still chooses branch points and guidance through its existing search and
+review jobs. Flat reviewer commands are serialized as the selected parent's
+actual command tool. Nested reviewer calls are validated against that parent's
+recorded schemas, with unique ids and no reviewer-authored submission.
+Both ordinary-content and reasoning-content continuations are supported.
+
+## RL messages
+
+In the RL driver's existing `miles.run_defaults`, select:
+
+```json
+{
+ "slot": "opencollab-base",
+ "extra": {
+ "mini": {"environment": {"cwd": "/workspace"}}
+ }
+}
+```
+
+`mini.environment.cwd` is the existing worker workspace configuration key,
+shared by these slots. The rest of the driver, worker profiles and task grader
+configuration follows [RL_DRIVER](RL_DRIVER.md). Set resources, endpoints and
+paths for your host. Search uses the current upstream request's `search` field
+and its documented variable-size group semantics; this adapter does not pad
+successful searches or change their stopping policy.
+
+The worker exports the SDK's actual messages and tool schemas, plus provenance:
+restored prefix messages, reviewer-authored assistant turns, and SDK-generated
+submission receipts. The existing driver excludes these from assistant loss.
+The sampled submission action remains a policy target. Real tool replies stay
+paired with their calls. Original task verifiers provide rewards; grader or
+transport failure is never fabricated as reward zero.
+
+Miles owns tokenization, assistant token masks, advantages, loss, optimization,
+checkpoints and serving-weight synchronization. No trainer is included here.
+Use a Miles client compatible with the current upstream driver's search request
+and result contract; older fixed-size Ash clients need their upstream update.
+
+## Historical tool compatibility
+
+For external trajectories recorded with `run_command`, `read_file`, `edit_file`,
+`write_file`, `get_status` and `submit_patch`, explicitly select
+`opencollab_tool_protocol: review50-v1` in the slot's `extra`. Evaluation exposes
+`--opencollab-tool-protocol review50-v1`. This compatibility mode still runs the
+Single2 session loop. It does not require or distribute the review50 dataset.
+
+Configure the actual checkout as the task workdir or `mini.environment.cwd`.
+Compatibility mode exposes that checkout at `/workspace` before the first model
+action; it refuses an existing alias pointing elsewhere.
+`submit_patch` produces a real diff receipt and records the SDK submission fact;
+it terminates through native Single2. The optional `/opt/review50-baseline`
+marker names the task's prepared Git baseline; otherwise the diff uses `HEAD`.
+Official grading remains separate. `get_status` reports the actual working-tree
+state, deadline and enforced model-turn count. Tool calls are counted and have
+no numeric budget limit; an absent limit is `null`.
+
+## Validation
+
+```bash
+python -m pytest tests/worker/agents/opencollab -q
+python -m pytest -q -m "not slow"
+./gate/run.sh
+python ops/contracts/ci_check.py
+```
+
+The integration tests exercise real Single2 sessions against a scripted model
+and an isolated shell fixture: multi-tool turns, snapshots, native restoration,
+reviewer injection, context shaping, submission, training provenance, large
+writes, cancellation and infrastructure failures. They do not claim policy
+learning or benchmark performance. GPU, task images and snapshot storage are
+external prerequisites for live RL; none is required by ordinary unit tests.
diff --git a/docs/RL_DRIVER.md b/docs/RL_DRIVER.md
index 017ff897..125b9bcc 100644
--- a/docs/RL_DRIVER.md
+++ b/docs/RL_DRIVER.md
@@ -62,7 +62,7 @@ return 503. These fields need care:
| --- | --- |
| `driver_token_env` | The variable of the callers' bearer token. `null` turns authentication off. The example config sets `null`, so use it only for local access. |
| `ledger` | Required. A relative path starts from the directory of the config file. |
-| `miles.run_defaults` | RunSpec fields for each root rollout. `slot` must be `mini-swe-agent`. `model` applies when the request names none. |
+| `miles.run_defaults` | RunSpec fields for each root rollout. `slot` is `mini-swe-agent` or `opencollab-base` ([OpenCollab](OPENCOLLAB.md)). `model` applies when the request names none. |
| `miles.image_resources` | Required: the `cpu` and the `memory_mb` of each sandbox. |
| `miles.tasks` | For each `task_id`, a `grade` job template without `snapshot_id`. The adapter refuses a request for a task without one. |
| `miles.api_key_env` | The variable in the worker's environment that holds the key of the model endpoint. The worker profile's `worker_env` must set it. |
diff --git a/pyproject.toml b/pyproject.toml
index 2bb557a8..fc70adc2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,7 @@ dependencies = [
[project.optional-dependencies]
# The agent and the trial runner the evaluations pin.
mini = ["mini-swe-agent==2.4.6"]
+opencollab = ["opencollab @ git+https://github.com/RISE-X-Lab/OpenCollab.git@c69a2076f4b54cc97891df432bf81273e3c5ebf1"]
harbor = ["harbor==0.22.0"]
# The Run Store queue and the RL driver.
runstore = ["psycopg2-binary>=2.9,<3", "fastapi>=0.110,<1", "uvicorn>=0.29,<1"]
@@ -29,6 +30,9 @@ docs = ["fonttools>=4", "uharfbuzz>=0.39"]
[project.urls]
Homepage = "https://github.com/dreamyang-liu/sprout"
+[tool.hatch.metadata]
+allow-direct-references = true
+
[tool.hatch.build.targets.wheel]
packages = ["src/sprout", "src/sprout_sandbox"]
@@ -61,6 +65,10 @@ warn_unreachable = true
module = ["harbor.*"]
ignore_missing_imports = true
+[[tool.mypy.overrides]]
+module = ["opencollab.*"]
+ignore_missing_imports = true
+
[tool.pytest.ini_options]
pythonpath = ["src", "."]
testpaths = ["tests"]
diff --git a/resources/opencollab/review50_protocol.json b/resources/opencollab/review50_protocol.json
new file mode 100644
index 00000000..0ec423ad
--- /dev/null
+++ b/resources/opencollab/review50_protocol.json
@@ -0,0 +1,122 @@
+{
+ "name": "review50-v1",
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "run_command",
+ "description": "Execute a shell command inside the repository sandbox.\n\nYou can run any shell command (Python scripts, tests, git commands, etc.).\nThe command runs in the repository workspace. No network access.\n\nArgs:\n command: Shell command to execute (e.g., \"pytest tests/\", \"python3 script.py\").",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "command": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "command"
+ ]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "read_file",
+ "description": "Read the contents of a file from the repository workspace.\n\nSupports reading specific line ranges using start_line and end_line (1-indexed).\nTo avoid overwhelming the context window, large files are automatically truncated\nto a maximum number of lines (default: 150 lines).\n\nArgs:\n filepath: Relative path within the workspace directory (e.g., \"fastapi/main.py\", \"pyproject.toml\").\n start_line: Optional starting line number (1-indexed, inclusive).\n end_line: Optional ending line number (1-indexed, inclusive).\n\nReturns:\n Dictionary containing file content, line range, and truncation status.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "filepath": {
+ "type": "string"
+ },
+ "start_line": {
+ "type": "integer"
+ },
+ "end_line": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "filepath"
+ ]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "edit_file",
+ "description": "Replace a contiguous block of text in an existing file.\n\nMore token-efficient than write_file for targeted changes. Provide 2-4 lines of unique\nsurrounding context in old_string to ensure an unambiguous match.\n\nArgs:\n filepath: Relative path to the target file.\n old_string: Text block to replace.\n new_string: Replacement text block.\n allow_multiple: Replace all occurrences of old_string if True.\n\nReturns:\n Dictionary containing file status, occurrences count, match strategy, and unified diff.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "filepath": {
+ "type": "string"
+ },
+ "old_string": {
+ "type": "string"
+ },
+ "new_string": {
+ "type": "string"
+ },
+ "allow_multiple": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "required": [
+ "filepath",
+ "old_string",
+ "new_string"
+ ]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "write_file",
+ "description": "Create or overwrite a file in the workspace.\n\nArgs:\n filepath: Relative path to the file to create or overwrite.\n content: Full content of the file.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "filepath": {
+ "type": "string"
+ },
+ "content": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "filepath",
+ "content"
+ ]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "get_status",
+ "description": "Check active budget details (time, tool calls) and active patch details.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "submit_patch",
+ "description": "Capture the current working tree, including new files, as the final submission and end this agent session. The runtime returns a real acknowledgement. Official grading runs separately; submission does not assert that the fix passes.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ }
+ }
+ }
+ ],
+ "system_prompt": "You are an expert software engineer working autonomously in a sandboxed Python repository at `/workspace`. Your goal is to resolve the issue in the problem statement with a minimal, correct change to the source code, verify it, and submit it.\n\n\n* `/workspace` is a git checkout. Its current state is the baseline your patch is measured against.\n* The sandbox is offline and all dependencies are already installed. Do not install packages.\n* `run_command` runs each command in a fresh `/bin/bash -c` inside `/workspace`. The current directory, environment variables and activated environments do not carry over between calls, so start a command with `cd && ...` when it needs a specific directory, and chain dependent steps with `&&`.\n* A command is stopped after 300 seconds. Do not start servers or interactive programs that wait for input.\n* Only the first 5000 characters of a command's output are returned. When output may be long, narrow it with `grep`, `head`, `tail`, or quieter test flags such as `-q` and `--tb=short`.\n* `read_file` returns at most 150 lines or 10000 characters per call, along with `total_lines` and `is_truncated`. Use `start_line` and `end_line` to read the part you need.\n* `edit_file` replaces `old_string` with `new_string`. `old_string` must match the file and be unique unless `allow_multiple` is true, so include enough surrounding lines to identify the location.\n* `write_file` creates or overwrites a whole file. Use `edit_file` to change existing files.\n* File tool paths are relative to `/workspace`.\n\n\n\n* Do not modify the existing tests or the test configuration (`tests/`, `test_*.py`, `*_test.py`, `conftest.py`, `pytest.ini`, `setup.cfg`, `pyproject.toml`, `tox.ini`). Graders reset these files and run their own tests, so changes to them are discarded.\n* Make the smallest change to non-test source files that satisfies the problem statement. Keep existing behavior and public interfaces unless the issue asks for a change.\n* Base every conclusion on output you have actually seen. If an output was truncated, look at the missing part before relying on it.\n* When the fix is complete and verified, call `submit_patch`. It records the `git diff` of `/workspace`, including new files.\n\n\n\nWork through these phases, keeping each one as short as the task allows:\nPhase 1. READING: Restate the problem in clear terms. Note the error messages, names, file paths and expected behavior it mentions, and how to reproduce it.\nPhase 2. RUNNING: Find out how the project's tests are run (README, existing test files) and run the relevant ones.\nPhase 3. EXPLORATION: Locate the code involved with `grep` and `find`. List the candidate files and functions, and choose the most likely place to fix.\nPhase 4. TEST CREATION: Write a minimal script that reproduces the issue, and run it to confirm the failure.\nPhase 5. FIX ANALYSIS: State what is wrong, where it is, and how you will fix it.\nPhase 6. FIX IMPLEMENTATION: Make a minimal, focused change.\nPhase 7. VERIFICATION: Rerun the reproduction script, add edge cases, and run the existing tests for the code you changed.\nPhase 8. FINAL REVIEW: Re-read the problem statement, confirm every requirement is met and the related tests pass, then submit.\n\n\n\n* Understand the relevant code before changing it, and fix the root cause rather than the symptom.\n* Combine related shell steps into one command to save tool calls, and prefer targeted searches (`grep -n`, filtered `find`) over reading whole directories.\n* Edit files in place instead of creating copies under new names.\n* Match the surrounding code style and avoid redundant comments.\n* If repeated attempts fail, step back, list the plausible causes, and test the most likely one first.\n\n\n\nThe runtime provides the six tools listed in this conversation. A successful submit_patch call captures the current working tree and ends the session after a real tool acknowledgement. Do not commit or reset the working tree. Submission is separate from official grading and does not imply success. If you end with a final response without tools, the caller still grades the current working tree.\n"
+}
diff --git a/src/sprout/contract/agents/__init__.py b/src/sprout/contract/agents/__init__.py
new file mode 100644
index 00000000..d050d557
--- /dev/null
+++ b/src/sprout/contract/agents/__init__.py
@@ -0,0 +1 @@
+"""Native agent conversation formats shared by clients and workers."""
diff --git a/src/sprout/contract/agents/history.py b/src/sprout/contract/agents/history.py
new file mode 100644
index 00000000..03c3a5f7
--- /dev/null
+++ b/src/sprout/contract/agents/history.py
@@ -0,0 +1,68 @@
+"""Dispatch native history operations without importing either agent SDK."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping, Sequence
+from pathlib import Path
+from typing import Any
+
+from sprout.contract import mini_history
+from sprout.contract.agents import opencollab
+
+
+def load_prefix(reference: Mapping[str, Any]) -> list[dict[str, Any]]:
+ """Read a checked prefix, using its recorded native format."""
+ with Path(reference["path"]).open("rb") as stream:
+ header = json.loads(stream.readline())
+ is_oc = header.get("format") == opencollab.FORMAT
+ if reference.get("slot") is not None and ((reference["slot"] == opencollab.SLOT) != is_oc):
+ raise ValueError("Native prefix slot differs from its recorded format")
+ if is_oc:
+ return opencollab.load_prefix(reference)
+ return mini_history.load_prefix(reference)
+
+
+def training_messages(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """The conversation represented by a native transcript, including SDK context shaping."""
+ if entries and entries[0].get("format") == opencollab.FORMAT:
+ return opencollab.training_messages(entries)
+ return mini_history.training_messages(entries)
+
+
+def materialize(
+ reference: Mapping[str, Any], cwd: Path, destination: Path, native_home: Path
+) -> dict[str, Any]:
+ """Copy a checked native prefix into an attempt-owned directory."""
+ if reference["slot"] != opencollab.SLOT:
+ return mini_history.materialize(reference, cwd, destination, native_home)
+ data = mini_history.read_prefix(reference)
+ load_prefix(reference)
+ destination.mkdir(parents=True, exist_ok=False)
+ path = destination / "prefix.jsonl"
+ path.write_bytes(data)
+ return {
+ "native_prefix": {"path": str(path.resolve()), "sha256": reference["sha256"], "slot": opencollab.SLOT}
+ }
+
+
+def validate_assistant_turn(
+ message: object,
+ *,
+ history: Sequence[Mapping[str, Any]] = (),
+ tools: list[dict[str, Any]] | None = None,
+ slot: str = "mini-swe-agent",
+ allow_reasoning: bool = False,
+) -> dict[str, Any]:
+ """Validate a reviewer turn against the selected actor's tool contract."""
+ if slot == opencollab.SLOT:
+ if tools is None:
+ raise ValueError("OpenCollab continuation requires recorded tool schemas")
+ return opencollab.validate_assistant_turn(
+ message, history=history, tools=tools, allow_reasoning=allow_reasoning
+ )
+ if slot != "mini-swe-agent":
+ raise ValueError("Unsupported native history slot")
+ return mini_history.validate_assistant_turn(
+ message, history=history, tools=tools, allow_reasoning=allow_reasoning
+ )
diff --git a/src/sprout/contract/agents/opencollab.py b/src/sprout/contract/agents/opencollab.py
new file mode 100644
index 00000000..7728612c
--- /dev/null
+++ b/src/sprout/contract/agents/opencollab.py
@@ -0,0 +1,96 @@
+"""OpenCollab native prefixes and reviewer turns, without importing the SDK."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from copy import deepcopy
+from pathlib import Path
+from typing import Any, cast
+
+SLOT = "opencollab-base"
+FORMAT = "sprout-opencollab-v1"
+
+
+def training_messages(rows: Any) -> list[dict[str, Any]]:
+ messages = []
+ for row in rows:
+ if row.get("type") == "oc.context":
+ messages = deepcopy(row["messages"])
+ elif row.get("type") == "oc.message":
+ messages.append(deepcopy(row["message"]))
+ return messages
+
+
+def load_prefix(reference: Any) -> list[dict[str, Any]]:
+ with Path(reference["path"]).open("rb") as stream:
+ data = stream.read(reference.get("byte_length", -1))
+ if not data.endswith(b"\n") or hashlib.sha256(data).hexdigest() != reference["sha256"]:
+ raise ValueError("OpenCollab native prefix checksum changed")
+ rows = [json.loads(line) for line in data.splitlines() if line.strip()]
+ if (
+ not rows
+ or rows[0].get("format") != FORMAT
+ or rows[0].get("profile") != "single2"
+ or rows[-1].get("type") != "oc.turn"
+ ):
+ raise ValueError("OpenCollab restore requires a closed Single2 turn")
+ return rows
+
+
+def validate_assistant_turn(
+ message: object, *, history: Any = (), tools: list[dict[str, Any]], allow_reasoning: bool = False
+) -> dict[str, Any]:
+ """Validate new reviewer calls against the recorded schemas, preserving mini's separate contract."""
+ from jsonschema import Draft202012Validator, ValidationError
+
+ fields = {"role", "content", "tool_calls"}
+ if not isinstance(message, dict) or set(message) not in (
+ fields,
+ fields | {"reasoning_content"} if allow_reasoning else fields,
+ ):
+ raise ValueError("assistant_turn requires content and tool calls")
+ content = message["content"]
+ reasoning = message.get("reasoning_content")
+ if message["role"] != "assistant" or not isinstance(content, str):
+ raise ValueError("assistant_turn requires assistant role and text content")
+ if reasoning is not None and (
+ not allow_reasoning or not isinstance(reasoning, str) or not reasoning.strip() or "\n" in content
+ ):
+ raise ValueError("Invalid reviewer reasoning content")
+ if not content.strip() and not reasoning:
+ raise ValueError("assistant_turn requires non-empty content or reasoning")
+ calls = message["tool_calls"]
+ if not isinstance(calls, list) or not calls:
+ raise ValueError("assistant_turn requires tool calls")
+ schemas = {tool["function"]["name"]: tool["function"]["parameters"] for tool in tools}
+ used = {call["id"] for row in history for call in row.get("tool_calls") or []}
+ for call in calls:
+ if (
+ not isinstance(call, dict)
+ or set(call) != {"id", "type", "function"}
+ or call["type"] != "function"
+ ):
+ raise ValueError("Invalid reviewer tool call")
+ if not isinstance(call["id"], str) or not call["id"].strip() or call["id"] in used:
+ raise ValueError("Reviewer tool call ids must be unique across history")
+ used.add(call["id"])
+ function = call["function"]
+ if (
+ not isinstance(function, dict)
+ or set(function) != {"name", "arguments"}
+ or function["name"] not in schemas
+ ):
+ raise ValueError("Reviewer call names an unavailable Single2 tool")
+ if not isinstance(function["arguments"], str):
+ raise ValueError("Reviewer tool arguments must be a JSON string")
+ arguments = json.loads(function["arguments"])
+ schema = deepcopy(schemas[function["name"]])
+ schema["additionalProperties"] = False
+ try:
+ Draft202012Validator(schema).validate(arguments)
+ except ValidationError as error:
+ raise ValueError(
+ "Reviewer arguments violate the recorded tool schema: " + error.message
+ ) from error
+ return cast(dict[str, Any], deepcopy(message))
diff --git a/src/sprout/contract/continuation.py b/src/sprout/contract/continuation.py
index 2ca31423..8be6a979 100644
--- a/src/sprout/contract/continuation.py
+++ b/src/sprout/contract/continuation.py
@@ -26,7 +26,7 @@
from dataclasses import dataclass
from typing import Any
-from sprout.contract.mini_history import validate_assistant_turn
+from sprout.contract.agents.history import validate_assistant_turn
#: The values of ``Continuation.guidance``.
GUIDANCE = ("assistant-turn", "user-hint", "none")
@@ -154,8 +154,17 @@ def prepare(
"""
# Continuation ties each field to its guidance: assistant_turn to assistant-turn, hint to user-hint.
if continuation.assistant_turn is not None:
- history = [entry["message"] for entry in prefix if entry.get("type") == "mini.message"]
- turn = validate_assistant_turn(continuation.assistant_turn, history=history, tools=tools)
+ from sprout.contract.agents.history import training_messages
+
+ history = training_messages([dict(entry) for entry in prefix])
+ if prefix and prefix[0].get("type") == "oc.session":
+ tools = tools or prefix[0]["tools"]
+ turn = validate_assistant_turn(
+ continuation.assistant_turn,
+ history=history,
+ tools=tools,
+ slot="opencollab-base" if prefix and prefix[0].get("type") == "oc.session" else "mini-swe-agent",
+ )
if continuation.assistant_turn_rendering == "reasoning":
turn = render_reasoning(turn)
return "", {"assistant_turn": turn}
diff --git a/src/sprout/contract/run_spec.py b/src/sprout/contract/run_spec.py
index 5f689071..3c0d9553 100644
--- a/src/sprout/contract/run_spec.py
+++ b/src/sprout/contract/run_spec.py
@@ -15,8 +15,8 @@ class RunSpec:
"""Everything one run needs. ``python -m sprout.worker run`` sets the fields that a person types."""
prompt: str
- #: The harness that a ``mini`` agent runs in. ``mini-swe-agent`` is the only one. The runner
- #: refuses any other name before it makes a sandbox.
+ #: The model agent implementation: ``mini-swe-agent`` or ``opencollab-base``.
+ #: The runner refuses other names before it makes a sandbox.
slot: str = "mini-swe-agent"
#: What the run does in its sandbox: ``mini`` runs the model in ``slot``,
#: ``oracle`` runs the ``solution/solve.sh`` of ``harbor_task``, ``nop`` does nothing.
diff --git a/src/sprout/eval/evaluate.py b/src/sprout/eval/evaluate.py
index 79e992d1..9c1be3a5 100644
--- a/src/sprout/eval/evaluate.py
+++ b/src/sprout/eval/evaluate.py
@@ -327,6 +327,8 @@ def _leak_judge(args: argparse.Namespace) -> dict[str, Any]:
def _actor(args: argparse.Namespace) -> Actor:
return Actor(
+ slot=getattr(args, "slot", "mini-swe-agent"),
+ tool_protocol=getattr(args, "opencollab_tool_protocol", "single2"),
model=args.model,
model_endpoint=args.model_endpoint,
api_key_env=args.model_key_env,
@@ -709,6 +711,8 @@ def _add_runstore_options(parser: argparse.ArgumentParser) -> None:
def _add_actor_options(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("--slot", choices=("mini-swe-agent", "opencollab-base"), default="mini-swe-agent")
+ parser.add_argument("--opencollab-tool-protocol", choices=("single2", "review50-v1"), default="single2")
parser.add_argument("--model", required=True)
parser.add_argument("--model-endpoint", required=True)
parser.add_argument(
diff --git a/src/sprout/eval/jobs.py b/src/sprout/eval/jobs.py
index 55884c7e..ca92ad40 100644
--- a/src/sprout/eval/jobs.py
+++ b/src/sprout/eval/jobs.py
@@ -44,6 +44,8 @@ class Actor:
max_turns: int
command_timeout_s: int
reasoning_effort: str | None = None
+ slot: str = "mini-swe-agent"
+ tool_protocol: str = "single2"
@dataclass(frozen=True)
@@ -155,9 +157,20 @@ def mini_job(
job = rollout_job(task, agent="mini", profile=profile, checkpoint=checkpoint, image=image)
spec = job["spec"]
spec["model"] = actor.model
+ spec["slot"] = actor.slot
spec["extra"] = {
"rollout_contract": _contract(actor, deadline_at=now + spec["timeout_s"] + QUEUE_ALLOWANCE_S),
- "mini": {"environment": {"timeout": actor.command_timeout_s, **_workdir(task)}},
+ "mini": {
+ "environment": {
+ "timeout": actor.command_timeout_s,
+ **(
+ {"cwd": task.workdir or "/workspace"}
+ if actor.slot == "opencollab-base" and actor.tool_protocol == "review50-v1"
+ else _workdir(task)
+ ),
+ }
+ },
+ **({"opencollab_tool_protocol": actor.tool_protocol} if actor.slot == "opencollab-base" else {}),
}
return job
diff --git a/src/sprout/rl_driver/messages.py b/src/sprout/rl_driver/messages.py
index 1bb35f36..18cd74de 100644
--- a/src/sprout/rl_driver/messages.py
+++ b/src/sprout/rl_driver/messages.py
@@ -69,12 +69,12 @@ def validate_endpoint(value: str) -> None:
def _check_run_defaults(defaults: object) -> None:
"""Raise ValueError unless ``defaults``, the ``run_defaults`` of a Miles section, can go into a rollout.
- It must be an object whose ``slot`` is ``mini-swe-agent``, and whose ``extra`` is an object without
+ Its ``slot`` is ``mini-swe-agent`` or ``opencollab-base``, and its ``extra`` is an object without
``rollout_contract``. Either key can be absent."""
if not isinstance(defaults, dict):
raise ValueError("run_defaults must be a JSON object")
- if defaults.get("slot", "mini-swe-agent") != "mini-swe-agent":
- raise ValueError("The Miles adapter supports mini-swe-agent only")
+ if defaults.get("slot", "mini-swe-agent") not in {"mini-swe-agent", "opencollab-base"}:
+ raise ValueError("The Miles adapter requires mini-swe-agent or opencollab-base")
extra = defaults.get("extra", {})
if not isinstance(extra, dict):
raise ValueError("run_defaults.extra must be a JSON object")
diff --git a/src/sprout/runstore/api.py b/src/sprout/runstore/api.py
index 88be7631..d5791044 100644
--- a/src/sprout/runstore/api.py
+++ b/src/sprout/runstore/api.py
@@ -10,8 +10,8 @@
from dataclasses import replace
from typing import TYPE_CHECKING, Any
+from sprout.contract.agents.history import load_prefix
from sprout.contract.continuation import Continuation, prepare
-from sprout.contract.mini_history import load_prefix
from sprout.runstore.config import merge
from sprout.runstore.index import Index
from sprout.runstore.specs import (
diff --git a/src/sprout/runstore/specs.py b/src/sprout/runstore/specs.py
index 2086f28d..00dd9940 100644
--- a/src/sprout/runstore/specs.py
+++ b/src/sprout/runstore/specs.py
@@ -182,8 +182,8 @@ def _validate_run(spec: dict[str, Any]) -> None:
raise ValueError("Unknown RunSpec fields")
if not isinstance(spec.get("prompt"), str) or not spec["prompt"].strip():
raise ValueError("RunSpec requires a nonempty prompt")
- if spec.get("slot", "mini-swe-agent") != "mini-swe-agent":
- raise ValueError("The only queued-run slot is mini-swe-agent")
+ if spec.get("slot", "mini-swe-agent") not in {"mini-swe-agent", "opencollab-base"}:
+ raise ValueError("Queued-run slot must be mini-swe-agent or opencollab-base")
agent = spec.get("agent", "mini")
if agent not in _RUN_AGENTS:
raise ValueError("RunSpec agent must be mini, oracle or nop")
diff --git a/src/sprout/worker/agents/history.py b/src/sprout/worker/agents/history.py
new file mode 100644
index 00000000..d7d245de
--- /dev/null
+++ b/src/sprout/worker/agents/history.py
@@ -0,0 +1,90 @@
+"""Recovery points and tool schemas for the selected native agent slot."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from pathlib import Path
+from typing import Any
+
+from sprout.contract.mini_history import NativePoint
+from sprout.worker.agents.mini_swe import history as mini
+from sprout.worker.checkpoint.rollback import Checkpoint, branch_checkpoints
+from sprout.worker.run.journal import journal_events, read_journal
+
+
+def _slot(events: list[dict[str, Any]]) -> str | None:
+ slots = {e["slot"] for e in events if e.get("type") == "run.started" and e.get("slot")}
+ return next(iter(slots)) if len(slots) == 1 else None
+
+
+def index_native(
+ journal: str | Path,
+ transcript: str | Path,
+ slot: str,
+ session_id: str,
+ *,
+ events: list[dict[str, Any]] | None = None,
+ inherited_native: dict[str, Any] | None = None,
+) -> list[NativePoint]:
+ """Index only closed native turns that have matching disk checkpoints."""
+ if slot == "opencollab-base":
+ from sprout.worker.agents.opencollab.history import index_native as oc_index
+
+ return oc_index(journal, transcript, session_id, events=events, inherited_native=inherited_native)
+ return mini.index_native(
+ journal, transcript, slot, session_id, events=events, inherited_native=inherited_native
+ )
+
+
+def reference_at(journal: str | Path, step: int, session_id: str) -> dict[str, Any] | None:
+ """The exact native prefix of the checkpoint at tool depth ``step``."""
+ if _slot(read_journal(journal)) == "opencollab-base":
+ from sprout.worker.agents.opencollab.history import reference_at as oc_reference
+
+ return oc_reference(journal, step, session_id)
+ return mini.reference_at(journal, step, session_id)
+
+
+def conversation_restore(journal: str | Path, step: int, session_id: str) -> str | None:
+ """The native turn id at a supported actor's exact checkpoint."""
+ if _slot(read_journal(journal)) != "opencollab-base":
+ return mini.conversation_restore(journal, step, session_id)
+ reference = reference_at(journal, step, session_id)
+ return str(reference["cut"]) if reference else None
+
+
+def available_branch_points(journal: str | Path) -> dict[int, Checkpoint]:
+ """Only disk checkpoints whose complete conversation and SDK state can be restored."""
+ events = journal_events(journal)
+ if _slot(events) != "opencollab-base":
+ return mini.available_branch_points(journal)
+ references = {
+ e["native_session_id"]: e
+ for e in events
+ if e.get("type") == "session.ref" and e.get("transcript_path")
+ }
+ points = branch_checkpoints(journal, events=events)
+ valid: dict[int, Checkpoint] = {}
+ for session_id, ref in references.items():
+ for point in index_native(
+ journal, ref["transcript_path"], "opencollab-base", session_id, events=events
+ ):
+ if point.tool_depth in points:
+ valid[point.tool_depth] = points[point.tool_depth]
+ return valid
+
+
+def actor_tools_at(journal: str | Path, step: int) -> list[dict[str, Any]]:
+ """The actor's actual tool schemas at the selected checkpoint."""
+ events = read_journal(journal)
+ if _slot(events) != "opencollab-base":
+ return mini.actor_tools_at(journal, step)
+ tools = None
+ for event in events:
+ if event.get("type") == "rollout.model_tools" and event.get("shape") == "chat/completions":
+ tools = event.get("tools")
+ if event.get("type") == "model.turn.completed" and event.get("step") == step:
+ if not isinstance(tools, list) or not tools:
+ raise ValueError("No recorded actor tool schema at the selected checkpoint")
+ return deepcopy(tools)
+ raise ValueError("No completed turn for the selected actor tool schema")
diff --git a/src/sprout/worker/agents/mini_swe/sandbox.py b/src/sprout/worker/agents/mini_swe/sandbox.py
index 8057fdbb..5b86c014 100644
--- a/src/sprout/worker/agents/mini_swe/sandbox.py
+++ b/src/sprout/worker/agents/mini_swe/sandbox.py
@@ -13,7 +13,7 @@
from copy import deepcopy
from typing import TYPE_CHECKING, Any, TypedDict
-from sprout.contract.mini_history import load_prefix
+from sprout.contract.agents.history import load_prefix
from sprout.worker.agents.mini_swe.tools import DEFAULT_COMMAND_TIMEOUT_S
from sprout.worker.run.control import RunControl
from sprout.worker.run.journal import JournalWriter
@@ -60,10 +60,16 @@ def resolve_workspace(
inherited: dict[str, Any] | None = None
if extra.get("native_prefix"):
entries = load_prefix(extra["native_prefix"])
- metadata = next(entry for entry in entries if entry.get("type") == "mini.session")
+ metadata = next(entry for entry in entries if entry.get("type") in {"mini.session", "oc.session"})
inherited = metadata.get("workspace")
if inherited and configured not in (None, inherited["cwd"]):
- raise ValueError("mini branch working directory differs from its inherited workspace")
+ if metadata.get("type") != "oc.session":
+ raise ValueError("mini branch working directory differs from its inherited workspace")
+ alias = session.execute(
+ "shell", {"command": "pwd -P", "working_dir": configured, "timeout": 10}, timeout=20
+ )
+ if _stdout(alias, "inherited working-directory alias") != inherited["cwd"]:
+ raise ValueError("OpenCollab branch working directory differs from its inherited workspace")
requested = inherited["cwd"] if inherited else configured
if requested is not None and (not isinstance(requested, str) or not requested.startswith("/")):
raise ValueError("mini working directory must be an absolute path or omitted for discovery")
diff --git a/src/sprout/worker/agents/opencollab/__init__.py b/src/sprout/worker/agents/opencollab/__init__.py
new file mode 100644
index 00000000..ead44e91
--- /dev/null
+++ b/src/sprout/worker/agents/opencollab/__init__.py
@@ -0,0 +1 @@
+"""OpenCollab Base (Single2) on Sprout's owned sandbox and checkpoint path."""
diff --git a/src/sprout/worker/agents/opencollab/environment.py b/src/sprout/worker/agents/opencollab/environment.py
new file mode 100644
index 00000000..d0f262e3
--- /dev/null
+++ b/src/sprout/worker/agents/opencollab/environment.py
@@ -0,0 +1,175 @@
+"""OpenCollab's environment port over Sprout ToolAccess, never host execution."""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import math
+import posixpath
+import shlex
+import time
+from contextlib import suppress
+from typing import Any
+from uuid import uuid4
+
+from opencollab.adapters._env_base import ENV_FILE_WRITE_LIMIT_BYTES
+from opencollab.adapters._env_docker import DockerEnvironment
+from opencollab.adapters.env import Environment, ExecResult
+
+from sprout.worker.run.control import RunAborted
+
+
+class SproutEnvironment(Environment): # type: ignore[misc] # SDK base has no published typing.
+ process_isolated = True
+ # These implementations use exec_cmd only, and retain native output limits.
+ read_text_range = DockerEnvironment.read_text_range
+ write_temp_file = DockerEnvironment.write_temp_file
+ remove_file = DockerEnvironment.remove_file
+
+ def __init__(
+ self, tools: Any, workspace: Any, control: Any, deadline: Any, *, image_shell: Any = None
+ ) -> None:
+ super().__init__()
+ self.tools, self.workspace = tools, workspace
+ self.control, self.deadline = control, deadline
+ self.image_shell = image_shell
+ self.turn_id: str | None = None
+ self.call_ids: list[str] = []
+ self.failure: BaseException | None = None
+ self._temporary_files: set[str] = set()
+
+ async def exec_cmd(self, cmd: str, timeout: float = 120.0) -> ExecResult:
+ self._ensure_active()
+ self.control.raise_if_stopped()
+ if not self.turn_id:
+ raise RuntimeError("OpenCollab execution requires an active model turn")
+ if not math.isfinite(timeout) or timeout <= 0:
+ raise ValueError("command timeout must be positive and finite")
+ remaining = self.deadline - time.monotonic()
+ if remaining <= 0:
+ self.control.request_stop("OpenCollab deadline exhausted", stop_reason="timeout")
+ self.control.raise_if_stopped()
+ identifier = uuid4().hex
+ arguments = {
+ "command": "bash -c " + shlex.quote(cmd),
+ "working_dir": self.workspace,
+ "timeout": max(1, math.ceil(min(timeout, remaining))),
+ }
+ if self.image_shell is not None:
+ arguments["command"] = self.image_shell.command(arguments["command"], working_dir=self.workspace)
+ worker = asyncio.create_task(
+ asyncio.to_thread(
+ self.tools.call,
+ "shell",
+ arguments,
+ call_id=identifier,
+ turn_id=self.turn_id,
+ )
+ )
+ try:
+ # Shield the serialized runtime operation until its own timeout settles it.
+ result = await asyncio.shield(worker)
+ self.call_ids.append(identifier)
+ outcome = result.outcome
+ if outcome is None or type(outcome.exit_code) is not int or outcome.running:
+ raise RuntimeError("OpenCollab command did not return a settled outcome")
+ self.control.raise_if_stopped()
+ return ExecResult(
+ outcome.exit_code,
+ outcome.stdout,
+ outcome.stderr,
+ stdout_truncated=outcome.stdout_truncated,
+ stderr_truncated=outcome.stderr_truncated,
+ timed_out=outcome.timed_out,
+ )
+ except asyncio.CancelledError:
+ # Drain a cancelled operation before another model turn may begin.
+ while not worker.done():
+ try:
+ await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ continue
+ except Exception:
+ break
+ try:
+ worker.result()
+ except Exception as error: # the cancelled call really lost transport
+ self.failure = error
+ else:
+ self.call_ids.append(identifier)
+ raise
+ except RunAborted:
+ # A rollout deadline or controller stop that arrives after the
+ # command settled does not make the sandbox transport uncertain.
+ raise
+ except BaseException as error:
+ # OpenCollab's tools can convert exceptions to feedback. Retain the
+ # transport failure so that cannot turn it into a benchmark failure.
+ self.failure = error
+ raise
+
+ async def write_file(self, path: str, content: str) -> None:
+ raw = content.encode("utf-8")
+ if len(raw) > ENV_FILE_WRITE_LIMIT_BYTES:
+ raise ValueError("file exceeds the native OpenCollab write limit")
+ target = posixpath.normpath(path)
+ if not target.startswith("/") or target == "/":
+ raise ValueError("file path must be absolute and non-root")
+ parent, quoted = shlex.quote(posixpath.dirname(target)), shlex.quote(target)
+ temporary = shlex.quote(
+ posixpath.join(
+ posixpath.dirname(target),
+ ".sprout-write-" + uuid4().hex,
+ )
+ )
+
+ async def checked(command: str, phase: str) -> None:
+ result = await self.exec_cmd(command)
+ if result.returncode != 0:
+ raise OSError(
+ f"sandbox file write failed ({phase}, exit={result.returncode}): " + result.stderr
+ )
+
+ # Bound each shell argument below Linux execve limits, even for large files.
+ owned = False
+ try:
+ await checked(
+ f"mkdir -p -- {parent} && (umask 077; set -C; : > {temporary})",
+ "create stage",
+ )
+ owned = True
+ for offset in range(0, len(raw), 32 * 1024):
+ payload = base64.b64encode(raw[offset : offset + 32 * 1024]).decode("ascii")
+ await checked(
+ f"printf '%s' {shlex.quote(payload)} | base64 -d >> {temporary}",
+ "append chunk",
+ )
+ digest = hashlib.sha256(raw).hexdigest()
+ await checked(
+ f'test "$(wc -c < {temporary})" -eq {len(raw)} && '
+ f"test \"$(sha256sum -- {temporary} | cut -d ' ' -f 1)\" = {digest} && "
+ f"if [ -f {quoted} ]; then chmod --reference={quoted} {temporary}; fi && "
+ f"mv -f -- {temporary} {quoted}",
+ "verify and commit",
+ )
+ owned = False
+ finally:
+ # Never mask cancellation or a transport failure; never bypass a
+ # stopped rollout to clean up. Harbor ultimately owns the sandbox.
+ if owned and self.failure is None:
+ with suppress(Exception, asyncio.CancelledError):
+ await self.exec_cmd(f"rm -f -- {temporary}")
+
+ async def read_file(self, path: str) -> str:
+ """Read through ToolAccess without borrowing private Docker transport."""
+ result = await self.exec_cmd(f"cat -- {shlex.quote(path)}")
+ if result.returncode != 0:
+ raise FileNotFoundError(result.stderr)
+ if result.stdout_truncated:
+ raise OSError(f"sandbox read exceeded capture limit for {path}")
+ return str(result.stdout)
+
+ async def cleanup(self) -> None:
+ # Harbor owns the VM. Never delete it, or a restore point, here.
+ pass
diff --git a/src/sprout/worker/agents/opencollab/history.py b/src/sprout/worker/agents/opencollab/history.py
new file mode 100644
index 00000000..82f72574
--- /dev/null
+++ b/src/sprout/worker/agents/opencollab/history.py
@@ -0,0 +1,175 @@
+"""Exact Single2 session states paired with closed Sprout runtime turns."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from copy import deepcopy
+from pathlib import Path
+from typing import Any
+
+from sprout.contract.agents.opencollab import (
+ FORMAT,
+ SLOT,
+ training_messages,
+)
+from sprout.contract.mini_history import NativePoint
+from sprout.worker.checkpoint.rollback import branch_checkpoints, closed_turns
+from sprout.worker.run.journal import read_journal
+
+
+class History:
+ def __init__(
+ self,
+ path: Any,
+ session_id: Any,
+ workspace: Any,
+ prefix: Any = (),
+ *,
+ tool_protocol: Any = "single2",
+ tools: Any = (),
+ ) -> None:
+ self.path = Path(path)
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ self.stream = self.path.open("x", encoding="utf-8")
+ self.append(
+ {
+ "type": "oc.session",
+ "format": FORMAT,
+ "profile": "single2",
+ "session_id": session_id,
+ "workspace": workspace,
+ "tool_protocol": tool_protocol,
+ "tools": list(tools),
+ }
+ )
+ for row in prefix:
+ if row["type"] != "oc.session":
+ self.append(row)
+ self.messages = training_messages(prefix)
+
+ def append(self, row: Any) -> None:
+ self.stream.write(json.dumps(row, ensure_ascii=False) + "\n")
+ self.stream.flush()
+ os.fsync(self.stream.fileno())
+
+ def persist_messages(self, messages: Any) -> None:
+ if messages[: len(self.messages)] == self.messages:
+ for message in messages[len(self.messages) :]:
+ self.append({"type": "oc.message", "message": deepcopy(message)})
+ else:
+ # Native context shaping may rewrite observations or remove old
+ # turns. Persist that state, not a stale append-only conversation.
+ self.append({"type": "oc.context", "messages": deepcopy(messages)})
+ self.messages = deepcopy(messages)
+
+ def close(self) -> None:
+ self.stream.close()
+
+
+def index_native(
+ journal: Any, transcript: Any, session_id: Any, *, events: Any = None, inherited_native: Any = None
+) -> list[NativePoint]:
+ events = list(read_journal(journal)) if events is None else events
+ calls = [row["call_id"] for row in events if row.get("type") == "tool.started"]
+ positions = {identifier: index for index, identifier in enumerate(calls, 1)}
+ checkpoints = branch_checkpoints(journal, events=events)
+ closed = closed_turns(events)
+ points: list[NativePoint] = []
+ seen: set[str] = set()
+ checksum, offset, message_step = hashlib.sha256(), 0, 0
+ pending: set[str] = set()
+ used: set[str] = set()
+ for line in Path(transcript).read_bytes().splitlines(keepends=True):
+ if not line.endswith(b"\n"):
+ break
+ checksum.update(line)
+ offset += len(line)
+ row = json.loads(line)
+ if row.get("type") == "oc.session":
+ if row.get("format") != FORMAT:
+ raise ValueError("Unsupported OpenCollab native format")
+ elif row.get("type") == "oc.context":
+ pending, used = set(), set()
+ for message in row["messages"]:
+ identifiers = [call["id"] for call in message.get("tool_calls") or []]
+ if identifiers:
+ if pending or len(set(identifiers)) != len(identifiers) or set(identifiers) & used:
+ raise ValueError("Invalid shaped Single2 tool-call history")
+ pending.update(identifiers)
+ used.update(identifiers)
+ if message.get("role") == "tool":
+ identifier = message.get("tool_call_id")
+ if identifier not in pending:
+ raise ValueError("Unpaired shaped Single2 tool result")
+ pending.remove(identifier)
+ elif row.get("type") == "oc.message":
+ message = row["message"]
+ identifiers = [call["id"] for call in message.get("tool_calls") or []]
+ if identifiers:
+ if pending or len(set(identifiers)) != len(identifiers) or set(identifiers) & used:
+ break
+ pending.update(identifiers)
+ used.update(identifiers)
+ if message.get("role") == "tool":
+ identifier = message.get("tool_call_id")
+ if identifier not in pending:
+ break
+ pending.remove(identifier)
+ elif row.get("type") == "oc.turn":
+ if pending or not isinstance(row.get("session_snapshot"), dict):
+ break
+ message_step += 1
+ runtime_ids = row.get("runtime_call_ids", [])
+ local = [identifier for identifier in runtime_ids if identifier in positions]
+ if not local:
+ continue # inherited turn or schema rejection: no local disk change
+ if len(local) != len(runtime_ids) or set(local) & seen:
+ break
+ seen.update(local)
+ depth = max(positions[identifier] for identifier in local)
+ turn = closed.get(row["turn_id"], {})
+ point = checkpoints.get(depth)
+ if turn.get("call_ids") != runtime_ids or turn.get("step") != depth or set(calls[:depth]) != seen:
+ break
+ if (
+ point
+ and point.snapshot_id is not None
+ and point.turn_id == row["turn_id"]
+ and point.session_ckpt == session_id
+ ):
+ points.append(
+ NativePoint(
+ message_step,
+ depth,
+ point.snapshot_id,
+ {
+ "slot": SLOT,
+ "session_id": session_id,
+ "path": str(Path(transcript).resolve()),
+ "byte_length": offset,
+ "sha256": checksum.hexdigest(),
+ "cut": row["turn_id"],
+ },
+ )
+ )
+ else:
+ raise ValueError("Unknown OpenCollab native record")
+ return points
+
+
+def reference_at(journal: Any, step: Any, session_id: Any) -> dict[str, Any] | None:
+ events = list(read_journal(journal))
+ reference = next(
+ (
+ row
+ for row in reversed(events)
+ if row.get("type") == "session.ref" and row.get("native_session_id") == session_id
+ ),
+ None,
+ )
+ if reference is None:
+ return None
+ points = index_native(journal, reference["transcript_path"], session_id, events=events)
+ return next((point.native for point in points if point.tool_depth == step), None)
diff --git a/src/sprout/worker/agents/opencollab/review50.py b/src/sprout/worker/agents/opencollab/review50.py
new file mode 100644
index 00000000..52cbb5e8
--- /dev/null
+++ b/src/sprout/worker/agents/opencollab/review50.py
@@ -0,0 +1,108 @@
+"""Reviewed-data tool compatibility using OpenCollab's native execution loop."""
+
+from __future__ import annotations
+
+import json
+import shlex
+import time
+from copy import deepcopy
+from pathlib import Path
+from typing import Any, cast
+
+from opencollab.adapters.tools.base import Tool
+
+from sprout.resource import resource
+
+
+def definition() -> dict[str, Any]:
+ return cast(dict[str, Any], json.loads(resource("opencollab/review50_protocol.json").read_text()))
+
+
+class ReviewTool(Tool): # type: ignore[misc] # SDK base has no published typing.
+ """Keep the recorded tool names and parameter meanings at the SDK boundary."""
+
+ def __init__(self, schema: dict[str, Any], started: float) -> None:
+ function = deepcopy(schema["function"])
+ self.name = function["name"]
+ self.description = function["description"]
+ self.parameters = function["parameters"]
+ self.started = started
+ self.submission_receipt = None
+
+ async def execute_with_runtime(self, params: Any, runtime: Any) -> Any:
+ env = runtime.environment
+ env.agent_tool_calls = getattr(env, "agent_tool_calls", 0) + 1
+ if self.name == "run_command":
+ if runtime.safety_policy is not None:
+ runtime.safety_policy.check_cmd(params["command"])
+ result = await env.exec_cmd(params["command"], timeout=300)
+ runtime.observations.record_execution(exit_code=result.returncode, timed_out=result.timed_out)
+ if result.returncode or result.timed_out:
+ return json.dumps(
+ {
+ "status": "error",
+ "error_type": "CommandError",
+ "error_message": (result.stdout + result.stderr)[:5000],
+ "returncode": result.returncode,
+ "timed_out": result.timed_out,
+ }
+ )
+ return json.dumps(
+ {
+ "status": "ok",
+ "stdout": result.stdout[:5000],
+ "stderr": result.stderr[:5000],
+ "is_truncated": result.stdout_truncated
+ or result.stderr_truncated
+ or len(result.stdout) > 5000
+ or len(result.stderr) > 5000,
+ }
+ )
+ source = Path(__file__).with_name("review50_sandbox.py").read_text()
+ command = (
+ "python3 -c "
+ + shlex.quote(source)
+ + " "
+ + shlex.quote(self.name)
+ + " "
+ + shlex.quote(json.dumps(params))
+ )
+ result = await env.exec_cmd(command, timeout=300)
+ if result.returncode or result.timed_out or result.stdout_truncated:
+ raise RuntimeError("Review50 sandbox helper failed: " + result.stderr[:1000])
+ answer = json.loads(result.stdout)
+ if self.name == "get_status":
+ controls = getattr(env, "rollout_controls", None)
+ budget = (
+ controls.status()
+ if controls is not None
+ else {
+ "model_calls": None,
+ "max_turns": None,
+ "remaining_turns": None,
+ "runtime_tool_calls": None,
+ "max_tool_calls": None,
+ "remaining_seconds": max(0, env.deadline - time.monotonic()),
+ }
+ )
+ answer.update(
+ elapsed_seconds=time.monotonic() - self.started,
+ remaining_seconds=budget["remaining_seconds"],
+ budget={**budget, "agent_tool_calls": env.agent_tool_calls},
+ runtime_calls_in_turn=len(env.call_ids),
+ grading="pending official verifier",
+ )
+ if self.name in {"write_file", "edit_file"}:
+ runtime.observations.record_write(
+ completed=answer["status"] == "ok", changed=None, path=params["filepath"]
+ )
+ if self.name == "submit_patch" and answer["status"] == "submitted":
+ # This is OpenCollab's own submission fact, not a replacement stop loop.
+ runtime.observations.record_submission(json.dumps(answer))
+ self.submission_receipt = answer
+ return json.dumps(answer, ensure_ascii=False)
+
+
+def build_tools() -> tuple[Tool, ...]:
+ started = time.monotonic()
+ return tuple(ReviewTool(schema, started) for schema in definition()["tools"])
diff --git a/src/sprout/worker/agents/opencollab/review50_sandbox.py b/src/sprout/worker/agents/opencollab/review50_sandbox.py
new file mode 100644
index 00000000..27aaea82
--- /dev/null
+++ b/src/sprout/worker/agents/opencollab/review50_sandbox.py
@@ -0,0 +1,101 @@
+"""Review50 file operations executed inside the sandbox, using only stdlib."""
+
+from __future__ import annotations
+
+import difflib
+import hashlib
+import json
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+
+def operate(name: str, args: dict[str, Any]) -> dict[str, Any]:
+ root = Path.cwd().resolve()
+ if name in {"submit_patch", "get_status"}:
+ marker = Path("/opt/review50-baseline")
+ baseline = marker.read_text().strip() if marker.is_file() else "HEAD"
+
+ def git(*parts: Any) -> Any:
+ return subprocess.run(["git", *parts], check=True, capture_output=True, text=True).stdout
+
+ if name == "get_status":
+ status = git("status", "--porcelain=v1", "--untracked-files=all")
+ diff = git("diff", "--stat", baseline)
+ return {
+ "status": "ok",
+ "patch": {
+ "baseline": baseline,
+ "working_tree_status": status[:10000],
+ "tracked_diff_stat": diff[:10000],
+ "is_truncated": len(status) > 10000 or len(diff) > 10000,
+ },
+ }
+ # Include newly created files by the same mechanism as verifier.collect.
+ git("add", "-N", "--", ".")
+ patch = git("diff", "--binary", baseline)
+ return {
+ "status": "submitted",
+ "patch_sha256": hashlib.sha256(patch.encode()).hexdigest(),
+ "patch_chars": len(patch),
+ "baseline": baseline,
+ "grading": "pending official verifier",
+ }
+ path = (root / args["filepath"]).resolve()
+ try:
+ path.relative_to(root)
+ except ValueError:
+ raise ValueError("File paths must remain within the repository workspace") from None
+ result = {"status": "ok", "filepath": args["filepath"]}
+ if name == "read_file":
+ lines = path.read_text().splitlines(keepends=True)
+ start = args.get("start_line", 1)
+ end = args.get("end_line", start + 149)
+ if start < 1 or end < start:
+ raise ValueError("Line bounds must be positive, ordered and inclusive")
+ stop = min(end, start + 149, len(lines))
+ text = "".join(lines[start - 1 : stop])
+ result.update(
+ content=text[:10000],
+ start_line=start,
+ end_line=stop,
+ total_lines=len(lines),
+ is_truncated=stop < len(lines) or len(text) > 10000,
+ )
+ elif name == "write_file":
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(args["content"])
+ result["size"] = len(args["content"].encode())
+ elif name == "edit_file":
+ before = path.read_text()
+ old, new = args["old_string"], args["new_string"]
+ if not old:
+ raise ValueError("old_string must not be empty")
+ occurrences = before.count(old)
+ if occurrences == 0 or occurrences > 1 and not args.get("allow_multiple", False):
+ raise ValueError(f"old_string matches {occurrences} times; require one or allow_multiple")
+ after = before.replace(old, new)
+ path.write_text(after)
+ diff = "".join(
+ difflib.unified_diff(
+ before.splitlines(True),
+ after.splitlines(True),
+ fromfile="a/" + args["filepath"],
+ tofile="b/" + args["filepath"],
+ )
+ )
+ result.update(
+ occurrences=occurrences, strategy="exact", diff=diff[:10000], is_truncated=len(diff) > 10000
+ )
+ else:
+ raise ValueError(f"Unknown review50 operation: {name}")
+ return result
+
+
+if __name__ == "__main__":
+ try:
+ answer = operate(sys.argv[1], json.loads(sys.argv[2]))
+ except (OSError, ValueError, subprocess.CalledProcessError) as error:
+ answer = {"status": "error", "error_type": type(error).__name__, "error_message": str(error)}
+ print(json.dumps(answer, ensure_ascii=False))
diff --git a/src/sprout/worker/agents/opencollab/slot.py b/src/sprout/worker/agents/opencollab/slot.py
new file mode 100644
index 00000000..d54e8ca8
--- /dev/null
+++ b/src/sprout/worker/agents/opencollab/slot.py
@@ -0,0 +1,412 @@
+"""Drive the real Single2 session loop through Sprout-owned collaborators."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import threading
+import time
+from dataclasses import replace
+from importlib.metadata import version
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+from opencollab.adapters.llm import LLMClient
+from opencollab.adapters.llm.types import LLMResponse
+from opencollab.adapters.llm.types import Usage as OCUsage
+from opencollab.application.session_run import GenerationTimeoutError
+from opencollab.bootstrap.agent_profiles import resolve_agent_profile
+from opencollab.bootstrap.runtime_context import build_workspace_safety_policy
+from opencollab.bootstrap.session_factory import build_session
+from opencollab.domain.agent import Agent
+
+from sprout.contract.agents.opencollab import load_prefix, training_messages, validate_assistant_turn
+from sprout.worker.agents.opencollab.environment import SproutEnvironment
+from sprout.worker.agents.opencollab.history import History
+from sprout.worker.agents.slot import AgentSlot, SlotResult, SlotStatus, TaskSpec
+from sprout.worker.gateway.server import _absorb_usage
+from sprout.worker.run.control import RunAborted, RunControl
+from sprout.worker.run.events import Usage
+from sprout.worker.run.journal import JournalWriter
+from sprout.worker.tools.access import ToolAccess
+
+
+class RecordedModel:
+ """Preserve native provider adaptation, recording usage and branch injection."""
+
+ def __init__(
+ self, client: Any, journal: Any, control: Any, pending: Any = None, client_factory: Any = None
+ ) -> None:
+ self.client, self.journal, self.control = client, journal, control
+ self.owner_loop = asyncio.get_running_loop()
+ self.client_factory = client_factory
+ self.pending = pending
+ self.usage = Usage()
+ self.last_usage: dict[str, Any] = {}
+ self.source = "actor"
+
+ def __getattr__(self, name: Any) -> Any:
+ return getattr(self.client, name)
+
+ async def complete(self, messages: Any, **kwargs: Any) -> Any:
+ self.control.raise_if_stopped()
+ self.source = "actor"
+ if self.pending is not None and kwargs.get("tools"):
+ turn, self.pending = self.pending, None
+ self.source = "reviewer"
+ self.last_usage = {}
+ self.journal.emit("branch.assistant_turn", source="reviewer", message=turn)
+ return LLMResponse(
+ content=turn["content"],
+ tool_calls=turn["tool_calls"],
+ usage=OCUsage(),
+ reasoning=turn.get("reasoning_content"),
+ finish_reason="tool_calls",
+ )
+ # Native read-time compaction runs in its own short-lived event loop.
+ # HTTP connection pools must be created and closed on that same loop.
+ if self.client_factory and asyncio.get_running_loop() is not self.owner_loop:
+ async with self.client_factory() as client:
+ response = await client.complete(messages, **kwargs)
+ else:
+ response = await self.client.complete(messages, **kwargs)
+ # Persist token consumption before the loop validates or executes it.
+ raw = response.usage.raw_usage or {
+ "prompt_tokens": response.usage.input_tokens,
+ "completion_tokens": response.usage.output_tokens,
+ "total_tokens": response.usage.total_tokens,
+ }
+ usage = Usage()
+ _absorb_usage(raw, usage)
+ self.usage.add(usage)
+ self.last_usage = usage.as_dict()
+ self.journal.emit(
+ "raw.opencollab-base",
+ response={
+ "usage": raw,
+ "choices": [
+ {
+ "message": {
+ "role": "assistant",
+ "content": response.content,
+ "tool_calls": response.tool_calls,
+ },
+ "finish_reason": response.finish_reason,
+ }
+ ],
+ },
+ )
+ self.journal.emit("usage.updated", usage=self.last_usage)
+ if response.content:
+ self.journal.emit("agent.message", text=response.content)
+ if response.reasoning:
+ self.journal.emit("agent.thinking", text=response.reasoning)
+ self.control.raise_if_stopped()
+ return response
+
+
+class TurnBridge:
+ def __init__(self, history: Any, tools: Any, env: Any, model: Any, journal: Any, control: Any) -> None:
+ self.history, self.tools, self.env, self.model = history, tools, env, model
+ self.journal, self.control = journal, control
+ self.session: Any = None
+ self.error: Exception | None = None
+
+ async def emit(self, event: Any) -> Any:
+ try:
+ self.journal.emit("opencollab.event", native_event_type=event.type, data=event.data)
+ if event.type == "step_start":
+ self.env.turn_id = uuid4().hex
+ self.env.call_ids = []
+ self.journal.emit("turn.started", turn=event.data["step"])
+ elif event.type == "tool_start":
+ # Native session has appended the complete assistant response;
+ # make that durable before any sandbox side effect begins.
+ self.history.persist_messages(self.session.messages)
+ elif event.type == "step_end":
+ if self.env.failure:
+ raise RuntimeError("Single2 sandbox transport failed") from self.env.failure
+ self.history.persist_messages(self.session.messages)
+ _, metadata = self.session._snapshot_for_save()
+ metadata["session_state"]["phase"] = "precheck"
+ metadata["session_state"]["pending_events"] = []
+ metadata["session_state"]["pending_step_latency"] = None
+ self.history.append(
+ {
+ "type": "oc.turn",
+ "turn_id": self.env.turn_id,
+ "runtime_call_ids": list(self.env.call_ids),
+ "source": self.model.source,
+ "session_snapshot": metadata,
+ }
+ )
+ if self.env.call_ids:
+ self.journal.emit(
+ "model.turn.completed",
+ turn_id=self.env.turn_id,
+ call_ids=list(self.env.call_ids),
+ source=self.model.source,
+ step=self.journal.tool_calls()[-1]["step"],
+ )
+ self.tools.close_turn(self.env.turn_id)
+ self.journal.emit(
+ "turn.completed",
+ turn=event.data["step"],
+ source=self.model.source,
+ usage=self.model.last_usage,
+ )
+ except Exception as error: # noqa: BLE001 - checkpoint failure aborts its owner
+ # EventBus isolates subscriber failures. Fail the owning rollout
+ # explicitly so a lost history/checkpoint is never silently accepted.
+ self.error = error
+ cause = error.__cause__
+ fields = {"error_type": type(error).__name__, "error": str(error)}
+ if cause is not None:
+ fields.update(cause_type=type(cause).__name__, cause=str(cause))
+ self.journal.emit("opencollab.bridge.failed", **fields)
+ if self.env.failure is not None:
+ detail = "%s: %s" % (type(self.env.failure).__name__, self.env.failure)
+ self.control.request_stop(
+ "Single2 sandbox transport failed: " + detail, stop_reason="infrastructure_error"
+ )
+ else:
+ self.control.request_stop(
+ "Single2 checkpoint bridge failed: " + str(error), stop_reason="infrastructure_error"
+ )
+
+
+class OpenCollabSlot(AgentSlot):
+ name = "opencollab-base"
+
+ def version(self) -> str:
+ return version("opencollab")
+
+ def run(self, task: TaskSpec, journal: JournalWriter, tools: ToolAccess) -> SlotResult:
+ if tools is None or not task.model:
+ raise ValueError("OpenCollab requires ToolAccess and an explicit model")
+ return asyncio.run(self._run(task, journal, tools))
+
+ async def _run(self, task: Any, journal: JournalWriter, tools: ToolAccess) -> SlotResult:
+ control = task.control or RunControl()
+ started_at = time.monotonic()
+ deadline = started_at + task.timeout_s
+ profile = resolve_agent_profile("base")
+ if profile.name != "single2":
+ raise ValueError("This experiment pins Base to the Single2 implementation")
+ workspace = task.extra["mini_workspace"]
+ prefix = load_prefix(task.extra["native_prefix"]) if task.extra.get("native_prefix") else []
+ protocol = task.extra.get("opencollab_tool_protocol", "single2")
+ if protocol not in {"single2", "review50-v1"}:
+ raise ValueError("Unknown OpenCollab tool protocol")
+ if prefix and prefix[0].get("tool_protocol", "single2") != protocol:
+ raise ValueError("OpenCollab branch tool protocol differs from its parent")
+ if prefix and prefix[0]["workspace"] != workspace:
+ raise ValueError("Single2 branch workspace differs from the parent's")
+ inherited_messages = training_messages(prefix)
+ if protocol == "review50-v1":
+ from sprout.worker.agents.opencollab.review50 import build_tools, definition
+
+ profile = replace(profile, system_prompt=definition()["system_prompt"])
+ native_tools = build_tools()
+ else:
+ native_tools = profile.resolve_tools("coding")
+ # Use the runtime deadline; SDK timers can cancel a healthy queued command.
+ for tool in native_tools:
+ tool.disable_outer_timeout = True
+ schemas = [tool.to_openai_schema() for tool in native_tools]
+ pending = task.extra.get("assistant_turn")
+ if pending is not None:
+ if not prefix or task.extra.get("resume_without_hint"):
+ raise ValueError("Reviewer continuation requires an exact prefix")
+ pending = validate_assistant_turn(
+ pending, history=inherited_messages, tools=schemas, allow_reasoning=True
+ )
+ if task.extra.get("resume_without_hint") and not prefix:
+ raise ValueError("Point continuation requires an exact prefix")
+ session_id = uuid4().hex
+ home = Path(task.extra.get("native_home") or journal.path.parent / "opencollab-native")
+ home.mkdir(parents=True, exist_ok=True)
+ history = History(
+ home / f"{session_id}.jsonl", session_id, workspace, prefix, tool_protocol=protocol, tools=schemas
+ )
+ settings = dict(task.extra.get("mini", {}).get("model", {}).get("model_kwargs", {}))
+ wire_env = {**os.environ, **task.env}
+
+ def client_factory() -> Any:
+ return LLMClient(
+ model=task.model,
+ provider="openai",
+ wire_protocol="chat_completions",
+ base_url=wire_env.get("OPENAI_BASE_URL"),
+ api_key=wire_env.get("OPENAI_API_KEY"),
+ max_retries=5,
+ request_timeout=task.timeout_s,
+ stream_chat=False,
+ first_event_timeout=None,
+ stream_idle_timeout=None,
+ provider_error_time_budget=min(300, task.timeout_s),
+ )
+
+ injected = task.extra.get("opencollab_llm")
+ client = injected or client_factory()
+ model = RecordedModel(
+ client, journal, control, pending, client_factory=None if injected else client_factory
+ )
+ env = SproutEnvironment(tools, workspace["cwd"], control, deadline, image_shell=task.image_shell)
+ env.rollout_controls = task.extra.get("rollout_controls")
+ bridge = TurnBridge(history, tools, env, model, journal, control)
+ agent = Agent(
+ name="single2",
+ system_prompt=profile.system_prompt,
+ tools=list(native_tools),
+ model=task.model,
+ provider="openai",
+ max_tokens_per_step=settings.get("max_tokens", 64000),
+ reasoning_effort=settings.get("reasoning_effort", "high"),
+ temperature=settings.get("temperature", 0.0),
+ top_p=settings.get("top_p"),
+ )
+ session = build_session(
+ agent=agent,
+ env=env,
+ llm=model,
+ event_sink=bridge,
+ max_budget_tokens=None,
+ max_steps=None,
+ agent_profile=profile,
+ llm_timeout=None,
+ safety_policy=build_workspace_safety_policy(env),
+ )
+ bridge.session = session
+ journal.emit(
+ "run.started",
+ slot=self.name,
+ slot_version=self.version(),
+ agent_profile=profile.name,
+ tool_protocol=protocol,
+ model=task.model,
+ task_prompt=task.prompt,
+ cwd=task.cwd,
+ actor_timeout_s=task.timeout_s,
+ generation_timeout_s=None,
+ )
+ journal.emit("session.ref", native_session_id=session_id, transcript_path=str(history.path.resolve()))
+ journal.emit("rollout.model_tools", shape="chat/completions", tools=schemas)
+ timer = threading.Timer(
+ task.timeout_s,
+ lambda: control.request_stop("OpenCollab wall-time exhausted", stop_reason="timeout"),
+ )
+ timer.daemon = True
+ timer.start()
+ status: SlotStatus = "error"
+ error: str | None = None
+ text = ""
+ wall_timeout = asyncio.timeout_at(deadline)
+ try:
+ if prefix:
+ restored = home / f"{session_id}.restore.json"
+ session.store.save(str(restored), inherited_messages, meta=prefix[-1]["session_snapshot"])
+ session.restore(str(restored))
+ # Keep inherited state/history, grant only NEW turns to this run.
+ journal.emit(
+ "opencollab.restored",
+ inherited_native=task.extra["native_prefix"],
+ inherited_steps=session.step_count,
+ inherited_tokens=session.used_tokens,
+ )
+ if not pending and not task.extra.get("resume_without_hint") and task.prompt:
+ await session.add_user_message(task.prompt)
+ else:
+ await session.add_user_message(task.prompt)
+ # The outer context identifies its own deadline; an inner provider
+ # TimeoutError must never masquerade as the whole rollout deadline.
+ async with wall_timeout:
+ text = await session.run_loop()
+ if env.failure:
+ raise RuntimeError("Single2 sandbox transport failed") from env.failure
+ if bridge.error:
+ raise bridge.error
+ control.raise_if_stopped()
+ if session.phase.value == "error":
+ raise RuntimeError(session.state.terminal_reason or "Single2 session failed")
+ status = "completed"
+ except RunAborted as exc:
+ if env.failure or bridge.error:
+ status, error = "error", str(env.failure or bridge.error)
+ else:
+ status = "timeout" if control.stop_reason in {"timeout", "max_turns_reached"} else "killed"
+ error = str(exc) or control.reason
+ except GenerationTimeoutError as exc:
+ error = f"{type(exc).__name__}: {exc}"
+ control.request_stop(error, stop_reason="infrastructure_error")
+ status = "error"
+ except TimeoutError as exc:
+ if wall_timeout.expired():
+ control.request_stop("OpenCollab wall-time exhausted", stop_reason="timeout")
+ status, error = "timeout", control.reason
+ else:
+ error = f"{type(exc).__name__}: {exc}"
+ control.request_stop(error, stop_reason="infrastructure_error")
+ status = "error"
+ except Exception as exc: # noqa: BLE001 - the slot returns a durable outcome
+ if (
+ control.stop_reason in {"timeout", "max_turns_reached"}
+ and not env.failure
+ and not bridge.error
+ ):
+ status, error = "timeout", control.reason
+ else:
+ status, error = "error", f"{type(exc).__name__}: {exc}"
+ finally:
+ timer.cancel()
+ history.persist_messages(session.messages)
+ session.save(str(home / f"{session_id}.session.json"))
+ history.close()
+ await session.aclose()
+ close = getattr(client, "close", None) or getattr(client, "aclose", None)
+ if close:
+ result = close()
+ if asyncio.iscoroutine(result):
+ await result
+ usage = model.usage.as_dict()
+ journal.emit("run.result", text=text)
+ submission = next(
+ (tool.submission_receipt for tool in native_tools if getattr(tool, "submission_receipt", None)),
+ None,
+ )
+ journal.emit(
+ "opencollab.termination",
+ tool_protocol=protocol,
+ submitted=submission is not None,
+ submission_receipt=submission,
+ kind=(
+ "explicit_submission"
+ if submission
+ else "final_response"
+ if status == "completed"
+ else control.stop_reason or status
+ ),
+ )
+ termination = (
+ "completed"
+ if status == "completed"
+ else "model_request_budget"
+ if status == "timeout" and control.stop_reason == "max_turns_reached"
+ else "actor_deadline"
+ if status == "timeout"
+ else "infrastructure_error"
+ )
+ journal.emit(
+ "run.finished",
+ status=status,
+ error=error,
+ usage=usage,
+ termination_kind=termination,
+ stop_reason=("infrastructure_error" if env.failure or bridge.error else control.stop_reason),
+ elapsed_s=time.monotonic() - started_at,
+ actor_timeout_s=task.timeout_s,
+ )
+ return SlotResult(
+ status=status, final_text=text, usage=dict(usage), native_session_id=session_id, error=error
+ )
diff --git a/src/sprout/worker/agents/opencollab/workspace.py b/src/sprout/worker/agents/opencollab/workspace.py
new file mode 100644
index 00000000..56b8688d
--- /dev/null
+++ b/src/sprout/worker/agents/opencollab/workspace.py
@@ -0,0 +1,32 @@
+"""Resolve the sandbox checkout and the historical protocol's workspace alias."""
+
+from __future__ import annotations
+
+import shlex
+from typing import Any
+
+from sprout.worker.agents.mini_swe.sandbox import resolve_workspace as resolve_native_workspace
+from sprout.worker.run.journal import JournalWriter
+from sprout.worker.sandbox.session import SandboxSession
+
+
+def resolve_workspace(
+ session: SandboxSession, extra: dict[str, Any], journal: JournalWriter
+) -> dict[str, Any]:
+ """Resolve a checkout; compatibility tools additionally expose it at ``/workspace``."""
+ resolved = resolve_native_workspace(session, extra, journal)
+ if resolved.get("opencollab_tool_protocol", "single2") != "review50-v1":
+ return resolved
+ target = resolved["mini_workspace"]["cwd"]
+ command = (
+ "if [ -e /workspace ] || [ -L /workspace ]; then "
+ f'test "$(cd /workspace && pwd -P)" = {shlex.quote(target)}; '
+ f"else ln -s -- {shlex.quote(target)} /workspace; fi"
+ )
+ result = session.execute("shell", {"command": command, "working_dir": target, "timeout": 10}, timeout=20)
+ if not result.success:
+ raise ValueError(
+ "OpenCollab /workspace alias is unavailable or points outside the configured checkout"
+ )
+ journal.emit("opencollab.workspace_alias", alias="/workspace", cwd=target)
+ return resolved
diff --git a/src/sprout/worker/attempts.py b/src/sprout/worker/attempts.py
index aeb9f054..42f7723b 100644
--- a/src/sprout/worker/attempts.py
+++ b/src/sprout/worker/attempts.py
@@ -28,7 +28,7 @@
from sprout.runstore.payload import PayloadUnavailable, encode_payload, send_payload
from sprout.runstore.specs import defaults_key, digest, validate_continuation
from sprout.runstore.store import Store
-from sprout.worker.agents.mini_swe.history import index_native
+from sprout.worker.agents.history import index_native
from sprout.worker.cleanup import LEFTOVERS_NAME, Leftovers, forget, log_section, with_log
from sprout.worker.files import read_json, write_json
from sprout.worker.kept import LEDGER_NAME, KeptSandboxes
diff --git a/src/sprout/worker/cli.py b/src/sprout/worker/cli.py
index f797ca77..fb207326 100644
--- a/src/sprout/worker/cli.py
+++ b/src/sprout/worker/cli.py
@@ -308,9 +308,9 @@ def _add_run_options(run: argparse.ArgumentParser) -> None:
run.add_argument("prompt")
run.add_argument(
"--slot",
- choices=("mini-swe-agent",),
+ choices=("mini-swe-agent", "opencollab-base"),
default="mini-swe-agent",
- help="the agent; mini-swe-agent is the only one",
+ help="the agent implementation",
)
run.add_argument("--cwd", default=".")
run.add_argument("--model")
diff --git a/src/sprout/worker/jobs/export.py b/src/sprout/worker/jobs/export.py
index f90f0f97..03cfb21f 100644
--- a/src/sprout/worker/jobs/export.py
+++ b/src/sprout/worker/jobs/export.py
@@ -12,6 +12,7 @@
from pathlib import Path
from typing import Any
+from sprout.contract.agents.history import training_messages
from sprout.contract.messages import (
LIMIT_STOP_REASONS,
branch_origin,
@@ -31,7 +32,7 @@ def export_messages(
Raises ValueError without a session id, for a slot other than ``mini-swe-agent``, or
unless exactly one history exists. ``allow_incomplete_tail`` drops an unfinished last record.
"""
- if not isinstance(session_id, str) or not session_id or slot != "mini-swe-agent":
+ if not isinstance(session_id, str) or not session_id or slot not in {"mini-swe-agent", "opencollab-base"}:
raise ValueError("Missing or unsupported native session identity")
files = list((directory / "native-home").glob(f"**/*{session_id}.jsonl"))
if len(files) != 1:
@@ -42,7 +43,6 @@ def export_messages(
raise ValueError("Native history has an incomplete final record")
data = data[: data.rfind(b"\n") + 1]
entries = [json.loads(line) for line in data.splitlines() if line.strip()]
- from sprout.contract.mini_history import training_messages
# mini writes its formatted observation before it closes the turn. The runtime's own
# presentation in ``tool.finished`` differs, and the export does not use it.
@@ -60,7 +60,7 @@ def provenance(
``prefix_messages`` counts the leading messages restored from ``recovery``. ``inserted_messages``
indexes the assistant messages that a reviewer wrote, in this job or in an ancestor that it continues.
"""
- from sprout.contract.mini_history import read_entries, read_prefix, training_messages
+ from sprout.contract.mini_history import read_entries, read_prefix
files = list((directory / "native-home").glob(f"**/*{session_id}.jsonl"))
if len(files) != 1:
@@ -72,11 +72,31 @@ def provenance(
and (entry["message"].get("extra") or {}).get("source") == "reviewer"
for call in entry["message"].get("tool_calls") or []
}
+ oc_inserted = set()
+ records = read_entries(files[0].read_bytes())
+ if records and records[0].get("type") == "oc.session":
+ events = journal_events(directory / "trajectory.jsonl")
+ sampled = [
+ choice["message"]
+ for e in events
+ if e.get("type") == "raw.opencollab-base"
+ for choice in e.get("response", {}).get("choices", [])
+ ]
+ for index, message in enumerate(messages):
+ if message["role"] == "assistant" and not any(
+ (message.get("content") or "") == (candidate.get("content") or "")
+ and (message.get("tool_calls") or []) == (candidate.get("tool_calls") or [])
+ for candidate in sampled
+ ):
+ oc_inserted.add(index)
inserted = [
index
for index, message in enumerate(messages)
- if message["role"] == "assistant"
- and {call["id"] for call in message.get("tool_calls") or []} & inserted_calls
+ if index in oc_inserted
+ or (
+ message["role"] == "assistant"
+ and {call["id"] for call in message.get("tool_calls") or []} & inserted_calls
+ )
]
restored = 0
if recovery:
@@ -98,7 +118,7 @@ def salvage_interrupted(
The attempt started from the point ``recovery``, if any. The salvaged result is ``truncated``, with
``stop_reason: "interrupted"`` and the failure under ``interrupted``. None if ``point`` cannot be rebuilt.
"""
- from sprout.contract.mini_history import read_entries, read_prefix, training_messages
+ from sprout.contract.mini_history import read_entries, read_prefix
try:
events = journal_events(directory / "trajectory.jsonl")
@@ -186,7 +206,7 @@ def complete_message_result(
if slot == "mini-swe-agent" and is_truncated_result(result) and not unsafe:
# After a timeout between two actions of one response, half the response does not
# match the disk. Fall back to the last pair of messages and snapshot that is proven.
- from sprout.contract.mini_history import read_entries, read_prefix, training_messages
+ from sprout.contract.mini_history import read_entries, read_prefix
from sprout.worker.agents.mini_swe.history import index_mini
files = list((directory / "native-home").glob(f"**/*{session_id}.jsonl"))
diff --git a/src/sprout/worker/jobs/review/branching.py b/src/sprout/worker/jobs/review/branching.py
index 29a4ff9f..ea5966e9 100644
--- a/src/sprout/worker/jobs/review/branching.py
+++ b/src/sprout/worker/jobs/review/branching.py
@@ -18,8 +18,8 @@
from pathlib import Path
from typing import Any, Protocol
-from sprout.contract.mini_history import load_prefix, training_messages, validate_assistant_turn
-from sprout.worker.agents.mini_swe.history import actor_tools_at, conversation_restore, reference_at
+from sprout.contract.agents.history import load_prefix, training_messages, validate_assistant_turn
+from sprout.worker.agents.history import actor_tools_at, conversation_restore, reference_at
from sprout.worker.checkpoint.rollback import Checkpoint, load_checkpoints
from sprout.worker.jobs.review import structured_review
from sprout.worker.jobs.review.branch_plan import (
@@ -83,7 +83,7 @@ def json_overhead(self, schema: dict[str, Any], name: str) -> int: ...
def require_mini_parent(journal: Path) -> None:
"""Raises ValueError unless ``journal`` has runs, and each of them is a mini-swe-agent run."""
slots = {e["slot"] for e in read_journal(journal) if e.get("type") == "run.started" and e.get("slot")}
- if slots != {"mini-swe-agent"}:
+ if slots not in ({"mini-swe-agent"}, {"opencollab-base"}):
raise ValueError("This branch mode accepts only mini-swe-agent parent trajectories")
@@ -110,15 +110,19 @@ def reviewer_context(journal: Path, checkpoints: Mapping[int, Checkpoint]) -> di
}
count, cuts = 0, {}
for entry in prefix:
- if entry["type"] == "mini.message" and entry["message"].get("role") != "exit":
+ if entry["type"] in {"mini.message", "oc.message"} and entry["message"].get("role") != "exit":
count += 1
- elif entry["type"] == "mini.turn":
+ elif entry["type"] == "oc.context":
+ count = len(entry["messages"])
+ elif entry["type"] in {"mini.turn", "oc.turn"}:
step = closed.get(entry["turn_id"])
if step in checkpoints:
cuts[str(step)] = count
if set(cuts) != {str(step) for step in checkpoints}:
raise ValueError("Reviewer mini history does not cover every eligible checkpoint")
- workspace = next((e.get("workspace") for e in prefix if e["type"] == "mini.session"), None)
+ workspace = next(
+ (e.get("workspace") for e in prefix if e["type"] in {"mini.session", "oc.session"}), None
+ )
return {
"messages": training_messages(prefix),
"prefix_message_counts": cuts,
@@ -330,13 +334,17 @@ def prepare_branches(
try:
assistant_turn = validate_assistant_turn(
proposed,
- history=[e["message"] for e in native if e["type"] == "mini.message"],
+ history=training_messages(native),
tools=tools,
+ slot="opencollab-base" if native[0].get("type") == "oc.session" else "mini-swe-agent",
)
except ValueError as error:
raise ReviewerPlanError(f"branch {index} ({base_name}@{step}): {error}") from error
for call_no, call in enumerate(assistant_turn["tool_calls"], 1):
- if SUBMISSION_MARKER in call["function"]["arguments"]:
+ if (
+ call["function"]["name"] == "submit_patch"
+ or SUBMISSION_MARKER in call["function"]["arguments"]
+ ):
raise ReviewerPlanError(
f"branch {index} ({base_name}@{step}): a reviewer-authored turn never submits; "
f"command {call_no} contains {SUBMISSION_MARKER}"
@@ -610,7 +618,12 @@ def review(
result: tuple[dict[str, Any], list[BranchChoice]] | None = review_with_feedback(
_reviewer_request(transport, model=model, schema=schema),
fit(),
- _reply_parser(guidance_mode, round_no=round_no, structured=schema is not None),
+ _reply_parser(
+ guidance_mode,
+ round_no=round_no,
+ structured=schema is not None,
+ tools_by_base={row["name"]: row.get("native_history", {}).get("tools", []) for row in reports},
+ ),
validate,
max_attempts=reviewer_max_attempts,
record=record,
@@ -633,7 +646,7 @@ def _eligible_points(
eligible: Mapping[str, Collection[int]] | None,
) -> dict[str, dict[int, Checkpoint]]:
"""Each attempt's branch points up to the last step of its report, narrowed by ``eligible``."""
- from sprout.worker.agents.mini_swe.history import available_branch_points
+ from sprout.worker.agents.history import available_branch_points
available = {a.name: available_branch_points(a.outcome.journal_path) for a in attempts}
return {
@@ -795,13 +808,22 @@ def reduce() -> str | None:
return fit, reduce
-def _reply_parser(guidance_mode: str, *, round_no: int, structured: bool) -> Callable[[str], Any]:
+def _reply_parser(
+ guidance_mode: str,
+ *,
+ round_no: int,
+ structured: bool,
+ tools_by_base: dict[str, list[dict[str, Any]]] | None = None,
+) -> Callable[[str], Any]:
"""The parser that reads the branch plan from a reviewer reply in ``guidance_mode``."""
parsers: dict[str, Callable[[str], Any]] = {
"user-hint": extract_json,
"none": extract_branch_plan,
"assistant-turn": partial(
- structured_review.extract_assistant_turn_plan, round_no=round_no, json_only=structured
+ structured_review.extract_assistant_turn_plan,
+ round_no=round_no,
+ json_only=structured,
+ tools_by_base=tools_by_base,
),
}
return parsers[guidance_mode]
diff --git a/src/sprout/worker/jobs/review/job.py b/src/sprout/worker/jobs/review/job.py
index 0cfdb1c9..edd3c402 100644
--- a/src/sprout/worker/jobs/review/job.py
+++ b/src/sprout/worker/jobs/review/job.py
@@ -32,10 +32,10 @@
from pathlib import Path
from typing import Any
+from sprout.contract.agents.history import training_messages
from sprout.contract.messages import clean_messages
-from sprout.contract.mini_history import training_messages
from sprout.runstore.specs import ReviewSpec
-from sprout.worker.agents.mini_swe.history import available_branch_points
+from sprout.worker.agents.history import available_branch_points
from sprout.worker.jobs.review.branching import (
Attempt,
BranchChoice,
diff --git a/src/sprout/worker/jobs/review/structured_review.py b/src/sprout/worker/jobs/review/structured_review.py
index 38f522a2..8a9f4b25 100644
--- a/src/sprout/worker/jobs/review/structured_review.py
+++ b/src/sprout/worker/jobs/review/structured_review.py
@@ -125,7 +125,9 @@ def _validate(schema: dict[str, Any], value: object, where: str) -> None:
) from error
-def _assistant_turn(branch: dict[str, Any], *, round_no: int, index: int) -> dict[str, Any]:
+def _assistant_turn(
+ branch: dict[str, Any], *, round_no: int, index: int, command_tool: str = "bash"
+) -> dict[str, Any]:
"""The wire message for a flat branch: its text, and one bash call per command."""
calls = []
for call, command in enumerate(branch["commands"], 1):
@@ -138,7 +140,7 @@ def _assistant_turn(branch: dict[str, Any], *, round_no: int, index: int) -> dic
{
"id": CALL_ID.format(round_no=round_no, branch=index + 1, call=call),
"type": "function",
- "function": {"name": "bash", "arguments": {"command": command}},
+ "function": {"name": command_tool, "arguments": {"command": command}},
}
)
if not branch["content"].strip():
@@ -146,7 +148,13 @@ def _assistant_turn(branch: dict[str, Any], *, round_no: int, index: int) -> dic
return {"role": "assistant", "content": branch["content"], "tool_calls": calls}
-def extract_assistant_turn_plan(text: str, *, round_no: int = 0, json_only: bool = False) -> dict[str, Any]:
+def extract_assistant_turn_plan(
+ text: str,
+ *,
+ round_no: int = 0,
+ json_only: bool = False,
+ tools_by_base: dict[str, list[dict[str, Any]]] | None = None,
+) -> dict[str, Any]:
"""The plan in ``text`` with each branch in the nested form, and each call's arguments as a JSON string.
A branch gives ``content`` and ``commands``, or a whole ``assistant_turn``. The calls built from
@@ -157,6 +165,9 @@ def extract_assistant_turn_plan(text: str, *, round_no: int = 0, json_only: bool
result: dict[str, Any] = deepcopy(plan)
for index, branch in enumerate(result["branches"]):
where = "branches.%d" % index
+ tools = (tools_by_base or {}).get(branch.get("base"), [])
+ names = {tool["function"]["name"] for tool in tools}
+ command_tool = "run_command" if "run_command" in names else "bash"
flat = {"content", "commands"} & set(branch)
if flat and "assistant_turn" in branch:
raise ValueError(
@@ -165,12 +176,19 @@ def extract_assistant_turn_plan(text: str, *, round_no: int = 0, json_only: bool
)
if flat:
_validate(FLAT_BRANCH, branch, where)
- turn = _assistant_turn(branch, round_no=round_no, index=index)
+ turn = _assistant_turn(branch, round_no=round_no, index=index, command_tool=command_tool)
for key in ("content", "commands"):
del branch[key]
branch["assistant_turn"] = turn
elif "assistant_turn" in branch:
- _validate(NESTED_BRANCH, branch, where)
+ nested = deepcopy(NESTED_BRANCH)
+ if names:
+ function = nested["properties"]["assistant_turn"]["properties"]["tool_calls"]["items"][
+ "properties"
+ ]["function"]["properties"]
+ function["name"]["enum"] = sorted(names)
+ function["arguments"] = {"type": "object"}
+ _validate(nested, branch, where)
else:
raise ValueError("Structured branch-plan field %s: a branch needs content and commands" % where)
for number, call in enumerate(branch["assistant_turn"]["tool_calls"]):
diff --git a/src/sprout/worker/jobs/rollout.py b/src/sprout/worker/jobs/rollout.py
index c804c0d6..4856fad5 100644
--- a/src/sprout/worker/jobs/rollout.py
+++ b/src/sprout/worker/jobs/rollout.py
@@ -24,8 +24,8 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any
+from sprout.contract.agents.history import load_prefix, materialize
from sprout.contract.continuation import Continuation, prepare
-from sprout.contract.mini_history import load_prefix, materialize
from sprout.contract.run_spec import KEEP_SANDBOX_S, RunSpec
from sprout.runstore.config import referenced_env, resolve
from sprout.runstore.payload import receive_payload
diff --git a/src/sprout/worker/run/rollout.py b/src/sprout/worker/run/rollout.py
index 9730c158..008b928a 100644
--- a/src/sprout/worker/run/rollout.py
+++ b/src/sprout/worker/run/rollout.py
@@ -121,6 +121,18 @@ def before(self, ctx: CallContext) -> Verdict:
except ValueError as error:
return Reject(str(error))
+ def status(self) -> dict[str, int | float | None]:
+ """The enforced limits and measured calls of this run; tool calls have no count limit."""
+ with self.lock:
+ return {
+ "model_calls": self.model_calls,
+ "max_turns": self.max_turns,
+ "remaining_turns": max(0, self.max_turns - self.model_calls),
+ "runtime_tool_calls": self.tool_calls,
+ "max_tool_calls": None,
+ "remaining_seconds": max(0.0, self.deadline - time.time()),
+ }
+
def finish(self) -> None:
"""Disarm the deadline, and journal the call counts as ``rollout.usage``."""
if self.timer:
diff --git a/src/sprout/worker/run/runner.py b/src/sprout/worker/run/runner.py
index 20952175..7c6034dd 100644
--- a/src/sprout/worker/run/runner.py
+++ b/src/sprout/worker/run/runner.py
@@ -147,11 +147,15 @@ def run(self, spec: RunSpec) -> RunOutcome:
detach = self._follow_reboards(spec, provisioned, claim)
_emit_sandbox_created(spec=spec, journal=journal, sandbox_id=provisioned.sandbox_id)
if spec.agent == "mini":
- from sprout.worker.agents.mini_swe.sandbox import resolve_workspace
+ if spec.slot == "opencollab-base":
+ from sprout.worker.agents.opencollab.workspace import resolve_workspace
+ else:
+ from sprout.worker.agents.mini_swe.sandbox import resolve_workspace
extra = resolve_workspace(provisioned.session, extra, journal)
tools = self._tool_access(spec, journal, provisioned, control, rollout_controls)
task = _task_spec(spec=spec, extra=extra, control=control, session=provisioned.session)
+ task.extra["rollout_controls"] = rollout_controls
gateway = self._wire_gateway(spec, journal, task, run_id, controls=rollout_controls)
result = slot.run(task, journal, tools)
error = _stop_error(control=control, result=result)
@@ -475,9 +479,13 @@ def _image_reference(spec: RunSpec) -> str | None:
def _agent_slot(spec: RunSpec) -> AgentSlot:
"""The slot that runs ``spec.agent``. Raises ValueError for a slot or an agent that no slot runs."""
- if spec.slot != "mini-swe-agent":
+ if spec.slot not in {"mini-swe-agent", "opencollab-base"}:
raise ValueError("unknown slot %r; the only slot is mini-swe-agent" % spec.slot)
if spec.agent == "mini":
+ if spec.slot == "opencollab-base":
+ from sprout.worker.agents.opencollab.slot import OpenCollabSlot
+
+ return OpenCollabSlot()
return MiniSweSlot()
if spec.agent == "nop":
return NopSlot()
diff --git a/tests/rl_driver/test_miles_requests.py b/tests/rl_driver/test_miles_requests.py
index ee666bbf..06fabf18 100644
--- a/tests/rl_driver/test_miles_requests.py
+++ b/tests/rl_driver/test_miles_requests.py
@@ -310,7 +310,7 @@ def test_a_search_request_one_slot_short_of_its_branches_is_refused(fields, slot
pytest.param(adapter_config(workers=4), "Invalid Miles adapter config", id="unknown-field"),
pytest.param(
adapter_config(run_defaults={"slot": "codex"}),
- "The Miles adapter supports mini-swe-agent only",
+ "The Miles adapter requires mini-swe-agent or opencollab-base",
id="other-slot",
),
pytest.param(
diff --git a/tests/runstore/test_specs.py b/tests/runstore/test_specs.py
index 7673c9aa..043b8f3f 100644
--- a/tests/runstore/test_specs.py
+++ b/tests/runstore/test_specs.py
@@ -329,8 +329,9 @@ def test_a_continuation_that_changes_anything_else_is_refused(child):
rollout_spec(agent="nop"),
rollout_spec(agent="mini", harbor_task=TASK_DIR, keep_sandbox=True),
rollout_spec(slot="mini-swe-agent", cwd=".", run_id=None),
+ rollout_spec(slot="opencollab-base"),
],
- ids=["kept-sandbox", "oracle", "nop", "mini-on-a-harbor-task", "the-defaults-spelled-out"],
+ ids=["kept-sandbox", "oracle", "nop", "mini-on-a-harbor-task", "the-defaults-spelled-out", "opencollab"],
)
def test_a_queued_rollout_may_keep_its_sandbox_and_run_each_agent(spec):
assert JobSpec("rollout", spec).validate()["spec"] == spec
@@ -342,7 +343,7 @@ def test_a_queued_rollout_may_keep_its_sandbox_and_run_each_agent(spec):
({"prompt": " "}, "RunSpec requires a nonempty prompt"),
({"model": "policy"}, "RunSpec requires a nonempty prompt"),
(rollout_spec(sandbox="podman"), "Unknown RunSpec fields"),
- (rollout_spec(slot="codex-cli"), "The only queued-run slot is mini-swe-agent"),
+ (rollout_spec(slot="codex-cli"), "Queued-run slot must be mini-swe-agent or opencollab-base"),
(rollout_spec(agent="codex"), "RunSpec agent must be mini, oracle or nop"),
(rollout_spec(agent="nop", harbor_task=" "), "RunSpec harbor_task must name a Harbor task directory"),
(rollout_spec(agent="oracle"), "An oracle run needs the harbor_task whose solution it runs"),
diff --git a/tests/worker/agents/opencollab/__init__.py b/tests/worker/agents/opencollab/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/worker/agents/opencollab/test_integration.py b/tests/worker/agents/opencollab/test_integration.py
new file mode 100644
index 00000000..26117a48
--- /dev/null
+++ b/tests/worker/agents/opencollab/test_integration.py
@@ -0,0 +1,196 @@
+# ruff: noqa: E402
+"""The shared worker, branch restoration, reviewer and training-export boundaries."""
+
+import json
+from dataclasses import asdict
+from pathlib import Path
+
+import pytest
+
+pytest.importorskip("opencollab")
+
+from sprout.contract.run_spec import RunSpec
+from sprout.worker.agents.history import available_branch_points, reference_at
+from sprout.worker.jobs.export import complete_message_result
+from sprout.worker.jobs.review.structured_review import extract_assistant_turn_plan
+from sprout.worker.jobs.rollout import _from_point
+from sprout.worker.run.runner import _agent_slot
+
+from .test_opencollab import Model, call, response, run
+from .test_review50_protocol import repository
+
+
+def test_runtime_submission_receipt_is_context_without_becoming_policy_output(tmp_path):
+ root = repository(tmp_path / "repo")
+ result, _, journal = run(
+ root, tmp_path / "run", Model([response(call("submit", "submit_patch"))]), protocol="review50-v1"
+ )
+ assert result.status == "completed", result.error
+ exported = complete_message_result(asdict(result), journal.parent, "opencollab-base")
+ assert "training_export_error" not in exported
+ messages = exported["training_messages"]
+ receipt = len(messages) - 1
+ action = next(i for i, m in enumerate(messages) if m.get("tool_calls"))
+ assert receipt in exported["training_provenance"]["inserted_messages"]
+ assert action not in exported["training_provenance"]["inserted_messages"]
+ assert exported["training_provenance"]["prefix_messages"] == 0
+ assert "patch_sha256" in messages[receipt]["content"]
+
+
+@pytest.mark.parametrize("rendering", ["content", "reasoning"])
+def test_queued_branch_materializes_exact_sdk_state_and_masks_reviewer(tmp_path, rendering):
+ root = repository(tmp_path / "repo")
+ parent, sandbox, journal = run(
+ root,
+ tmp_path / "parent",
+ Model([response(call("read", "read_file", filepath="source.txt")), response(content="Parent done.")]),
+ protocol="review50-v1",
+ )
+ assert parent.status == "completed", parent.error
+ step, checkpoint = min(available_branch_points(journal).items())
+ native = reference_at(journal, step, parent.native_session_id)
+ point = {
+ "id": "point",
+ "snapshot_id": checkpoint.snapshot_id,
+ "tool_depth": step,
+ "message_step": 1,
+ "native": native,
+ }
+ turn = {
+ "role": "assistant",
+ "content": "Check the retained source before editing.",
+ "tool_calls": [call("review", "run_command", command="git diff --stat")],
+ }
+ destination = tmp_path / "queued"
+ fields = _from_point(
+ {"prompt": "Fix the fixture", "extra": {}},
+ point,
+ continuation={
+ "guidance": "assistant-turn",
+ "assistant_turn": turn,
+ "assistant_turn_rendering": rendering,
+ },
+ directory=destination,
+ slot="opencollab-base",
+ )
+ assert fields["sandbox_image"] == checkpoint.snapshot_id
+ assert Path(fields["extra"]["native_prefix"]["path"]).is_file()
+ result, _, child_journal = run(
+ root,
+ tmp_path / "child",
+ Model([response(content="Child done.")]),
+ prefix=fields["extra"]["native_prefix"],
+ reviewer=fields["extra"]["assistant_turn"],
+ sandbox=sandbox,
+ protocol="review50-v1",
+ )
+ assert result.status == "completed", result.error
+ exported = complete_message_result(
+ asdict(result), child_journal.parent, "opencollab-base", recovery=point
+ )
+ assert "training_export_error" not in exported
+ provenance = exported["training_provenance"]
+ prefix = provenance["prefix_messages"]
+ assert prefix > 0 and prefix in provenance["inserted_messages"]
+ assert len(exported["training_messages"]) - 1 not in provenance["inserted_messages"]
+
+
+def test_flat_review_commands_use_the_selected_parents_actual_tool():
+ from sprout.worker.agents.opencollab.review50 import definition
+
+ branch = {
+ "name": "repair",
+ "base": "parent",
+ "branch_step": 1,
+ "why": "inspect",
+ "content": "Inspect the source first.",
+ "commands": ["git diff"],
+ }
+ plan = extract_assistant_turn_plan(
+ json.dumps({"synthesis": "Inspect first", "branches": [branch]}),
+ json_only=True,
+ tools_by_base={"parent": definition()["tools"]},
+ )
+ tool = plan["branches"][0]["assistant_turn"]["tool_calls"][0]
+ assert tool["function"]["name"] == "run_command"
+ assert json.loads(tool["function"]["arguments"]) == {"command": "git diff"}
+ mini = extract_assistant_turn_plan(
+ json.dumps({"synthesis": "Inspect first", "branches": [branch]}), json_only=True
+ )
+ assert mini["branches"][0]["assistant_turn"]["tool_calls"][0]["function"]["name"] == "bash"
+
+
+def test_slot_is_selected_by_the_existing_worker_runner():
+ assert _agent_slot(RunSpec(prompt="fix", slot="opencollab-base")).name == "opencollab-base"
+ assert _agent_slot(RunSpec(prompt="fix")).name == "mini-swe-agent"
+
+
+@pytest.mark.parametrize(
+ "protocol,success", [("single2", True), ("review50-v1", True), ("review50-v1", False)]
+)
+def test_historical_workspace_alias_is_prepared_without_overwriting_another_checkout(
+ tmp_path, monkeypatch, protocol, success
+):
+ from types import SimpleNamespace
+
+ from sprout.worker.agents.opencollab import workspace
+ from sprout.worker.run.journal import JournalWriter
+ from sprout.worker.tools.result import ToolResult
+
+ resolved = {
+ "mini_workspace": {"cwd": "/testbed", "repository_dir": "/testbed"},
+ "opencollab_tool_protocol": protocol,
+ }
+ monkeypatch.setattr(workspace, "resolve_native_workspace", lambda *_: resolved)
+ calls = []
+
+ def execute(name, arguments, **kwargs):
+ calls.append((name, arguments))
+ return ToolResult(success, "")
+
+ session = SimpleNamespace(execute=execute)
+ with JournalWriter(tmp_path / "workspace.jsonl") as journal:
+ if not success:
+ with pytest.raises(ValueError, match="alias"):
+ workspace.resolve_workspace(session, resolved, journal)
+ else:
+ assert workspace.resolve_workspace(session, resolved, journal) == resolved
+ if protocol == "single2":
+ assert not calls
+ else:
+ assert len(calls) == 1 and calls[0][0] == "shell"
+ assert 'test "$(cd /workspace && pwd -P)" = /testbed' in calls[0][1]["command"]
+ assert "ln -s -- /testbed /workspace" in calls[0][1]["command"]
+
+
+def test_native_sdk_uses_nonstreaming_requests_through_the_real_gateway(tmp_path, monkeypatch):
+ from sprout.worker.gateway import GatewayServer, RoutingTable
+ from sprout.worker.gateway.routing import ModelRoute
+ from sprout.worker.run.journal import JournalWriter
+ from tests.support.http import chat_server
+
+ completion = {
+ "id": "response",
+ "object": "chat.completion",
+ "created": 0,
+ "model": "fixture-model",
+ "choices": [
+ {"index": 0, "message": {"role": "assistant", "content": "Done."}, "finish_reason": "stop"}
+ ],
+ "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
+ }
+ with chat_server([completion]) as provider, JournalWriter(tmp_path / "gateway.jsonl") as journal:
+ table = RoutingTable({"default": ModelRoute(base_url=provider.url)})
+ gateway = GatewayServer(table, journal=journal).start()
+ token = table.mint("agent")
+ try:
+ for key, value in gateway.env_for(token).items():
+ monkeypatch.setenv(key, value)
+ checkout = tmp_path / "repo"
+ checkout.mkdir()
+ result, _, _ = run(checkout, tmp_path / "run", None)
+ finally:
+ gateway.stop()
+ assert result.status == "completed", result.error
+ assert result.final_text == "Done."
+ assert len(provider.requests) == 1 and not provider.requests[0].get("stream", False)
diff --git a/tests/worker/agents/opencollab/test_opencollab.py b/tests/worker/agents/opencollab/test_opencollab.py
new file mode 100644
index 00000000..c45b74e2
--- /dev/null
+++ b/tests/worker/agents/opencollab/test_opencollab.py
@@ -0,0 +1,464 @@
+# ruff: noqa: E402
+"""Single2 execution, exact rollback, and accounting across native branches."""
+
+import asyncio
+import json
+import subprocess
+import threading
+import time
+from uuid import uuid4
+
+import pytest
+
+pytest.importorskip("opencollab")
+from opencollab.adapters.llm.types import LLMResponse, Usage
+from opencollab.application.session_run import GenerationTimeoutError
+
+from sprout.contract.agents.history import load_prefix, training_messages, validate_assistant_turn
+from sprout.worker.agents.history import actor_tools_at, available_branch_points, reference_at
+from sprout.worker.agents.opencollab.environment import SproutEnvironment
+from sprout.worker.agents.opencollab.slot import OpenCollabSlot
+from sprout.worker.agents.slot import TaskSpec
+from sprout.worker.checkpoint.checkpointer import Checkpointer
+from sprout.worker.run.control import RunControl
+from sprout.worker.run.journal import JournalWriter, read_journal
+from sprout.worker.tools.access import ToolAccess
+from sprout.worker.tools.interceptors import MutationTracker
+from sprout.worker.tools.pipeline import ToolPipeline
+from sprout.worker.tools.result import CommandOutcome, ToolResult
+from sprout_sandbox.pool import Snapshot
+
+
+def call(identifier, name, **args):
+ return {"id": identifier, "type": "function", "function": {"name": name, "arguments": json.dumps(args)}}
+
+
+class Model:
+ def context_window(self):
+ return 128000
+
+ def __init__(self, responses):
+ self.responses = iter(responses)
+ self.requests = []
+ self.closed = False
+
+ async def complete(self, messages, **kwargs):
+ self.requests.append((json.loads(json.dumps(messages)), kwargs))
+ return next(self.responses)
+
+ async def close(self):
+ self.closed = True
+
+
+def response(*calls, content="Testing a concrete change."):
+ return LLMResponse(
+ content=content,
+ tool_calls=list(calls),
+ finish_reason="tool_calls" if calls else "stop",
+ usage=Usage(
+ input_tokens=10, output_tokens=5, raw_usage={"prompt_tokens": 10, "completion_tokens": 5}
+ ),
+ )
+
+
+class Sandbox:
+ """Only a controlled test fixture; does not claim live VM verification."""
+
+ def __init__(self, root):
+ self.root = root
+ self.sandbox_id = uuid4().hex
+ self.snapshots = {}
+ self.failure = False
+
+ def supports_snapshot(self):
+ return True
+
+ def call(self, name, args, timeout):
+ if self.failure:
+ raise ConnectionError("synthetic lost execution transport")
+ result = subprocess.run(
+ args["command"],
+ shell=True,
+ cwd=args["working_dir"],
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ )
+ return ToolResult(
+ result.returncode == 0,
+ result.stdout + result.stderr,
+ outcome=CommandOutcome(exit_code=result.returncode, stdout=result.stdout, stderr=result.stderr),
+ )
+
+ def snapshot(self, **kwargs):
+ identifier = str(uuid4())
+ self.snapshots[identifier] = {
+ path.name: path.read_bytes() for path in self.root.iterdir() if path.is_file()
+ }
+ return Snapshot(identifier)
+
+
+def run(
+ root,
+ logdir,
+ model,
+ *,
+ prefix=None,
+ reviewer=None,
+ point_only=False,
+ sandbox=None,
+ timeout=30,
+ protocol="single2",
+):
+ logdir.mkdir()
+ sandbox = sandbox or Sandbox(root)
+ with JournalWriter(logdir / "trajectory.jsonl") as journal:
+ control, tracker = RunControl(), MutationTracker()
+ tools = ToolAccess(
+ sandbox,
+ journal,
+ control=control,
+ pipeline=ToolPipeline([tracker]),
+ checkpointer=Checkpointer(session=sandbox, tracker=tracker),
+ )
+ extra = {
+ "mini_workspace": {"cwd": str(root), "repository_dir": str(root)},
+ "native_home": str(logdir / "native-home"),
+ "opencollab_llm": model,
+ "opencollab_tool_protocol": protocol,
+ }
+ if prefix:
+ extra["native_prefix"] = prefix
+ if reviewer:
+ extra["assistant_turn"] = reviewer
+ if point_only:
+ extra["resume_without_hint"] = True
+ task = TaskSpec(
+ "Fix the fixture.",
+ str(logdir),
+ model="fixture-model",
+ timeout_s=timeout,
+ control=control,
+ extra=extra,
+ )
+ result = OpenCollabSlot().run(task, journal, tools)
+ tools.finalize()
+ return result, sandbox, logdir / "trajectory.jsonl"
+
+
+def test_native_six_tools_closed_turns_and_reviewer_branch(tmp_path):
+ root = tmp_path / "repo"
+ root.mkdir()
+ model = Model(
+ [
+ response(
+ call("a", "file_write", path="fixture.txt", content="parent", mode="create"),
+ call("b", "file_read", path="fixture.txt"),
+ ),
+ response(call("c", "bash", command="printf later > fixture.txt")),
+ response(content="Done."),
+ ]
+ )
+ result, sandbox, journal = run(root, tmp_path / "parent", model)
+ assert result.status == "completed", result.error
+ assert result.usage["input_tokens"] == 30
+ assert model.closed
+ assert [schema["function"]["name"] for schema in model.requests[0][1]["tools"]] == [
+ "bash",
+ "file_read",
+ "file_write",
+ "apply_patch",
+ "git_diff",
+ "grep",
+ ]
+ assert model.requests[0][1]["reasoning_effort"] == "high"
+ points = available_branch_points(journal)
+ assert len(points) == 2
+ step, point = min(points.items())
+ prefix = reference_at(journal, step, point.session_ckpt)
+ entries = load_prefix(prefix)
+ retained = training_messages(entries)
+ assert retained[-1]["role"] == "tool"
+ assert "parent" in retained[-1]["content"]
+ assert not any("printf later" in str(message) for message in retained)
+ # Restore this test fixture's disk BEFORE continuing from the exact state.
+ for path in root.iterdir():
+ if path.is_file():
+ path.unlink()
+ for name, contents in sandbox.snapshots[point.snapshot_id].items():
+ (root / name).write_bytes(contents)
+ branch_model = Model([response(content="Branch done.")])
+ reviewer = {
+ "role": "assistant",
+ "content": "I will verify a revised fix.",
+ "tool_calls": [call("review", "file_write", path="fixture.txt", content="branch", mode="create")],
+ }
+ branch, _, child_journal = run(root, tmp_path / "child", branch_model, prefix=prefix, reviewer=reviewer)
+ assert branch.status == "completed", branch.error
+ assert (root / "fixture.txt").read_text() == "branch"
+ assert branch.usage["input_tokens"] == 10 # inherited and reviewer tokens are not recharged
+ child_messages = branch_model.requests[0][0]
+ assert child_messages[: len(retained)] == retained
+ # Single2 adds its own native progress steering; branching must not add
+ # another task prompt or reviewer instruction as a synthetic user message.
+ new_users = [message for message in child_messages[len(retained) :] if message["role"] == "user"]
+ assert all(message["content"].startswith("[Progress:") for message in new_users)
+ assert len(available_branch_points(child_journal)) == 1
+ validate_assistant_turn(
+ reviewer, history=retained, tools=actor_tools_at(journal, step), slot="opencollab-base"
+ )
+
+
+def test_transport_failure_cannot_be_reported_completed(tmp_path):
+ root = tmp_path / "repo"
+ root.mkdir()
+ sandbox = Sandbox(root)
+ sandbox.failure = True
+ result, _, journal = run(
+ root,
+ tmp_path / "broken",
+ Model([response(call("a", "bash", command="true")), response(content="Done.")]),
+ sandbox=sandbox,
+ )
+ assert result.status == "error"
+ assert not available_branch_points(journal)
+ rows = read_journal(journal)
+ assert any(row["type"] == "execution.uncertain" for row in rows)
+ bridge = next(row for row in rows if row["type"] == "opencollab.bridge.failed")
+ assert bridge["cause_type"] == "ToolInfraError"
+ assert "did not settle" in bridge["cause"]
+ uncertain = next(row for row in rows if row["type"] == "execution.uncertain")
+ assert uncertain["exception"] == "ConnectionError"
+ assert uncertain["message"] == "synthetic lost execution transport"
+ finished = next(row for row in rows if row["type"] == "run.finished")
+ assert finished["stop_reason"] == "infrastructure_error"
+
+
+@pytest.mark.parametrize(
+ "returncode,stdout,truncated,error",
+ [
+ (0, "Unicode: 中文\n", False, None),
+ (1, "", False, FileNotFoundError),
+ (0, "partial", True, OSError),
+ ],
+)
+def test_full_file_read_uses_owned_transport(tmp_path, returncode, stdout, truncated, error):
+ """New Docker private methods must not bypass the SPROUT environment port."""
+ from opencollab.adapters.env import ExecResult
+
+ env = SproutEnvironment(None, str(tmp_path), RunControl(), time.monotonic() + 10)
+ calls = []
+
+ async def execute(command, timeout=120):
+ calls.append(command)
+ return ExecResult(returncode, stdout, "missing", stdout_truncated=truncated)
+
+ env.exec_cmd = execute
+ path = "/workspace/a'b.txt"
+ if error is None:
+ assert asyncio.run(env.read_file(path)) == stdout
+ else:
+ with pytest.raises(error):
+ asyncio.run(env.read_file(path))
+ import shlex
+
+ assert calls == [f"cat -- {shlex.quote(path)}"]
+
+
+def test_cancelled_command_is_settled_without_becoming_transport_failure(tmp_path):
+ """OpenCollab may cancel a tool deadline while its synchronous worker is
+ queued. The adapter must let that owned call settle before returning the
+ cancellation, otherwise it runs behind a later checkpoint."""
+ settled = threading.Event()
+
+ class SlowTools:
+ def call(self, _name, _args, *, call_id, turn_id):
+ assert call_id and turn_id
+ time.sleep(0.05)
+ settled.set()
+ return ToolResult(True, "done", outcome=CommandOutcome(exit_code=0, stdout="done"))
+
+ async def scenario():
+ env = SproutEnvironment(SlowTools(), str(tmp_path), RunControl(), time.monotonic() + 10)
+ env.turn_id = "turn"
+ task = asyncio.create_task(env.exec_cmd("true"))
+ await asyncio.sleep(0.005)
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ return env
+
+ env = asyncio.run(scenario())
+ assert settled.is_set()
+ assert env.failure is None
+ assert len(env.call_ids) == 1
+
+
+def test_controller_stop_after_settled_command_is_not_transport_failure(tmp_path):
+ control = RunControl()
+
+ class StoppingTools:
+ def call(self, _name, _args, *, call_id, turn_id):
+ assert call_id and turn_id
+ control.request_stop("deadline", stop_reason="timeout")
+ return ToolResult(True, "done", outcome=CommandOutcome(exit_code=0, stdout="done"))
+
+ async def scenario():
+ env = SproutEnvironment(StoppingTools(), str(tmp_path), control, time.monotonic() + 10)
+ env.turn_id = "turn"
+ with pytest.raises(Exception, match="deadline"):
+ await env.exec_cmd("true")
+ return env
+
+ env = asyncio.run(scenario())
+ assert env.failure is None
+ assert len(env.call_ids) == 1
+
+
+def test_prefix_checksum_and_invalid_reviewer_arguments(tmp_path):
+ import hashlib
+
+ from opencollab.bootstrap.agent_profiles import resolve_agent_profile
+
+ from sprout.worker.agents.opencollab.history import History
+
+ path = tmp_path / "native.jsonl"
+ history = History(path, "session", {"cwd": "/fixture"})
+ history.append({"type": "oc.turn", "session_snapshot": {}, "runtime_call_ids": []})
+ history.close()
+ reference = {"path": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
+ assert load_prefix(reference)
+ with path.open("ab") as stream:
+ stream.write(b" \n")
+ with pytest.raises(ValueError, match="checksum"):
+ load_prefix(reference)
+ schemas = [tool.to_openai_schema() for tool in resolve_agent_profile("base").resolve_tools("coding")]
+ invalid = {
+ "role": "assistant",
+ "content": "Check a hypothesis.",
+ "tool_calls": [call("a", "bash", command="true", invented=True)],
+ }
+ with pytest.raises(ValueError, match="recorded tool schema"):
+ validate_assistant_turn(invalid, tools=schemas, slot="opencollab-base")
+
+
+def test_native_context_shaping_preserves_actual_model_context(tmp_path):
+ from sprout.worker.agents.opencollab.history import History
+
+ path = tmp_path / "shaped.jsonl"
+ history = History(path, "native", {"cwd": "/fixture"})
+ initial = [{"role": "system", "content": "System."}, {"role": "user", "content": "Original task."}]
+ history.persist_messages(initial)
+ shaped = [{"role": "system", "content": "System."}, {"role": "user", "content": "Shaped native task."}]
+ history.persist_messages(shaped)
+ history.persist_messages(shaped[:1])
+ history.close()
+ rows = [json.loads(line) for line in path.read_text().splitlines()]
+ assert training_messages(rows) == shaped[:1]
+
+
+def test_slow_generation_has_no_native_per_call_stop(tmp_path, monkeypatch):
+ import asyncio
+
+ from sprout.worker.agents.opencollab import slot
+
+ build = slot.build_session
+ sessions = []
+ agents = []
+
+ def capture(**kwargs):
+ agents.append(kwargs["agent"])
+ session = build(**kwargs)
+ sessions.append(session)
+ return session
+
+ class SlowModel(Model):
+ async def complete(self, messages, **kwargs):
+ await asyncio.sleep(0.08)
+ return response(content="Done.")
+
+ monkeypatch.setattr(slot, "build_session", capture)
+ root = tmp_path / "repo"
+ root.mkdir()
+ result, _, _ = run(root, tmp_path / "slow", SlowModel([]), timeout=1)
+ assert sessions[0].runner._per_call_timeout is None
+ assert all(tool.disable_outer_timeout for tool in agents[0].tools)
+ assert result.status == "completed"
+
+
+@pytest.mark.parametrize(
+ "exception", [TimeoutError("provider timed out"), GenerationTimeoutError("synthetic generation deadline")]
+)
+def test_inner_timeout_is_infrastructure_error_not_actor_deadline(tmp_path, exception):
+ class FailedModel(Model):
+ async def complete(self, messages, **kwargs):
+ raise exception
+
+ root = tmp_path / "repo"
+ root.mkdir()
+ result, _, journal = run(root, tmp_path / "failed", FailedModel([]))
+ assert result.status == "error"
+ assert type(exception).__name__ in result.error
+ assert "wall-time exhausted" not in result.error
+ finished = next(r for r in read_journal(journal) if r["type"] == "run.finished")
+ assert finished["termination_kind"] == "infrastructure_error"
+ assert finished["elapsed_s"] < finished["actor_timeout_s"]
+
+
+def test_real_actor_deadline_still_stops_a_pending_generation(tmp_path):
+ import asyncio
+
+ class HungModel(Model):
+ async def complete(self, messages, **kwargs):
+ await asyncio.Event().wait()
+
+ root = tmp_path / "repo"
+ root.mkdir()
+ result, _, journal = run(root, tmp_path / "deadline", HungModel([]), timeout=0.08)
+ assert result.status == "timeout"
+ finished = next(r for r in read_journal(journal) if r["type"] == "run.finished")
+ assert finished["termination_kind"] == "actor_deadline"
+ assert finished["elapsed_s"] >= 0.08
+
+
+def test_native_compaction_uses_loop_owned_provider_clients(tmp_path):
+ import asyncio
+
+ from opencollab.application.compaction_summary import run_coro_blocking
+
+ from sprout.worker.agents.opencollab.slot import RecordedModel
+
+ created = []
+
+ class LoopOwnedModel(Model):
+ def __init__(self):
+ super().__init__([])
+ self.loop = asyncio.get_running_loop()
+ created.append(self)
+
+ async def complete(self, messages, **kwargs):
+ assert asyncio.get_running_loop() is self.loop
+ return response(content="Retained progress.")
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ assert asyncio.get_running_loop() is self.loop
+ await self.close()
+
+ async def exercise(journal):
+ primary = LoopOwnedModel()
+ model = RecordedModel(primary, journal, RunControl(), client_factory=LoopOwnedModel)
+ await model.complete([])
+ for _ in range(2):
+ run_coro_blocking(lambda: model.complete([]))
+ await model.complete([])
+ assert model.usage.input_tokens == 40
+ assert not primary.closed
+ await primary.close()
+
+ with JournalWriter(tmp_path / "compaction.jsonl") as journal:
+ asyncio.run(exercise(journal))
+ assert len(created) == 3 and all(client.closed for client in created)
diff --git a/tests/worker/agents/opencollab/test_opencollab_large_write.py b/tests/worker/agents/opencollab/test_opencollab_large_write.py
new file mode 100644
index 00000000..9b916998
--- /dev/null
+++ b/tests/worker/agents/opencollab/test_opencollab_large_write.py
@@ -0,0 +1,202 @@
+# ruff: noqa: E402
+"""Large writes remain bounded, atomic, and on the owned ToolAccess path."""
+
+import asyncio
+import subprocess
+import time
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+pytest.importorskip("opencollab")
+from opencollab.adapters.env import ExecResult
+
+from sprout.worker.agents.opencollab import environment
+from sprout.worker.run.control import RunControl
+from sprout.worker.run.journal import JournalWriter, read_journal
+from sprout.worker.tools.access import ToolAccess
+
+from .test_opencollab import Model, Sandbox, call, response, run
+
+
+@pytest.fixture
+def port(tmp_path):
+ sandbox = Sandbox(tmp_path)
+ with JournalWriter(tmp_path / "calls.jsonl") as journal:
+ control = RunControl()
+ tools = ToolAccess(sandbox, journal, control=control)
+ env = environment.SproutEnvironment(
+ tools,
+ str(tmp_path),
+ control,
+ time.monotonic() + 120,
+ )
+ env.turn_id = "large-write"
+ yield env
+
+
+@pytest.mark.parametrize("size", [0, 32769, 300000, 4 * 1024 * 1024])
+def test_large_utf8_roundtrip_bounded_owned_calls(tmp_path, port, size):
+ target = tmp_path / "quoted ' 中文" / "file.txt"
+ content = "中" * (size // 3) + "x" * (size % 3)
+ asyncio.run(port.write_file(str(target), content))
+ assert target.read_bytes() == content.encode()
+ calls = [row for row in read_journal(tmp_path / "calls.jsonl") if row["type"] == "tool.started"]
+ assert calls
+ assert max(len(row["args"]["command"].encode()) for row in calls) < 65536
+ assert len(calls) == 2 + (size + 32767) // 32768
+ assert not list(target.parent.glob(".sprout-write-*"))
+
+
+def test_existing_executable_permissions_are_preserved(tmp_path, port):
+ target = tmp_path / "script"
+ target.write_text("original")
+ target.chmod(0o751)
+ asyncio.run(port.write_file(str(target), "new" * 100000))
+ assert target.stat().st_mode & 0o777 == 0o751
+
+
+@pytest.mark.parametrize("failure", ["chunk", "checksum", "cancel"])
+def test_failure_keeps_original_and_cleans_owned_stage(tmp_path, port, failure):
+ target = tmp_path / "target"
+ target.write_text("original")
+ execute = port.exec_cmd
+ chunks = 0
+
+ async def inject(command, timeout=120):
+ nonlocal chunks
+ if "base64 -d >>" in command:
+ chunks += 1
+ if chunks == 2:
+ if failure == "chunk":
+ return ExecResult(1, "", "injected append failure")
+ if failure == "cancel":
+ raise asyncio.CancelledError()
+ command = command.replace("base64 -d >>", "head -c 1 >>")
+ return await execute(command, timeout)
+
+ port.exec_cmd = inject
+ error = asyncio.CancelledError if failure == "cancel" else OSError
+ with pytest.raises(error):
+ asyncio.run(port.write_file(str(target), "new" * 100000))
+ assert target.read_text() == "original"
+ assert not list(tmp_path.glob(".sprout-write-*"))
+
+
+def test_collision_does_not_overwrite_or_delete_foreign_stage(tmp_path, port, monkeypatch):
+ monkeypatch.setattr(environment, "uuid4", lambda: SimpleNamespace(hex="collision"))
+ stage = tmp_path / ".sprout-write-collision"
+ stage.write_text("not ours")
+ with pytest.raises(OSError, match="create stage"):
+ asyncio.run(port.write_file(str(tmp_path / "target"), "new"))
+ assert stage.read_text() == "not ours"
+ assert not (tmp_path / "target").exists()
+
+
+@pytest.mark.parametrize(
+ "path,content",
+ [
+ ("relative", "value"),
+ ("/", "value"),
+ ("/target", "x" * (4 * 1024 * 1024 + 1)),
+ ],
+)
+def test_invalid_write_has_no_sandbox_effect(tmp_path, port, path, content):
+ with pytest.raises(ValueError):
+ asyncio.run(port.write_file(path, content))
+ assert not port.call_ids
+
+
+def test_native_small_str_replace_in_large_existing_file(tmp_path):
+ root = tmp_path / "repo"
+ root.mkdir()
+ target = root / "large.py"
+ original = "# padding\n" * 30000 + "old_unique_value = 1\n"
+ target.write_text(original)
+ result, _, journal = run(
+ root,
+ tmp_path / "native",
+ Model(
+ [
+ response(
+ call(
+ "edit",
+ "file_write",
+ path="large.py",
+ mode="str_replace",
+ old_str="old_unique_value = 1",
+ new_str="new_unique_value = 2",
+ )
+ ),
+ response(content="Done."),
+ ]
+ ),
+ )
+ assert result.status == "completed", result.error
+ assert target.read_text() == original.replace("old_unique_value = 1", "new_unique_value = 2")
+ calls = [row for row in read_journal(journal) if row["type"] == "tool.started"]
+ assert len(calls) > 5
+ assert max(len(row["args"]["command"].encode()) for row in calls) < 65536
+
+
+def test_same_runtime_binary_accepts_large_write(tmp_path, port):
+ """No Podman/global cleanup fixtures: only a private local runtime process."""
+ import httpx
+
+ from sprout.worker.tools.result import CommandOutcome, ToolResult
+ from tests.support.paths import RUNTIME_BIN
+
+ binary = RUNTIME_BIN
+ if not binary.is_file():
+ pytest.skip("prebuilt runtime unavailable")
+ # Unix socket path length is limited, independent of shell argument size.
+ import tempfile
+
+ with tempfile.TemporaryDirectory(prefix="sprout-write-") as temporary:
+ socket = Path(temporary) / "r.sock"
+ process = subprocess.Popen(
+ [str(binary), "--listen", f"unix:{socket}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
+ )
+ try:
+ for _ in range(100):
+ if socket.exists():
+ break
+ assert process.poll() is None
+ time.sleep(0.02)
+ with httpx.Client(
+ transport=httpx.HTTPTransport(uds=str(socket)), base_url="http://runtime", timeout=10
+ ) as client:
+
+ def call_runtime(name, args, timeout):
+ import json
+
+ reply = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "tools/call",
+ "params": {"name": name, "arguments": args},
+ },
+ )
+ reply.raise_for_status()
+ result = reply.json()["result"]
+ output = result["content"][0]["text"]
+ if result.get("isError"):
+ value = json.loads(output)
+ outcome = CommandOutcome(
+ exit_code=value["exit_code"], stdout=value["stdout"], stderr=value["stderr"]
+ )
+ else:
+ outcome = CommandOutcome(exit_code=0, stdout=output)
+ return ToolResult(not result.get("isError"), output, outcome=outcome)
+
+ port.tools.session.call = call_runtime
+ target = tmp_path / "runtime-large.txt"
+ content = "runtime 中文\n" * 30000
+ asyncio.run(port.write_file(str(target), content))
+ assert target.read_bytes() == content.encode()
+ finally:
+ process.terminate()
+ process.wait(timeout=8)
diff --git a/tests/worker/agents/opencollab/test_review50_protocol.py b/tests/worker/agents/opencollab/test_review50_protocol.py
new file mode 100644
index 00000000..d12e8fef
--- /dev/null
+++ b/tests/worker/agents/opencollab/test_review50_protocol.py
@@ -0,0 +1,200 @@
+# ruff: noqa: E402
+"""The reviewed tool interface must execute and finish through native Single2."""
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+pytest.importorskip("opencollab")
+
+from sprout.contract.agents.history import load_prefix, training_messages
+from sprout.contract.messages import clean_messages, export_tools
+from sprout.worker.agents.history import available_branch_points, reference_at
+from sprout.worker.agents.opencollab.review50 import build_tools, definition
+from sprout.worker.run.journal import JournalWriter, read_journal
+from tests.worker.agents.opencollab.test_opencollab import Model, call, response, run
+
+
+def repository(root: Path) -> Path:
+ root.mkdir()
+ subprocess.run(["git", "init", "-q", str(root)], check=True)
+ (root / "source.txt").write_text("original\n")
+ subprocess.run(["git", "-C", str(root), "add", "."], check=True)
+ # This is isolated test-fixture history, never a benchmark's history.
+ subprocess.run(
+ [
+ "git",
+ "-C",
+ str(root),
+ "-c",
+ "user.name=Fixture",
+ "-c",
+ "user.email=fixture@example.invalid",
+ "commit",
+ "-qm",
+ "fixture",
+ ],
+ check=True,
+ )
+ return root
+
+
+def exported(journal):
+ events = read_journal(journal)
+ ref = next(e for e in events if e["type"] == "session.ref")
+ rows = [json.loads(line) for line in Path(ref["transcript_path"]).read_text().splitlines()]
+ return clean_messages(training_messages(rows)), events
+
+
+def test_review50_file_tools_submission_and_closed_history(tmp_path):
+ root = repository(tmp_path / "repo")
+ model = Model(
+ [
+ response(call("read", "read_file", filepath="source.txt", start_line=1, end_line=1)),
+ response(
+ call("edit", "edit_file", filepath="source.txt", old_string="original", new_string="fixed")
+ ),
+ response(call("write", "write_file", filepath="new.txt", content="new file\n")),
+ response(call("status", "get_status")),
+ response(call("command", "run_command", command="printf verified")),
+ response(call("submit", "submit_patch")),
+ ]
+ )
+ result, _, journal = run(root, tmp_path / "run", model, protocol="review50-v1")
+ assert result.status == "completed", result.error
+ assert len(model.requests) == 6 # no extra model request needed to end
+ messages, events = exported(journal)
+ results = {m["tool_call_id"]: json.loads(m["content"]) for m in messages if m["role"] == "tool"}
+ assert results["read"]["content"] == "original\n"
+ assert results["edit"]["occurrences"] == 1
+ assert results["write"]["size"] == 9
+ assert results["status"]["grading"] == "pending official verifier"
+ assert results["status"]["budget"]["agent_tool_calls"] == 4
+ assert results["status"]["budget"]["max_tool_calls"] is None
+ assert "new.txt" in results["status"]["patch"]["working_tree_status"]
+ assert "source.txt" in results["status"]["patch"]["tracked_diff_stat"]
+ assert results["command"]["stdout"] == "verified"
+ assert results["submit"]["status"] == "submitted"
+ assert results["submit"]["patch_chars"] > 0
+ patch = subprocess.run(
+ ["git", "-C", str(root), "diff", "HEAD"], check=True, text=True, capture_output=True
+ ).stdout
+ assert "new file" in patch and "+fixed" in patch
+ assert export_tools(events) == definition()["tools"]
+ assert messages[0]["content"] == definition()["system_prompt"]
+ assert next(e for e in events if e["type"] == "opencollab.termination")["kind"] == "explicit_submission"
+ assert available_branch_points(journal)
+
+
+def test_review50_submission_rejects_later_calls_but_keeps_pairs(tmp_path):
+ root = repository(tmp_path / "repo")
+ model = Model(
+ [
+ response(
+ call("s", "submit_patch"),
+ call("later", "write_file", filepath="forbidden.txt", content="not executed"),
+ )
+ ]
+ )
+ result, _, journal = run(root, tmp_path / "run", model, protocol="review50-v1")
+ assert result.status == "completed", result.error
+ assert not (root / "forbidden.txt").exists()
+ messages, _ = exported(journal)
+ assert {m["tool_call_id"] for m in messages if m["role"] == "tool"} == {"s", "later"}
+
+
+def test_failed_submission_is_not_a_successful_finish(tmp_path):
+ root = tmp_path / "not-a-repo"
+ root.mkdir()
+ model = Model([response(call("s", "submit_patch")), response(content="No patch available.")])
+ result, _, journal = run(root, tmp_path / "run", model, protocol="review50-v1")
+ assert result.status == "completed", result.error
+ messages, events = exported(journal)
+ assert json.loads(next(m["content"] for m in messages if m["role"] == "tool"))["status"] == "error"
+ assert next(e for e in events if e["type"] == "opencollab.termination")["kind"] == "final_response"
+ assert len(model.requests) == 2
+
+
+def test_review50_branch_inherits_protocol_and_can_submit(tmp_path):
+ root = repository(tmp_path / "repo")
+ result, _, journal = run(
+ root,
+ tmp_path / "parent",
+ Model([response(call("r", "read_file", filepath="source.txt")), response(content="Done.")]),
+ protocol="review50-v1",
+ )
+ assert result.status == "completed", result.error
+ step, point = min(available_branch_points(journal).items())
+ prefix = reference_at(journal, step, point.session_ckpt)
+ assert load_prefix(prefix)[0]["tool_protocol"] == "review50-v1"
+ reviewer = {
+ "role": "assistant",
+ "content": "I will make a focused correction.",
+ "tool_calls": [
+ call("review", "edit_file", filepath="source.txt", old_string="original", new_string="branch")
+ ],
+ }
+ model = Model([response(call("s", "submit_patch"))])
+ child, _, child_journal = run(
+ root, tmp_path / "child", model, prefix=prefix, reviewer=reviewer, protocol="review50-v1"
+ )
+ assert child.status == "completed", child.error
+ assert (root / "source.txt").read_text() == "branch\n"
+ exported(child_journal)
+ with pytest.raises(ValueError, match="protocol differs"):
+ run(root, tmp_path / "wrong", Model([]), prefix=prefix)
+
+
+def test_file_errors_and_repeated_edits_do_not_mutate_ambiguously(tmp_path):
+ root = repository(tmp_path / "repo")
+ (root / "source.txt").write_text("same same\n")
+ result, _, journal = run(
+ root,
+ tmp_path / "run",
+ Model(
+ [
+ response(
+ call(
+ "ambiguous",
+ "edit_file",
+ filepath="source.txt",
+ old_string="same",
+ new_string="different",
+ )
+ ),
+ response(call("outside", "read_file", filepath="../outside.txt")),
+ response(content="Stopped."),
+ ]
+ ),
+ protocol="review50-v1",
+ )
+ assert result.status == "completed", result.error
+ messages, _ = exported(journal)
+ assert all(json.loads(m["content"])["status"] == "error" for m in messages if m["role"] == "tool")
+ assert (root / "source.txt").read_text() == "same same\n"
+
+
+def test_compatibility_tools_have_independent_state():
+ first, second = build_tools(), build_tools()
+ assert [t.to_openai_schema() for t in first] == definition()["tools"]
+ assert all(a is not b for a, b in zip(first, second, strict=True))
+
+
+def test_status_uses_enforced_rollout_counts_and_does_not_invent_a_tool_limit(tmp_path):
+ import time
+
+ from sprout.worker.run.control import RunControl
+ from sprout.worker.run.rollout import RolloutControls
+
+ with JournalWriter(tmp_path / "controls.jsonl") as journal:
+ controls = RolloutControls(
+ {"model_endpoint": "http://127.0.0.1:1234", "deadline_at": time.time() + 30, "max_turns": 8},
+ journal,
+ RunControl(),
+ )
+ controls.prepare_model_request({})
+ result = controls.status()
+ assert result["model_calls"] == 1 and result["remaining_turns"] == 7
+ assert result["runtime_tool_calls"] == 0 and result["max_tool_calls"] is None