Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ markers = [
"kimi_code: v1 e2e cases on the kimi-code harness",
"pi: v1 e2e cases on the pi harness",
"pool: v1 e2e cases on the pool harness",
"cline: v1 e2e cases on the Cline harness",
"codex: v1 e2e cases on the codex harness",
"unit: marks tests as unit tests",
"asyncio: marks tests as async tests",
Expand Down
6 changes: 3 additions & 3 deletions tests/v1/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
uv run pytest tests/v1 -n auto -m modal # only modal (needs local setup)

Marks: runtimes `subprocess` / `docker` / `prime` / `modal`, placement `colocated`,
harnesses `null` / `bash` / `rlm` / `kimi_code` / `pi` / `pool` / `openclaw` / `codex` /
`claude_code` / `hermes_agent`.
harnesses `null` / `bash` / `rlm` / `kimi_code` / `pi` / `pool` / `openclaw` / `cline` /
`codex` / `claude_code` / `hermes_agent`.
A mark is applied per axis, so it selects every case touching that value on ANY axis; for one exact
combination use `-k` on the test id (e.g. `-k "harness-in-docker-with-tool-in-subprocess"`).
prime/modal provision real remote sandboxes (slow, infra-flaky, need setup), so they're local-only.
Expand Down Expand Up @@ -71,7 +71,7 @@ def tool_runtime(request) -> dict:


# Built-in harnesses are bundled in the `harnesses` package; the agent CLIs (`rlm` /
# `kimi-code` / `openclaw` / `codex` / `claude-code` / `hermes-agent`) install their
# `kimi-code` / `openclaw` / `cline` / `codex` / `claude-code` / `hermes-agent`) install their
# dependencies at rollout.
# `compact` (an example harness) and `terminus-2` (drives the host tmux) are excluded from e2e.
@pytest.fixture
Expand Down
110 changes: 110 additions & 0 deletions tests/v1/test_cline_harness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from typing import cast

import pytest
from pydantic import ValidationError

from verifiers.v1.clients import ModelContext
from verifiers.v1.configs.client import EvalClientConfig
from verifiers.v1.harnesses.cline import ClineHarness, ClineHarnessConfig
from verifiers.v1.runtimes import ProgramResult, Runtime
from verifiers.v1.task import TaskData
from verifiers.v1.trace import Trace
from verifiers.v1.types import Sampling


class RecordingRuntime:
Comment thread
arafatkatze marked this conversation as resolved.
Outdated
def __init__(self) -> None:
self.commands: list[tuple[list[str], dict[str, str]]] = []
self.programs: list[tuple[list[str], dict[str, str]]] = []
self.files: dict[str, bytes] = {}

async def write(self, path: str, data: bytes) -> None:
self.files[path] = data

async def run(self, argv: list[str], env: dict[str, str]) -> ProgramResult:
self.commands.append((argv, env))
return ProgramResult(exit_code=0, stdout="", stderr="")

async def run_program(self, argv: list[str], env: dict[str, str]) -> ProgramResult:
self.programs.append((argv, env))
return ProgramResult(exit_code=0, stdout="", stderr="")


def test_cline_config_validates_pinned_version_and_retries() -> None:
config = ClineHarnessConfig(id="cline")
assert config.version == "3.0.57"
assert config.compaction == "basic"
assert config.max_retries == 6

with pytest.raises(ValidationError):
ClineHarnessConfig(id="cline", version="3.0.57; unsafe")
with pytest.raises(ValidationError):
ClineHarnessConfig(id="cline", max_retries=0)


@pytest.mark.asyncio
async def test_cline_launch_wires_interception_and_restricts_tools() -> None:
runtime = RecordingRuntime()
harness = ClineHarness(
ClineHarnessConfig(id="cline", disabled_tools=["custom_external_tool"])
)
ctx = ModelContext(
model="openai/example",
client=EvalClientConfig(),
sampling=Sampling(reasoning_effort="medium"),
)
trace = Trace.model_construct(id="trace-id")

result = await harness.launch(
ctx,
trace,
cast(Runtime, runtime),
"http://interception.test/v1",
"ephemeral-secret",
{},
TaskData(prompt="solve the task", system_prompt="follow the rules"),
)

assert result.exit_code == 0
settings = runtime.files[
"/tmp/vf-cline/trace-id/settings/global-settings.json"
].decode()
assert '"fetch_web_content"' in settings
assert '"spawn_agent"' in settings
assert '"custom_external_tool"' in settings
assert (
runtime.files["/tmp/vf-cline/trace-id/settings/cline_mcp_settings.json"]
== b'{"mcpServers":{}}'
)

auth, _ = runtime.commands[-1]
assert auth[auth.index("--baseurl") + 1] == "http://interception.test/v1"
assert auth[auth.index("--modelid") + 1] == "openai/example"

argv, env = runtime.programs[-1]
assert argv[argv.index("--key") + 1] == "ephemeral-secret"
assert argv[argv.index("--model") + 1] == "openai/example"
assert argv[argv.index("--thinking") + 1] == "medium"
assert argv[-1] == "follow the rules\n\nsolve the task"
assert env["CLINE_TELEMETRY_DISABLED"] == "1"
assert env["CLINE_MCP_SETTINGS_PATH"].endswith("cline_mcp_settings.json")


@pytest.mark.asyncio
async def test_cline_rejects_mcp_before_starting_program() -> None:
runtime = RecordingRuntime()
harness = ClineHarness(ClineHarnessConfig(id="cline"))
ctx = ModelContext(model="openai/example", client=EvalClientConfig())

with pytest.raises(ValueError, match="does not support MCP"):
await harness.launch(
ctx,
Trace.model_construct(id="trace-id"),
cast(Runtime, runtime),
"http://interception.test/v1",
"ephemeral-secret",
{"server": "http://mcp.test"},
TaskData(prompt="solve the task"),
)

assert runtime.programs == []
1 change: 1 addition & 0 deletions tests/v1/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def pair(a: str, b: str, id: str, *extra_marks):
marks=[mark.kimi_code, mark.docker],
id="kimi-code-responses-harness-in-docker",
),
pair("cline", "docker", "cline-harness-in-docker"),
pair("codex", "docker", "codex-harness-in-docker"),
pair("claude-code", "docker", "claude-code-harness-in-docker"),
pair("hermes-agent", "docker", "hermes-agent-harness-in-docker"),
Expand Down
3 changes: 3 additions & 0 deletions verifiers/v1/harnesses/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
ClaudeCodeHarness,
ClaudeCodeHarnessConfig,
)
from verifiers.v1.harnesses.cline import ClineHarness, ClineHarnessConfig
from verifiers.v1.harnesses.codex import CodexHarness, CodexHarnessConfig
from verifiers.v1.harnesses.hermes_agent import (
HermesAgentHarness,
Expand All @@ -31,6 +32,8 @@
"BrowserUseHarnessConfig",
"ClaudeCodeHarness",
"ClaudeCodeHarnessConfig",
"ClineHarness",
"ClineHarnessConfig",
"CodexHarness",
"CodexHarnessConfig",
"HermesAgentHarness",
Expand Down
71 changes: 71 additions & 0 deletions verifiers/v1/harnesses/cline/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Cline harness

The `cline` harness runs the public Cline CLI inside the selected verifiers
runtime and routes every model call through the rollout's interception server.
Cline's native OpenAI-compatible tool calls, results, usage, and final response
therefore become ordinary verifiers trace nodes and model-call records.

## Quickstart

Choose an isolated container or VM runtime. The harness installs its pinned Cline
release during trusted setup.

```bash
export OPENAI_API_KEY=...
uv run eval MY_TASKSET \
--env.agent.harness.id cline \
--env.agent.runtime.type docker \
--model openai/gpt-4.1-mini \
--client.base-url https://openrouter.ai/api/v1 \
--client.api-key-var OPENAI_API_KEY \
--no-push
```

`--no-push` keeps the run local. Inspect the resolved config and traces under
`outputs/<run>/configs/eval.json` and `outputs/<run>/traces.jsonl`.

## Harness configuration

```toml
[env.agent.harness]
id = "cline"
version = "3.0.57"
compaction = "basic" # basic | agentic | off
max_retries = 6
disabled_tools = [] # additional native Cline tool names
```

Use the agent and sampling blocks for settings owned by verifiers:

```toml
[env.agent]
max_turns = 20
max_output_tokens = 32768

[env.agent.timeout]
setup = 600
rollout = 1800

[sampling]
temperature = 0.2
```

The interception server applies the configured model and sampling values to
Cline's requests before forwarding them upstream.

## Security posture and limitations

- Each rollout gets an isolated Cline data directory, deleted after scoring.
- Cline telemetry and update checks are disabled.
- Cline MCP configuration starts empty. The harness does not advertise MCP or
resume support yet.
- Network/human/nested-agent tools are disabled by default. The retained tools
read, search, edit, and run commands in the task runtime.
- The adapter uses Cline's one-shot headless mode. Multi-turn tool use inside one
task is captured, but env-driven user simulation requires future ACP support.
- Cline `3.0.57` has no max-step flag. Use verifiers' `max_turns`, token caps, and
rollout timeout; their stop conditions and errors are recorded on the trace.
- Eval-client traces preserve message/tool structure and provider-reported usage,
but exact token IDs and masks require a compatible training client/renderer.
- Compaction was not forced in the smoke validation. If Cline rewrites history,
inspect the resulting branches before using those samples for training.
3 changes: 3 additions & 0 deletions verifiers/v1/harnesses/cline/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from verifiers.v1.harnesses.cline.harness import ClineHarness, ClineHarnessConfig

__all__ = ["ClineHarness", "ClineHarnessConfig"]
Loading