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.
- agent_convo/__init__.py +8 -0
- agent_convo/cli.py +137 -0
- agent_convo/config.py +243 -0
- agent_convo/doctor.py +40 -0
- agent_convo/evaluation.py +108 -0
- agent_convo/evolution.py +112 -0
- agent_convo/export.py +45 -0
- agent_convo/improve.py +73 -0
- agent_convo/langchain_factory.py +153 -0
- agent_convo/runner.py +345 -0
- agent_convo/storage.py +56 -0
- agent_convo-0.1.0.dist-info/METADATA +228 -0
- agent_convo-0.1.0.dist-info/RECORD +16 -0
- agent_convo-0.1.0.dist-info/WHEEL +4 -0
- agent_convo-0.1.0.dist-info/entry_points.txt +2 -0
- agent_convo-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_convo/__init__.py
ADDED
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)
|
agent_convo/evolution.py
ADDED
|
@@ -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}")
|