Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
35 changes: 19 additions & 16 deletions docs/capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ agentcap run \

Four required pieces:

| flag | what it picks |
|-------------|--------------------------------------------------------------|
| `--agent` | which CLI to drive (`hermes` \| `opencode` \| `goose` \| `pi`) |
| `--model` | the model id the agent will ask for (required for all agents) |
| `--upstream`| where the proxy forwards calls — any OpenAI-compat endpoint |
| `--tasks` | path to `tasks.txt`, one initial user prompt per line |

| flag | what it picks |
| -------------- | ----------------------------------------------------------------------------------------------------- |
| `--agent` | which CLI to drive (`hermes` \| `opencode` \| `goose` \| `pi`) |
| `--model` | the model id the agent will ask for (required for all agents) |
| `--upstream` | where the proxy forwards calls — any OpenAI-compat endpoint |
| `--tasks` | path to a tasks file:`.txt` has one prompt per line; `.yaml`/`.yml` accepts a list or `tasks:` list |

The proxy listens on a free local port and rewrites the agent's
endpoint to point at itself, so the agent talks to the proxy and the
Expand All @@ -45,11 +46,12 @@ agentcap run --agent goose --model zai-org/GLM-4.6 \
--turns 4 --followup synthesized
```

| `--followup` | next-turn prompt |
|--------------|-----------------------------------------------------------|
| `continue` | literal string `"continue"` |
| `templates` | one of a fixed pool (varies per turn) |
| `synthesized`| a small LLM is asked to produce a natural next user message |

| `--followup` | next-turn prompt |
| --------------- | ------------------------------------------------------------- |
| `continue` | literal string`"continue"` |
| `templates` | one of a fixed pool (varies per turn) |
| `synthesized` | a small LLM is asked to produce a natural next user message |

`synthesized` calls a separate model — by default the same upstream,
override with `--synth-upstream` / `--synth-model`. The follow-up call
Expand Down Expand Up @@ -100,11 +102,12 @@ multi-minute cold build the first time each agent is invoked.

The proxy is backend-agnostic; `--upstream` is the only switch.

| backend | when to use |
|-------------------------------|------------------------------------------------------|
| Inference Providers (`router.huggingface.co`) | demos, casual capture; pay-per-token |
| Local `llama.app` server | full control over quant / chat template / sampler |
| `transformers serve` | small models; awkward for big ones at long context |

| backend | when to use |
| ----------------------------------------------- | ---------------------------------------------------- |
| Inference Providers (`router.huggingface.co`) | demos, casual capture; pay-per-token |
| Local`llama.app` server | full control over quant / chat template / sampler |
| `transformers serve` | small models; awkward for big ones at long context |

For known-good `(backend, model, agent)` tuples see
[docs/tested-models-and-agents.md](tested-models-and-agents.md).
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,21 @@ dependencies = [
"starlette>=1.0.1",
"uvicorn>=0.30",
"huggingface_hub>=1.13", # HfApi.upload_file with repo_type="dataset"
"pyyaml>=6", # used by HermesDriver to overlay context_length / base_url into config.yaml
"click>=8.1",
"pyarrow>=15", # streaming ParquetWriter in export_local
"tqdm>=4.60", # per-row progress in export_local
]

[project.optional-dependencies]
yaml = [
"pyyaml>=6",
]

dev = [
"pytest>=8",
"pytest-asyncio>=0.24",
"ruff>=0.6",
"pyyaml>=6"
]

[project.urls]
Expand Down
5 changes: 4 additions & 1 deletion src/agentcap/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,10 @@ def _resolve(t: str) -> tuple[Path, str | None, str]:
"tasks_file",
required=True,
type=click.Path(exists=True, dir_okay=False),
help="Plain-text file with one prompt per line (# comments + blank lines ignored).",
help=(
"Tasks file. .txt uses one prompt per line (# comments + blank lines "
"ignored); .yaml/.yml uses a list of prompts or a top-level tasks list."
),
)
@click.option(
"--turns",
Expand Down
11 changes: 9 additions & 2 deletions src/agentcap/drivers/hermes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@
from pathlib import Path
from typing import Sequence

import yaml

from . import AgentDriver, AgentTurn
from ..sandbox import Sandbox

Expand Down Expand Up @@ -71,6 +69,15 @@ def _rewrite_config(
``model.base_url`` and (optionally) ``context_length``. Kept for
unit tests; the production path bakes the equivalent into the
image, so the driver never calls this at runtime."""

try:
import yaml
except ImportError as exc:
raise RuntimeError(
"Hermes config rewriting requires PyYAML. Install it with "
"`pip install 'agentcap[yaml]'` or `pip install pyyaml`."
) from exc

cfg = yaml.safe_load(config_text) or {}
if not isinstance(cfg, dict):
raise ValueError("hermes config.yaml is not a YAML mapping")
Expand Down
69 changes: 65 additions & 4 deletions src/agentcap/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterable, Sequence
from typing import Any, Callable, Iterable, Sequence

from .drivers import AgentDriver, AgentTurn
from .followups import FollowUp
Expand Down Expand Up @@ -52,9 +52,21 @@ def completed_turns(self) -> int:


def read_tasks_txt(path: Path | str) -> list[str]:
"""Read a plain-text tasks file (one prompt per line, ``#`` comments
and blank lines ignored)."""
text = Path(path).read_text()
"""Read a tasks file.

``.txt`` files keep the legacy one-prompt-per-line format, with blank
lines and ``#`` comments ignored. ``.yaml``/``.yml`` files accept either
a top-level list of prompts or a mapping with a ``tasks`` list. Each YAML
task may be a string, or a mapping with a ``prompt`` key.
"""
path = Path(path)
if path.suffix.lower() in {".yaml", ".yml"}:
return _read_tasks_yaml(path)
return _read_tasks_text(path)


def _read_tasks_text(path: Path) -> list[str]:
text = path.read_text()
out: list[str] = []
for line in text.splitlines():
s = line.strip()
Expand All @@ -64,6 +76,55 @@ def read_tasks_txt(path: Path | str) -> list[str]:
return out


def _read_tasks_yaml(path: Path) -> list[str]:
try:
import yaml
except ImportError as exc:
raise RuntimeError(
"YAML task files require PyYAML. Install it with "
"`pip install 'agentcap[yaml]'` or `pip install pyyaml`, "
"or use a .txt tasks file."
) from exc

data = yaml.safe_load(path.read_text())
if data is None:
return []

if isinstance(data, dict):
if "tasks" not in data:
raise ValueError("YAML tasks file must contain a top-level 'tasks' key")
data = data["tasks"]

if not isinstance(data, list):
raise ValueError(
"YAML tasks file must be a list, or a mapping with a 'tasks' list"
)

out: list[str] = []
for i, item in enumerate(data, start=1):
prompt: Any

if isinstance(item, str):
prompt = item
elif isinstance(item, dict):
if "prompt" not in item:
raise ValueError(f"YAML task #{i} mapping must contain a 'prompt' key")
prompt = item["prompt"]
else:
raise ValueError(
f"YAML task #{i} must be a string or a mapping with a 'prompt' key"
)

if not isinstance(prompt, str):
raise ValueError(f"YAML task #{i} prompt must be a string")

prompt = prompt.strip()
if prompt:
out.append(prompt)

return out


class Orchestrator:
"""Run a corpus through an agent driver with a follow-up strategy."""

Expand Down
36 changes: 36 additions & 0 deletions tests/test_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import sys
from pathlib import Path

import pytest
Expand Down Expand Up @@ -290,3 +291,38 @@ def test_run_task_aborts_on_followup_turn_timeout():
assert len(result.turns) == 1
aborted = [e for e in events if e[0] == "task_aborted"]
assert aborted and aborted[0][1]["reason"] == "follow-up-turn-timeout"

def test_read_tasks_yaml_list_of_strings(tmp_path: Path):
pytest.importorskip("yaml")
p = tmp_path / "tasks.yaml"
p.write_text("- first task\n- second task\n")
assert read_tasks_txt(p) == ["first task", "second task"]


def test_read_tasks_yaml_mapping_with_task_objects(tmp_path: Path):
pytest.importorskip("yaml")
p = tmp_path / "tasks.yml"
p.write_text(
"tasks:\n"
" - prompt: |\n"
" first line\n"
" second line\n"
" - second task\n"
)
assert read_tasks_txt(p) == ["first line\nsecond line", "second task"]


def test_read_tasks_yaml_validates_shape(tmp_path: Path):
pytest.importorskip("yaml")
p = tmp_path / "tasks.yaml"
p.write_text("name: not-a-task-list\n")
with pytest.raises(ValueError, match="top-level 'tasks' key"):
read_tasks_txt(p)


def test_read_tasks_yaml_requires_pyyaml(monkeypatch, tmp_path: Path):
p = tmp_path / "tasks.yaml"
p.write_text("- first task\n")
monkeypatch.setitem(sys.modules, "yaml", None)
with pytest.raises(RuntimeError, match="YAML task files require PyYAML"):
read_tasks_txt(p)