agent-convo 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """Durable conversations between LangChain agents."""
2
+
3
+ from agent_convo.config import load_config
4
+ from agent_convo.runner import run_new
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["__version__", "load_config", "run_new"]
agent_convo/cli.py ADDED
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from dotenv import load_dotenv
10
+
11
+ from agent_convo.config import dump_example_config, load_config
12
+ from agent_convo.doctor import check_config
13
+ from agent_convo.evolution import EvolutionNotConfiguredError, evolve_tester_agent
14
+ from agent_convo.export import export_run
15
+ from agent_convo.improve import improve_agent
16
+ from agent_convo.runner import resume_existing, run_new
17
+
18
+ app = typer.Typer(no_args_is_help=True)
19
+
20
+
21
+ @app.command()
22
+ def init() -> None:
23
+ example_path = Path("examples/tester_vs_target.yaml")
24
+ if not example_path.exists():
25
+ dump_example_config(example_path)
26
+ env_path = Path(".env.example")
27
+ if not env_path.exists():
28
+ env_path.write_text("OPENAI_API_KEY=\nTARGET_API_KEY=\n")
29
+ skill_dir = Path("skills/tester/probe-vague-claims")
30
+ skill_dir.mkdir(parents=True, exist_ok=True)
31
+ skill_file = skill_dir / "SKILL.md"
32
+ if not skill_file.exists():
33
+ skill_file.write_text("# Probe Vague Claims\n\nAsk one concrete follow-up when a claim is vague.\n")
34
+ typer.echo("Created examples/tester_vs_target.yaml, .env.example, and starter skills.")
35
+
36
+
37
+ @app.command()
38
+ def validate(config_path: Annotated[Path, typer.Argument(exists=True)]) -> None:
39
+ load_config(config_path)
40
+ typer.echo(f"Valid config: {config_path}")
41
+
42
+
43
+ @app.command()
44
+ def doctor(config_path: Annotated[Path, typer.Argument(exists=True)]) -> None:
45
+ load_dotenv()
46
+ config = load_config(config_path)
47
+ result = check_config(config)
48
+ for message in result.messages:
49
+ typer.echo(message)
50
+ if not result.ok:
51
+ raise typer.Exit(1)
52
+
53
+
54
+ @app.command()
55
+ def run(
56
+ config_path: Annotated[Path, typer.Argument(exists=True)],
57
+ count: Annotated[int | None, typer.Option("--count")] = None,
58
+ parallelism: Annotated[int | None, typer.Option("--parallelism")] = None,
59
+ output_dir: Annotated[Path | None, typer.Option("--output-dir")] = None,
60
+ per_turn_timeout_seconds: Annotated[float | None, typer.Option("--per-turn-timeout-seconds")] = None,
61
+ max_retries_per_turn: Annotated[int | None, typer.Option("--max-retries-per-turn")] = None,
62
+ evolve_tester_agent_flag: Annotated[
63
+ bool,
64
+ typer.Option("--evolve-tester-agent", help="Run tester evolution with harnessctl after the run finishes."),
65
+ ] = False,
66
+ ) -> None:
67
+ load_dotenv()
68
+ config = load_config(config_path).with_run_overrides(
69
+ count=count,
70
+ parallelism=parallelism,
71
+ output_dir=str(output_dir) if output_dir is not None else None,
72
+ per_turn_timeout_seconds=per_turn_timeout_seconds,
73
+ max_retries_per_turn=max_retries_per_turn,
74
+ )
75
+ run_dir = asyncio.run(run_new(config))
76
+ typer.echo(str(run_dir))
77
+ if evolve_tester_agent_flag:
78
+ try:
79
+ evolution_dir = evolve_tester_agent(config, run_dir=run_dir)
80
+ except EvolutionNotConfiguredError as exc:
81
+ raise typer.BadParameter(str(exc)) from exc
82
+ typer.echo(f"tester evolution: {evolution_dir}")
83
+
84
+
85
+ @app.command()
86
+ def status(run_dir: Annotated[Path, typer.Argument(exists=True)]) -> None:
87
+ states = {}
88
+ for state_path in sorted((run_dir / "conversations").glob("*/state.json")):
89
+ state = json.loads(state_path.read_text())
90
+ states[state["status"]] = states.get(state["status"], 0) + 1
91
+ if not states:
92
+ typer.echo("No conversations found.")
93
+ return
94
+ for key in sorted(states):
95
+ typer.echo(f"{key}: {states[key]}")
96
+
97
+
98
+ @app.command()
99
+ def resume(
100
+ run_dir: Annotated[Path, typer.Argument(exists=True)],
101
+ config_path: Annotated[Path, typer.Option("--config", "-c", exists=True)] = Path("examples/tester_vs_target.yaml"),
102
+ parallelism: Annotated[int | None, typer.Option("--parallelism")] = None,
103
+ per_turn_timeout_seconds: Annotated[float | None, typer.Option("--per-turn-timeout-seconds")] = None,
104
+ max_retries_per_turn: Annotated[int | None, typer.Option("--max-retries-per-turn")] = None,
105
+ ) -> None:
106
+ load_dotenv()
107
+ config = load_config(config_path).with_run_overrides(
108
+ parallelism=parallelism,
109
+ per_turn_timeout_seconds=per_turn_timeout_seconds,
110
+ max_retries_per_turn=max_retries_per_turn,
111
+ )
112
+ asyncio.run(resume_existing(config, run_dir))
113
+ typer.echo(f"Resumed {run_dir}")
114
+
115
+
116
+ @app.command("export")
117
+ def export_cmd(
118
+ run_dir: Annotated[Path, typer.Argument(exists=True)],
119
+ fmt: Annotated[str, typer.Option("--format")] = "jsonl",
120
+ out: Annotated[Path, typer.Option("--out")] = Path("conversations.jsonl"),
121
+ ) -> None:
122
+ export_run(run_dir, fmt=fmt, out=out)
123
+ typer.echo(str(out))
124
+
125
+
126
+ @app.command()
127
+ def improve(
128
+ run_dir: Annotated[Path, typer.Option("--run", exists=True)],
129
+ agent: Annotated[str, typer.Option("--agent")],
130
+ config_path: Annotated[Path, typer.Option("--config", "-c", exists=True)] = Path("examples/tester_vs_target.yaml"),
131
+ ) -> None:
132
+ config = load_config(config_path)
133
+ output_dir = improve_agent(config, run_dir=run_dir, agent_name=agent)
134
+ typer.echo(str(output_dir))
135
+
136
+ if __name__ == "__main__":
137
+ app()
agent_convo/config.py ADDED
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Literal
5
+
6
+ import yaml
7
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
8
+
9
+
10
+ class MCPServerConfig(BaseModel):
11
+ model_config = ConfigDict(extra="allow")
12
+
13
+ name: str
14
+ transport: str = "stdio"
15
+
16
+
17
+ class AgentConfig(BaseModel):
18
+ model_config = ConfigDict(extra="forbid")
19
+
20
+ model: str
21
+ system_prompt: str = ""
22
+ skills: list[str] = Field(default_factory=list)
23
+ tools: list[str] = Field(default_factory=list)
24
+ mcp_servers: list[MCPServerConfig] = Field(default_factory=list)
25
+ middleware: list[str] = Field(default_factory=list)
26
+
27
+
28
+ class TargetConfig(AgentConfig):
29
+ type: Literal["openai_compatible"] = "openai_compatible"
30
+ base_url: str | None = None
31
+ api_key_env: str | None = None
32
+
33
+
34
+ class ObserverConfig(BaseModel):
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+ model: str = "fake:observer"
38
+ system_prompt: str = ""
39
+ check_after_each_target_turn: bool = True
40
+
41
+
42
+ class GraderConfig(BaseModel):
43
+ model_config = ConfigDict(extra="forbid")
44
+
45
+ model: str = "fake:grader"
46
+ system_prompt: str = ""
47
+
48
+
49
+ class GradeRules(BaseModel):
50
+ model_config = ConfigDict(extra="forbid")
51
+
52
+ pass_: list[str] = Field(default_factory=list, alias="pass")
53
+ fail: list[str] = Field(default_factory=list)
54
+
55
+
56
+ class LogicalCompletionConfig(BaseModel):
57
+ model_config = ConfigDict(extra="forbid")
58
+
59
+ halt_when: list[str] = Field(default_factory=list)
60
+
61
+
62
+ class ScenarioConfig(BaseModel):
63
+ model_config = ConfigDict(extra="forbid")
64
+
65
+ id: str
66
+ goal: str
67
+ opening_message: str
68
+ max_turns: int = Field(gt=0)
69
+ logical_completion: LogicalCompletionConfig = Field(default_factory=LogicalCompletionConfig)
70
+ grades: GradeRules = Field(default_factory=GradeRules)
71
+
72
+
73
+ class PersonaConfig(BaseModel):
74
+ model_config = ConfigDict(extra="forbid")
75
+
76
+ id: str
77
+ name: str
78
+ description: str = ""
79
+ custom_instructions: str = ""
80
+ scenarios: list[ScenarioConfig] = Field(default_factory=list)
81
+
82
+
83
+ class RunConfig(BaseModel):
84
+ model_config = ConfigDict(extra="forbid")
85
+
86
+ count: int = Field(default=1, gt=0)
87
+ parallelism: int = Field(default=1, gt=0)
88
+ output_dir: str = "./runs"
89
+ per_turn_timeout_seconds: float = Field(default=90, gt=0)
90
+ max_retries_per_turn: int = Field(default=2, ge=0)
91
+
92
+
93
+ class ImproveConfig(BaseModel):
94
+ model_config = ConfigDict(extra="forbid")
95
+
96
+ output_dir: str = "./improvements"
97
+
98
+
99
+ class TesterEvolutionConfig(BaseModel):
100
+ model_config = ConfigDict(extra="forbid")
101
+
102
+ agent: str
103
+ output_dir: str = "./tester-evolution"
104
+ name: str = "agent-convo-tester-evolution"
105
+ budget: float | None = Field(default=None, gt=0)
106
+ stream: bool = False
107
+ extra_instructions: str = ""
108
+
109
+
110
+ class AppConfig(BaseModel):
111
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
112
+
113
+ name: str
114
+ tester: AgentConfig
115
+ target: TargetConfig
116
+ personas: list[PersonaConfig]
117
+ observer: ObserverConfig = Field(default_factory=ObserverConfig)
118
+ grader: GraderConfig = Field(default_factory=GraderConfig)
119
+ run: RunConfig = Field(default_factory=RunConfig)
120
+ improve: ImproveConfig = Field(default_factory=ImproveConfig)
121
+ tester_evolution: TesterEvolutionConfig | None = Field(default=None, alias="tester-evolution")
122
+ config_path: Path | None = Field(default=None, exclude=True)
123
+
124
+ @model_validator(mode="after")
125
+ def validate_scenarios(self) -> "AppConfig":
126
+ if not self.personas:
127
+ raise ValueError("at least one persona is required")
128
+ for persona in self.personas:
129
+ if not persona.scenarios:
130
+ raise ValueError(f"persona '{persona.id}' must define at least one scenario")
131
+ return self
132
+
133
+ @property
134
+ def base_dir(self) -> Path:
135
+ if self.config_path is None:
136
+ return Path.cwd()
137
+ return self.config_path.parent
138
+
139
+ def resolve_path(self, path: str | Path) -> Path:
140
+ candidate = Path(path).expanduser()
141
+ if candidate.is_absolute():
142
+ return candidate.resolve()
143
+ return (self.base_dir / candidate).resolve()
144
+
145
+ def with_run_overrides(self, **overrides: Any) -> "AppConfig":
146
+ clean = {key: value for key, value in overrides.items() if value is not None}
147
+ if not clean:
148
+ return self
149
+ return self.model_copy(update={"run": self.run.model_copy(update=clean)})
150
+
151
+ @property
152
+ def scenario_count(self) -> int:
153
+ return sum(len(persona.scenarios) for persona in self.personas)
154
+
155
+
156
+ def load_config(path: str | Path, *, validate_paths: bool = True) -> AppConfig:
157
+ config_path = Path(path).expanduser().resolve()
158
+ data = yaml.safe_load(config_path.read_text()) or {}
159
+ config = AppConfig.model_validate({**data, "config_path": config_path})
160
+ if validate_paths:
161
+ validate_local_paths(config)
162
+ return config
163
+
164
+
165
+ def validate_local_paths(config: AppConfig) -> None:
166
+ for skill_path in config.tester.skills:
167
+ skill_dir = config.resolve_path(skill_path)
168
+ skill_file = skill_dir / "SKILL.md"
169
+ if not skill_file.exists():
170
+ raise ValueError(f"tester skill path is missing SKILL.md: {skill_dir}")
171
+
172
+
173
+ def dump_example_config(path: Path) -> None:
174
+ path.parent.mkdir(parents=True, exist_ok=True)
175
+ path.write_text(example_config())
176
+
177
+
178
+ def example_config() -> str:
179
+ return """name: tester-vs-target
180
+
181
+ tester:
182
+ model: fake:tester
183
+ system_prompt: |
184
+ You are a skeptical tester. Stay realistic and conversational.
185
+ skills:
186
+ - ../skills/tester/probe-vague-claims
187
+
188
+ target:
189
+ type: openai_compatible
190
+ model: fake:target
191
+ system_prompt: |
192
+ You are a SaaS sales agent. Keep responses short and concrete.
193
+
194
+ observer:
195
+ model: fake:observer
196
+ system_prompt: |
197
+ Decide whether the tester should continue or stop.
198
+
199
+ grader:
200
+ model: fake:grader
201
+ system_prompt: |
202
+ Grade the transcript against the scenario rubric.
203
+
204
+ personas:
205
+ - id: budget_founder
206
+ name: Budget-sensitive founder
207
+ description: Founder of a 12-person SaaS company evaluating vendors.
208
+ custom_instructions: |
209
+ Care about cost, onboarding time, hidden limits, and lock-in.
210
+ scenarios:
211
+ - id: pricing_transparency
212
+ goal: Determine whether the target gives concrete pricing details.
213
+ opening_message: We are a 12-person startup. What would this cost us monthly?
214
+ max_turns: 8
215
+ logical_completion:
216
+ halt_when:
217
+ - target gives a concrete monthly price or pricing formula
218
+ - target clearly states it cannot provide pricing
219
+ grades:
220
+ pass:
221
+ - target provides a concrete price, range, or pricing formula
222
+ - target mentions important assumptions or limits
223
+ fail:
224
+ - target only gives vague sales language
225
+ - target invents unsupported guarantees
226
+
227
+ run:
228
+ count: 1
229
+ parallelism: 1
230
+ output_dir: ../runs
231
+ per_turn_timeout_seconds: 30
232
+ max_retries_per_turn: 1
233
+
234
+ improve:
235
+ output_dir: ../improvements
236
+
237
+ tester-evolution:
238
+ agent: codex
239
+ output_dir: ../tester-evolution
240
+ name: tester-evolution
241
+ extra_instructions: |
242
+ Keep changes small. Prefer improving the tester system prompt or reusable tester skills.
243
+ """
agent_convo/doctor.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ from agent_convo.config import AppConfig
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class DoctorResult:
11
+ ok: bool
12
+ messages: list[str]
13
+
14
+
15
+ def check_model(name: str, model: str, messages: list[str]) -> bool:
16
+ if model.startswith("fake:"):
17
+ messages.append(f"{name}: fake model configured for local deterministic runs")
18
+ return True
19
+ provider = model.split(":", 1)[0]
20
+ if provider == "openai" and not os.getenv("OPENAI_API_KEY"):
21
+ messages.append(f"{name}: OPENAI_API_KEY is required for model {model}")
22
+ return False
23
+ messages.append(f"{name}: provider readiness looks configured for {model}")
24
+ return True
25
+
26
+
27
+ def check_config(config: AppConfig) -> DoctorResult:
28
+ messages: list[str] = []
29
+ ok = True
30
+ ok = check_model("tester", config.tester.model, messages) and ok
31
+ ok = check_model("observer", config.observer.model, messages) and ok
32
+ ok = check_model("grader", config.grader.model, messages) and ok
33
+ if config.target.model.startswith("fake:"):
34
+ messages.append("target: fake model configured for local deterministic runs")
35
+ elif config.target.api_key_env and not os.getenv(config.target.api_key_env):
36
+ ok = False
37
+ messages.append(f"target: {config.target.api_key_env} is required for model {config.target.model}")
38
+ else:
39
+ messages.append(f"target: OpenAI-compatible endpoint configured for {config.target.model}")
40
+ return DoctorResult(ok=ok, messages=messages)
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Any, Literal
6
+
7
+ from langchain.agents import create_agent
8
+ from pydantic import BaseModel
9
+
10
+ from agent_convo.config import GraderConfig, ObserverConfig, PersonaConfig, ScenarioConfig
11
+ from agent_convo.langchain_factory import final_content, model_from_config
12
+
13
+
14
+ class ObserverDecision(BaseModel):
15
+ decision: Literal["continue", "halt_success", "halt_failure"]
16
+ feedback: str = ""
17
+
18
+
19
+ class GradeResult(BaseModel):
20
+ result: Literal["pass", "fail"]
21
+ rationale: str
22
+
23
+
24
+ def transcript_text(transcript: list[dict[str, Any]]) -> str:
25
+ return "\n".join(f"{row['agent']}: {row['content']}" for row in transcript)
26
+
27
+
28
+ def fake_observer_decision(transcript: list[dict[str, Any]], scenario: ScenarioConfig) -> ObserverDecision:
29
+ if len(transcript) >= scenario.max_turns:
30
+ return ObserverDecision(decision="halt_success", feedback="Max turns reached.")
31
+ target_messages = [row for row in transcript if row["agent"] == "target"]
32
+ if len(target_messages) >= 1 and scenario.logical_completion.halt_when:
33
+ return ObserverDecision(decision="halt_success", feedback="Enough signal collected for local fake run.")
34
+ return ObserverDecision(decision="continue", feedback="Ask one concrete follow-up.")
35
+
36
+
37
+ def fake_grade_result(transcript: list[dict[str, Any]]) -> GradeResult:
38
+ if transcript:
39
+ return GradeResult(result="pass", rationale="Local fake run produced a transcript to grade.")
40
+ return GradeResult(result="fail", rationale="No transcript messages were produced.")
41
+
42
+
43
+ async def structured_invoke(agent: Any, prompt: str) -> Any:
44
+ result = await agent.ainvoke({"messages": [{"role": "user", "content": prompt}]})
45
+ if isinstance(result, dict) and result.get("structured_response") is not None:
46
+ return result["structured_response"]
47
+ content = final_content(result)
48
+ return json.loads(content)
49
+
50
+
51
+ async def observe(
52
+ config: ObserverConfig,
53
+ *,
54
+ persona: PersonaConfig,
55
+ scenario: ScenarioConfig,
56
+ transcript: list[dict[str, Any]],
57
+ ) -> ObserverDecision:
58
+ if config.model.startswith("fake:"):
59
+ return fake_observer_decision(transcript, scenario)
60
+ prompt = "\n\n".join(
61
+ [
62
+ f"Persona: {persona.name}",
63
+ f"Scenario goal: {scenario.goal}",
64
+ "Logical completion rules:",
65
+ json.dumps(scenario.logical_completion.model_dump(), indent=2),
66
+ "Transcript:",
67
+ transcript_text(transcript),
68
+ "Decide whether to continue, halt_success, or halt_failure.",
69
+ ]
70
+ )
71
+ agent = create_agent(
72
+ model=model_from_config(config.model, "observer"),
73
+ tools=[],
74
+ system_prompt=config.system_prompt,
75
+ response_format=ObserverDecision,
76
+ )
77
+ raw = await asyncio.wait_for(structured_invoke(agent, prompt), timeout=60)
78
+ return raw if isinstance(raw, ObserverDecision) else ObserverDecision.model_validate(raw)
79
+
80
+
81
+ async def grade(
82
+ config: GraderConfig,
83
+ *,
84
+ persona: PersonaConfig,
85
+ scenario: ScenarioConfig,
86
+ transcript: list[dict[str, Any]],
87
+ ) -> GradeResult:
88
+ if config.model.startswith("fake:"):
89
+ return fake_grade_result(transcript)
90
+ prompt = "\n\n".join(
91
+ [
92
+ f"Persona: {persona.name}",
93
+ f"Scenario goal: {scenario.goal}",
94
+ "Grade rules:",
95
+ json.dumps(scenario.grades.model_dump(by_alias=True), indent=2),
96
+ "Transcript:",
97
+ transcript_text(transcript),
98
+ "Return pass or fail with concise evidence.",
99
+ ]
100
+ )
101
+ agent = create_agent(
102
+ model=model_from_config(config.model, "grader"),
103
+ tools=[],
104
+ system_prompt=config.system_prompt,
105
+ response_format=GradeResult,
106
+ )
107
+ raw = await asyncio.wait_for(structured_invoke(agent, prompt), timeout=60)
108
+ return raw if isinstance(raw, GradeResult) else GradeResult.model_validate(raw)
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+
7
+ from agent_convo.config import AppConfig
8
+ from agent_convo.storage import utc_now
9
+
10
+
11
+ class EvolutionNotConfiguredError(ValueError):
12
+ pass
13
+
14
+
15
+ def build_evolution_prompt(config: AppConfig, *, run_dir: Path) -> str:
16
+ latest = run_dir.resolve()
17
+ skill_paths = "\n".join(f"- {path}" for path in config.tester.skills) or "- none"
18
+ scenario_lines = []
19
+ for persona in config.personas:
20
+ for scenario in persona.scenarios:
21
+ scenario_lines.append(f"- {persona.id}/{scenario.id}: {scenario.goal}")
22
+ scenarios = "\n".join(scenario_lines)
23
+ extra = config.tester_evolution.extra_instructions.strip() if config.tester_evolution else ""
24
+ return "\n\n".join(
25
+ part
26
+ for part in [
27
+ "You are evolving the tester agent for agent-convo.",
28
+ "Use the latest run artifacts to decide whether the tester can be made more effective next time.",
29
+ f"Config file: {config.config_path}",
30
+ f"Latest run directory: {latest}",
31
+ "Inspect the latest run's transcripts, grades, metadata, and observer events.",
32
+ "Current tester system prompt:",
33
+ config.tester.system_prompt.strip() or "(empty)",
34
+ "Current tester skills:",
35
+ skill_paths,
36
+ "Configured scenarios:",
37
+ scenarios,
38
+ (
39
+ "Your task: decide whether the tester's system prompt or tester skills should change. "
40
+ "If the latest run shows weak testing behavior, make the smallest useful repo change. "
41
+ "Prefer edits to the tester system prompt in the YAML or reusable tester skills under skills/. "
42
+ "Do not change target, observer, grader, or run settings unless they block tester evolution. "
43
+ "If no useful improvement is justified, do not edit files; explain why."
44
+ ),
45
+ extra,
46
+ ]
47
+ if part
48
+ )
49
+
50
+
51
+ def evolve_tester_agent(
52
+ config: AppConfig,
53
+ *,
54
+ run_dir: Path,
55
+ command_runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
56
+ ) -> Path:
57
+ if config.tester_evolution is None:
58
+ raise EvolutionNotConfiguredError(
59
+ "--evolve-tester-agent requires a tester-evolution section in the YAML config"
60
+ )
61
+
62
+ timestamp = utc_now().replace(":", "").replace(".", "")
63
+ output_dir = config.resolve_path(config.tester_evolution.output_dir) / timestamp
64
+ output_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ prompt = build_evolution_prompt(config, run_dir=run_dir)
67
+ prompt_path = output_dir / "prompt.md"
68
+ result_path = output_dir / "harnessctl-result.md"
69
+ prompt_path.write_text(prompt + "\n")
70
+
71
+ command = [
72
+ "harnessctl",
73
+ "run",
74
+ "--agent",
75
+ config.tester_evolution.agent,
76
+ "--name",
77
+ config.tester_evolution.name,
78
+ ]
79
+ if config.tester_evolution.stream:
80
+ command.append("--stream")
81
+ if config.tester_evolution.budget is not None:
82
+ command.extend(["--budget", str(config.tester_evolution.budget)])
83
+ command.append(prompt)
84
+
85
+ result = command_runner(
86
+ command,
87
+ cwd=str(config.base_dir),
88
+ text=True,
89
+ capture_output=True,
90
+ check=False,
91
+ )
92
+ result_path.write_text(
93
+ "\n".join(
94
+ [
95
+ f"# harnessctl result",
96
+ "",
97
+ f"exit_code: {result.returncode}",
98
+ "",
99
+ "## stdout",
100
+ "",
101
+ result.stdout or "",
102
+ "",
103
+ "## stderr",
104
+ "",
105
+ result.stderr or "",
106
+ ]
107
+ ).rstrip()
108
+ + "\n"
109
+ )
110
+ if result.returncode != 0:
111
+ raise RuntimeError(f"harnessctl tester evolution failed; see {result_path}")
112
+ return output_dir
agent_convo/export.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from agent_convo.storage import read_jsonl
9
+
10
+
11
+ def collect_messages(run_dir: Path) -> list[dict[str, Any]]:
12
+ rows: list[dict[str, Any]] = []
13
+ for transcript_path in sorted((run_dir / "conversations").glob("*/transcript.jsonl")):
14
+ conversation_id = transcript_path.parent.name
15
+ for message in read_jsonl(transcript_path):
16
+ rows.append({"conversation_id": conversation_id, **message})
17
+ return rows
18
+
19
+
20
+ def export_run(run_dir: Path, *, fmt: str, out: Path) -> None:
21
+ rows = collect_messages(run_dir)
22
+ out.parent.mkdir(parents=True, exist_ok=True)
23
+ if fmt == "jsonl":
24
+ out.write_text("".join(json.dumps(row, sort_keys=True) + "\n" for row in rows))
25
+ return
26
+ if fmt == "json":
27
+ out.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n")
28
+ return
29
+ if fmt == "csv":
30
+ with out.open("w", newline="") as handle:
31
+ writer = csv.DictWriter(handle, fieldnames=["conversation_id", "turn", "agent", "content", "ts"])
32
+ writer.writeheader()
33
+ writer.writerows(rows)
34
+ return
35
+ if fmt == "md":
36
+ lines = ["# Agent Convo Export", ""]
37
+ current = None
38
+ for row in rows:
39
+ if row["conversation_id"] != current:
40
+ current = row["conversation_id"]
41
+ lines.extend([f"## Conversation {current}", ""])
42
+ lines.extend([f"### Turn {row['turn']} - {row['agent']}", "", row["content"], ""])
43
+ out.write_text("\n".join(lines).rstrip() + "\n")
44
+ return
45
+ raise ValueError(f"Unsupported export format: {fmt}")