Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3ece609
feat(repo): --delegation and an execution id for repo run cells
napetrov Oct 19, 2026
b39140e
fix: include harness binary and version in execution ID
napetrov Oct 19, 2026
60cd28a
docs, tests: clarify execution_id inputs include harness binary and v…
napetrov Oct 19, 2026
31a3c00
fix(repo): address execution_id definition gaps: centralize DELEGATIO…
napetrov Oct 19, 2026
afdbfa0
fix(repo): key the execution id on the harness executable's digest
napetrov Oct 19, 2026
d882fe9
fix(repo): deny Workflow under --delegation none for claude-code
xaleryb Oct 20, 2026
2d7856c
fix(repo): key the execution id on the profile's bridge and egress
napetrov Oct 20, 2026
0f7e593
docs(repo): list every field the execution id hashes
napetrov Oct 20, 2026
1581d34
fix(repo): refuse an unsupported --delegation before pinning the batch
napetrov Oct 20, 2026
654d30f
feat(repo): delegation.none is an allow-list; refuse unrecorded or mi…
napetrov Oct 21, 2026
5a3fdf1
fix(repo): --force refuses a batch holding cells of another execution…
napetrov Oct 21, 2026
c68a1a6
fix(repo): --force refuses kept cells of another execution on a host …
xaleryb Oct 22, 2026
7bb3b1c
Treat a malformed result.json execution as unrecorded
antonwolfy Oct 22, 2026
26dffbc
Say a mismatched rerun is refused before any agent starts
antonwolfy Oct 22, 2026
7c7327d
fix(repo): a refused --force run leaves the plan alone; test the kept…
napetrov Oct 22, 2026
d2bbc16
refactor(repos): extract the cell log line and the probe-failed scan …
napetrov Oct 22, 2026
f62b1f6
refactor(repos): extract the unsupported-marker cleanup from run_batch
napetrov Oct 22, 2026
ce28c4c
chore(growth): drop run_batch from the baseline
napetrov Oct 22, 2026
888dfa2
docs(repo): delegation.none is an allow-list in the contract and the …
napetrov Oct 23, 2026
d12aeaa
test(repo): pin that run_cell does not reuse a result without an exec…
antonwolfy Oct 23, 2026
651938c
docs(repo): name the profile fields the execution id hashes
antonwolfy Oct 23, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
### Added

- **`repo run --delegation none` for a single-agent baseline.** Under
`--tools all` Claude Code could start subagents through its `Agent` tool, so
a `repo run` cell was not a single-agent run. `--delegation none` runs the
harness profile's new `delegation.none` tool arguments instead (for
`claude-code`: `Agent`, `Task` and `Workflow` added to
`--disallowedTools`); a profile without them refuses the flag, and so does a tool mode that
`delegation.none` does not list (an allow-list, no fallback to the native
arguments; `claude-code` lists `none`, `read` and `all`). A cell records the
delegation whose arguments ran: `native` for a tool mode whose arguments are
the same either way. The default,
`--delegation native`, keeps today's behaviour.

### Changed

- **A `repo run` cell is reused only by the same execution.** `meta.json` and
`result.json` gain `execution` (`id`, `tools`, `delegation`); the `id`
hashes the profile's name, command template, tool arguments, environment
(`env_set`, `env_allow`), config (`config_dir_env`, `config_files`), prompt
suffix, `egress`, `bridge`, the SHA-256 of the harness executable and harness
version (the executable's content, not its path). Rerunning a batch with
another `--tools`, `--delegation`, an edited profile field listed above, harness binary, or harness
upgrade no longer counts cells from
the old one as done: the run is refused before any agent starts, and `--force` or a
new `--batch` runs them. A batch dir that already holds cells of another
execution is refused before any agent starts (unless `--force`), and so is a
result from earlier versions, which carries no `execution`: it is not
reused.
- `--force` is refused when the batch dir holds finished cells outside the run's plan, or of a task the host cannot run (which `--force` keeps), whose execution id differs, so one report row never pools two executions. `--delegation none` does not stop a skill with `context: fork` from starting a subagent.
40 changes: 37 additions & 3 deletions dokimi/docs/repo-evaluation-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,11 @@ tools: # what the "{tools}" element expands to
none: ["--tools", ""]
read: ["--tools", "Read"]
all: ["--disallowedTools", "WebFetch", "WebSearch"]
delegation: # --delegation none: these replace the tool mode's arguments
none:
none: ["--tools", ""] # list every tool mode a run may use, even if unchanged
read: ["--tools", "Read"]
all: ["--disallowedTools", "WebFetch", "WebSearch", "Agent", "Task", "Workflow"]
config_dir_env: CLAUDE_CONFIG_DIR # a fresh directory per run
env_allow: [PATH, HOME, AWS_REGION, AWS_BEARER_TOKEN_BEDROCK, ANTHROPIC_API_KEY,
CLAUDE_CODE_USE_BEDROCK, https_proxy, no_proxy, ...]
Expand Down Expand Up @@ -649,6 +654,18 @@ bridge: {for: AGENTS.md, write: CLAUDE.md, content: "@AGENTS.md"}
directory, with `{config_dir}` and `{support_dir}` (the framework's helper
scripts) as the agent's container sees them. The built-in `claude-code`
profile uses them for a hook that refuses backgrounded shell commands.
- Optional `delegation.none` maps a tool mode to the arguments `repo run
--delegation none` uses instead, so the agent cannot start a subagent. It
is an allow-list: list every tool mode the run may use (for `none` and
`read` the arguments are the native ones); a tool mode it leaves out is
refused. A profile without `delegation.none` refuses `--delegation none`
before anything runs. The default, `--delegation native`, runs the tool mode
as written: under `all`, Claude Code may start subagents.
`--delegation none` limits the tools the agent is given, not the skills it
loads: a skill whose frontmatter says `context: fork` can still start a
subagent under `none`. Do not read a `none` cell as subagent-free when the
run's skills include one (tracked in intel/dokimi, "repo run: block
fork-context skills under --delegation none").

Profiles for other harnesses (Codex, Gemini CLI, Copilot CLI, Cursor agent,
Aider, OpenCode, in-house agents) follow the same shape. Their `guidance`
Expand Down Expand Up @@ -975,9 +992,26 @@ python cli.py repo compare repo-runs/pilot --baseline guidance:none --metric roo
- Each cell holds the §4 inputs plus `agent.stdout` (the raw trace) and
`result.json`, the framework's verdict. A cell with `result.json` is skipped
on a rerun unless `--force`; a cell without one is reported as missing, not
failed, and the next run of the batch fills it. A batch stays on the eval
tree it started with: its revision, or for a working tree the digests of
its `eval_dir` and of the image build context. A batch with a task that
failed, and the next run of the batch fills it. `meta.json` and
`result.json` record `execution`: the tool mode, the delegation mode (the one
whose arguments ran; a skill with `context: fork` can still start a subagent
under `none`) and an
`id` hashing the profile's name, `invoke`, tool arguments, `env_set`,
`env_allow`, `config_dir_env`, `config_files`, `prompt_suffix`, `egress`,
`bridge`, the SHA-256 of the harness executable (`--bin`) and harness version.
A rerun whose `id` differs (another `--tools`, a `--delegation` that changes the tool arguments, profile,
harness binary or harness version, or an edited `invoke`, `env_set`, `env_allow`, `config_dir_env`, `config_files`, `prompt_suffix`, `egress` or `bridge`) is refused before any agent starts, and
`--force` or another `--batch` runs it. `--force` replaces the cells of the
run's plan only: if the batch dir holds finished cells outside that plan
whose `id` differs from this run's (e.g. `--reps 2`, then `--reps 1
--delegation none --force`), the run is refused before any agent starts, because
the report would pool both executions in one row; use another `--batch`. The
same goes for the finished cells of a task this host cannot run, which
`--force` keeps; that refusal comes after the host probe, before any agent starts. A result from before `execution` was
recorded is refused as `unrecorded`, not counted as native; `--force` or
another `--batch` runs it. A batch stays on the eval tree it
started with: its revision, or for a working tree the digests of its `eval_dir`
and of the image build context. A batch with a task that
names a `dokimi:` grader (or a task directory that cannot be read) also stays on the built-in graders' sources.
- The agent's container mounts the workspace, the per-run `home`, `config` and
scratch directories (each `image.env` value under `{run_dir}/`), the caches,
Expand Down
10 changes: 8 additions & 2 deletions dokimi/dokimi/commands/repo_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from dokimi.repos.manifest import RepoManifest
from dokimi.repos.workspace import Arm

from dokimi.repos.harness_profile import DELEGATION_MODES


def _context(args: argparse.Namespace, out: Path, pin: bool = False):
"""Source, manifest, exported eval dir, image and caches shared by `tasks check` and `run`.
Expand Down Expand Up @@ -198,6 +200,7 @@ def _run_locked(args: argparse.Namespace, arms: list[str], batch_dir: Path, lock
try:
parsed = [parse_arm(a) for a in arms] # before the image build: a typo or an unreadable patch fails now
profile = load_profile(args.harness)
profile.tool_args(args.tools, args.delegation) # a profile that cannot turn subagents off: refuse now
harness_bin = _harness_bin(args.bin or profile.bin)
source = RepoSource.parse(args.source) # a misspelled revision fails before the build and the pin too
arms = _pin_arms(parsed, source_git_dir(source, open_source(source, _cache_dir(args)), _cache_dir(args)),
Expand All @@ -207,8 +210,8 @@ def _run_locked(args: argparse.Namespace, arms: list[str], batch_dir: Path, lock
tasks = load_tasks(ctx.content_dir, ctx.manifest, args.task)
validate_tasks(tasks) # refuse malformed host requirements before containers start
options = [AgentOptions(profile=profile, model=m, harness_bin=harness_bin, agent_network=args.agent_network,
tools=args.tools, timeout_s=args.timeout, max_turns=args.max_turns,
budget_usd=args.budget) for m in args.model]
tools=args.tools, delegation=args.delegation, timeout_s=args.timeout,
max_turns=args.max_turns, budget_usd=args.budget) for m in args.model]
for task in tasks: # refuse before anything runs, not cell by cell
task_caps(task, options[0])
agent_network(options[0], ctx, task)
Expand Down Expand Up @@ -374,6 +377,9 @@ def register(repo_sub, positive_int, source_help: str, cache_help: str) -> None:
help="REQUIRED while the egress proxy is not implemented: the agent's container gets "
"this network, so its commands can reach any host. Recorded in meta.json")
run_p.add_argument("--tools", default="all", help="Profile tool mode (default: all)")
run_p.add_argument("--delegation", choices=DELEGATION_MODES, default="native",
help="native: the agent may start subagents (default); none: the profile's tool "
"arguments without the subagent tools, for a single-agent baseline")
run_p.add_argument("--timeout", type=positive_int, default=2700,
help="Seconds per agent run unless task.json sets timeout_s (default 2700)")
run_p.add_argument("--max-turns", type=positive_int, default=200, dest="max_turns",
Expand Down
59 changes: 54 additions & 5 deletions dokimi/dokimi/repos/harness_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
A profile is data, not code. ``invoke`` is an argv template, and every element
becomes exactly one argument after placeholder substitution, so a prompt with
quotes or spaces never passes through a shell. The element ``"{tools}"`` is
replaced by the argument list of the selected tool mode. The child process
replaced by the argument list of the selected tool mode, or by its
``delegation`` override when subagents are turned off. The child process
gets a scrubbed environment: only ``env_allow`` names are inherited, then
``env_set`` is applied, then ``config_dir_env`` points at a per-run directory
so the user's own settings and memory cannot leak into a run.
"""

from __future__ import annotations

import hashlib
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
Expand All @@ -28,6 +31,7 @@
PLACEHOLDERS = frozenset({"bin", "prompt", "model", "max_turns", "budget"})
CONFIG_PLACEHOLDERS = frozenset({"config_dir", "support_dir"})
TOOLS_ELEMENT = "{tools}"
DELEGATION_MODES = ("native", "none") # native: the agent may start subagents; none: it cannot
_PLACEHOLDER_RE = re.compile(r"\{([a-z_]+)\}")
_VERSION_RE = re.compile(r"\d+\.\d+\.\d+")

Expand All @@ -52,6 +56,7 @@ class HarnessProfile:
bridge: dict[str, str] | None = None
prompt_suffix: str = ""
config_files: dict[str, str] = field(default_factory=dict)
delegation: dict[str, dict[str, tuple[str, ...]]] = field(default_factory=dict)
path: str = "<profile>"

@property
Expand All @@ -74,11 +79,10 @@ def argv(
max_turns: int = 4,
budget_usd: float = 0.25,
bin_path: str | None = None,
delegation: str = "native",
) -> list[str]:
"""The command line for one run, without a shell."""
if tools not in self.tools:
raise ProfileError(f"{self.path}: tool mode {tools!r} is not defined "
f"(have: {', '.join(sorted(self.tools))})")
tool_args = self.tool_args(tools, delegation)
values = {
"bin": bin_path or self.bin,
"prompt": prompt,
Expand All @@ -89,11 +93,54 @@ def argv(
out: list[str] = []
for element in self.invoke:
if element == TOOLS_ELEMENT:
out.extend(self.tools[tools])
out.extend(tool_args)
else:
out.append(_substitute(element, values))
return out

def tool_args(self, tools: str, delegation: str = "native") -> tuple[str, ...]:
"""What ``{tools}`` becomes: the tool mode's arguments, or its ``delegation`` override."""
if tools not in self.tools:
raise ProfileError(f"{self.path}: tool mode {tools!r} is not defined "
f"(have: {', '.join(sorted(self.tools))})")
if delegation == "native":
return self.tools[tools]
if delegation not in DELEGATION_MODES:
raise ProfileError(f"unknown delegation mode {delegation!r} (known: {', '.join(DELEGATION_MODES)})")
if delegation not in self.delegation:
raise ProfileError(f"{self.path}: declares no `delegation: {delegation}`, so a run cannot be "
"kept from starting subagents")
listed = self.delegation[delegation]
if tools not in listed: # an allow-list: no silent fallback to the native arguments
raise ProfileError(f"{self.path}: `delegation: {delegation}` lists no arguments for tool mode "
f"{tools!r} (lists: {', '.join(sorted(listed)) or 'none'}); list every mode "
"it may run with")
return listed[tools]

def effective_delegation(self, tools: str, delegation: str = "native") -> str:
"""The delegation whose arguments the command line gets: ``native`` when the profile's arguments for
``delegation`` are the native ones (the tool mode never starts subagents anyway)."""
if delegation != "native" and self.tool_args(tools, delegation) == self.tools[tools]:
return "native"
return delegation

def execution_id(self, tools: str, delegation: str = "native", harness_sha256: str | None = None,
harness_version: str | None = None) -> str:
"""Short hash of how the agent is started: the profile's ``invoke`` template with its tool arguments,
environment, config, prompt suffix, egress and bridge.

Two runs with the same id ran the same command line up to the per-cell values (prompt, model,
caps); ``repo run`` reuses a finished cell only when its id matches. Includes the harness
executable's digest and version to detect changes to the effective harness, wherever it lives.
"""
key = {"profile": self.name, "invoke": list(self.invoke), "tools": list(self.tool_args(tools, delegation)),
"env_set": dict(sorted(self.env_set.items())), "env_allow": list(self.env_allow),
"config_dir_env": self.config_dir_env, "config_files": dict(sorted(self.config_files.items())),
"egress": list(self.egress), "bridge": self.bridge,
"prompt_suffix": self.prompt_suffix, "harness_sha256": harness_sha256,
"harness_version": harness_version}
return hashlib.sha256(json.dumps(key, sort_keys=True).encode("utf-8")).hexdigest()[:12]

def prompt(self, task_prompt: str) -> str:
"""The task prompt as the agent gets it: with ``prompt_suffix`` after a blank line."""
return f"{task_prompt}\n\n{self.prompt_suffix}" if self.prompt_suffix else task_prompt
Expand Down Expand Up @@ -190,6 +237,8 @@ def parse_profile(raw: Any, path: str = "<profile>") -> HarnessProfile:
bridge=raw.get("bridge"),
prompt_suffix=raw.get("prompt_suffix") or "",
config_files=dict(raw.get("config_files") or {}),
delegation={mode: {t: tuple(args) for t, args in by_tools.items()}
for mode, by_tools in (raw.get("delegation") or {}).items()},
path=path,
)

Expand Down
6 changes: 6 additions & 0 deletions dokimi/dokimi/repos/profiles/claude-code.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ tools:
none: ["--tools", ""]
read: ["--tools", "Read"]
all: ["--disallowedTools", "WebFetch", "WebSearch"]
# --delegation none: the same tools without the ones that start subagents (Agent/Task, Workflow).
delegation:
none:
none: ["--tools", ""]
read: ["--tools", "Read"]
all: ["--disallowedTools", "WebFetch", "WebSearch", "Agent", "Task", "Workflow"]
config_dir_env: CLAUDE_CONFIG_DIR
env_allow:
- PATH
Expand Down
Loading
Loading