teich 0.1.1a1__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.
- agentic_datagen/__init__.py +18 -0
- agentic_datagen/__main__.py +4 -0
- agentic_datagen/cli.py +333 -0
- agentic_datagen/config.py +289 -0
- agentic_datagen/converter.py +647 -0
- agentic_datagen/formatter.py +792 -0
- agentic_datagen/loader.py +49 -0
- agentic_datagen/runner.py +1525 -0
- agentic_datagen/trace_readme.py +214 -0
- teich/__init__.py +36 -0
- teich/__main__.py +4 -0
- teich-0.1.1a1.dist-info/METADATA +132 -0
- teich-0.1.1a1.dist-info/RECORD +15 -0
- teich-0.1.1a1.dist-info/WHEEL +4 -0
- teich-0.1.1a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Agentic Datagen v2 - Generate training data from Codex and Pi traces."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.1a1"
|
|
4
|
+
|
|
5
|
+
from .config import Config, load_config
|
|
6
|
+
from .converter import TrainingExample, convert_trace_to_training_example, convert_traces_to_training_data
|
|
7
|
+
from .formatter import format_and_mask
|
|
8
|
+
from .loader import load_traces
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Config",
|
|
12
|
+
"TrainingExample",
|
|
13
|
+
"convert_trace_to_training_example",
|
|
14
|
+
"convert_traces_to_training_data",
|
|
15
|
+
"format_and_mask",
|
|
16
|
+
"load_traces",
|
|
17
|
+
"load_config",
|
|
18
|
+
]
|
agentic_datagen/cli.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""CLI for teich."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import RLock
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console, Group
|
|
11
|
+
from rich.live import Live
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .runner import CodexRunner, PiRunner, SessionProgressUpdate, TraceMetrics
|
|
17
|
+
from .trace_readme import write_traces_readme
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
name="teich",
|
|
22
|
+
help="Generate agent training data using Codex or Pi",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def generate(
|
|
29
|
+
config: Path = typer.Option(
|
|
30
|
+
Path("config.yaml"),
|
|
31
|
+
"--config", "-c",
|
|
32
|
+
help="Path to configuration file",
|
|
33
|
+
),
|
|
34
|
+
output: Path = typer.Option(
|
|
35
|
+
None,
|
|
36
|
+
"--output", "-o",
|
|
37
|
+
help="Override output directory",
|
|
38
|
+
),
|
|
39
|
+
concurrency: int = typer.Option(
|
|
40
|
+
None,
|
|
41
|
+
"--concurrency", "-j",
|
|
42
|
+
min=1,
|
|
43
|
+
help="Number of prompts to run in parallel",
|
|
44
|
+
),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Generate training traces from prompts."""
|
|
47
|
+
console.print(Panel.fit("Teich", style="bold blue"))
|
|
48
|
+
|
|
49
|
+
if not config.exists():
|
|
50
|
+
console.print(f"[red]Config file not found: {config}[/red]")
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
|
|
53
|
+
cfg = Config.from_yaml(config)
|
|
54
|
+
if output:
|
|
55
|
+
cfg.output.traces_dir = output
|
|
56
|
+
if concurrency is not None:
|
|
57
|
+
cfg.max_concurrency = concurrency
|
|
58
|
+
|
|
59
|
+
# Ensure output dir exists
|
|
60
|
+
cfg.output.traces_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
prompt_inputs = cfg.get_prompt_inputs()
|
|
62
|
+
effective_concurrency = (
|
|
63
|
+
max(1, min(cfg.max_concurrency, len(prompt_inputs))) if prompt_inputs else cfg.max_concurrency
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
console.print(f"[green]Loaded config: {config}[/green]")
|
|
67
|
+
console.print(
|
|
68
|
+
f"[blue]Processing {len(prompt_inputs)} prompts with concurrency {effective_concurrency}...[/blue]\n"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Run generation
|
|
72
|
+
try:
|
|
73
|
+
agent_provider = cfg.get_agent_provider()
|
|
74
|
+
if agent_provider == "codex":
|
|
75
|
+
runner = CodexRunner(cfg)
|
|
76
|
+
elif agent_provider == "pi":
|
|
77
|
+
runner = PiRunner(cfg)
|
|
78
|
+
else:
|
|
79
|
+
console.print(
|
|
80
|
+
f"[red]Unsupported agent provider: {agent_provider}. Supported providers: codex, pi.[/red]"
|
|
81
|
+
)
|
|
82
|
+
raise typer.Exit(1)
|
|
83
|
+
|
|
84
|
+
with BatchProgressReporter(console) as reporter:
|
|
85
|
+
results = runner.run_all(
|
|
86
|
+
max_concurrency=cfg.max_concurrency,
|
|
87
|
+
progress_callback=reporter.update,
|
|
88
|
+
)
|
|
89
|
+
readme_path = write_traces_readme(
|
|
90
|
+
cfg.output.traces_dir,
|
|
91
|
+
pretty_name=cfg.output.pretty_name,
|
|
92
|
+
tags=cfg.output.tags,
|
|
93
|
+
model_id=cfg.model.model,
|
|
94
|
+
readme_file_name=cfg.output.readme_file_name,
|
|
95
|
+
)
|
|
96
|
+
totals = reporter.snapshot_totals()
|
|
97
|
+
|
|
98
|
+
console.print(f"\n[bold green]Success! Generated {len(results)} trace files:[/bold green]")
|
|
99
|
+
for r in results:
|
|
100
|
+
console.print(f" - {r}")
|
|
101
|
+
console.print(
|
|
102
|
+
"\n[cyan]Usage:[/cyan] "
|
|
103
|
+
f"tokens={totals['total_tokens']} input={totals['input_tokens']} "
|
|
104
|
+
f"output={totals['output_tokens']} reasoning={totals['reasoning_tokens']} "
|
|
105
|
+
f"cache_read={totals['cache_read_tokens']}"
|
|
106
|
+
)
|
|
107
|
+
console.print(f"[cyan]API cost:[/cyan] ${totals['total_cost']:.6f}")
|
|
108
|
+
console.print(f"[green]Saved sandboxes: {cfg.output.sandbox_dir}[/green]")
|
|
109
|
+
console.print(f"\n[green]Wrote README: {readme_path}[/green]")
|
|
110
|
+
|
|
111
|
+
except Exception as e:
|
|
112
|
+
console.print(f"\n[red]Error: {e}[/red]")
|
|
113
|
+
raise typer.Exit(1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _format_elapsed(update: SessionProgressUpdate) -> str:
|
|
117
|
+
if update.started_at is None:
|
|
118
|
+
return "--"
|
|
119
|
+
finished_at = update.finished_at or datetime.now(timezone.utc)
|
|
120
|
+
elapsed = max(0, int((finished_at - update.started_at).total_seconds()))
|
|
121
|
+
minutes, seconds = divmod(elapsed, 60)
|
|
122
|
+
hours, minutes = divmod(minutes, 60)
|
|
123
|
+
if hours:
|
|
124
|
+
return f"{hours:d}:{minutes:02d}:{seconds:02d}"
|
|
125
|
+
return f"{minutes:02d}:{seconds:02d}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _format_api_tokens(metrics: TraceMetrics | None) -> str:
|
|
129
|
+
if not metrics or not metrics.total_tokens:
|
|
130
|
+
return "--"
|
|
131
|
+
return str(metrics.total_tokens)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _format_cost(metrics: TraceMetrics | None) -> str:
|
|
135
|
+
if not metrics:
|
|
136
|
+
return "--"
|
|
137
|
+
return f"${metrics.total_cost:.6f}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class BatchProgressReporter:
|
|
141
|
+
def __init__(self, progress_console: Console):
|
|
142
|
+
self.console = progress_console
|
|
143
|
+
self.enabled = progress_console.is_terminal
|
|
144
|
+
self._lock = RLock()
|
|
145
|
+
self._updates: dict[str, SessionProgressUpdate] = {}
|
|
146
|
+
self._live: Live | None = None
|
|
147
|
+
|
|
148
|
+
def __enter__(self) -> BatchProgressReporter:
|
|
149
|
+
if self.enabled:
|
|
150
|
+
self._live = Live(self._render(), console=self.console, refresh_per_second=4)
|
|
151
|
+
self._live.__enter__()
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def __exit__(self, exc_type, exc, exc_tb) -> None:
|
|
155
|
+
if self._live is not None:
|
|
156
|
+
self._live.__exit__(exc_type, exc, exc_tb)
|
|
157
|
+
self._live = None
|
|
158
|
+
|
|
159
|
+
def update(self, update: SessionProgressUpdate) -> None:
|
|
160
|
+
with self._lock:
|
|
161
|
+
previous = self._updates.get(update.prompt_id)
|
|
162
|
+
if previous and update.started_at is None:
|
|
163
|
+
update.started_at = previous.started_at
|
|
164
|
+
self._updates[update.prompt_id] = update
|
|
165
|
+
if self._live is not None:
|
|
166
|
+
self._live.update(self._render(), refresh=True)
|
|
167
|
+
|
|
168
|
+
def snapshot_totals(self) -> dict[str, float | int]:
|
|
169
|
+
with self._lock:
|
|
170
|
+
totals = {
|
|
171
|
+
"input_tokens": 0,
|
|
172
|
+
"output_tokens": 0,
|
|
173
|
+
"reasoning_tokens": 0,
|
|
174
|
+
"cache_read_tokens": 0,
|
|
175
|
+
"cache_write_tokens": 0,
|
|
176
|
+
"total_tokens": 0,
|
|
177
|
+
"est_total_tokens": 0,
|
|
178
|
+
"total_cost": 0.0,
|
|
179
|
+
}
|
|
180
|
+
for update in self._updates.values():
|
|
181
|
+
metrics = update.metrics
|
|
182
|
+
if not metrics:
|
|
183
|
+
continue
|
|
184
|
+
totals["input_tokens"] += metrics.input_tokens
|
|
185
|
+
totals["output_tokens"] += metrics.output_tokens
|
|
186
|
+
totals["reasoning_tokens"] += metrics.reasoning_tokens
|
|
187
|
+
totals["cache_read_tokens"] += metrics.cache_read_tokens
|
|
188
|
+
totals["cache_write_tokens"] += metrics.cache_write_tokens
|
|
189
|
+
totals["total_tokens"] += metrics.total_tokens
|
|
190
|
+
totals["est_total_tokens"] += metrics.est_total_tokens
|
|
191
|
+
totals["total_cost"] += metrics.total_cost
|
|
192
|
+
return totals
|
|
193
|
+
|
|
194
|
+
def _render(self):
|
|
195
|
+
with self._lock:
|
|
196
|
+
summary = Table.grid(expand=True)
|
|
197
|
+
summary.add_column(justify="left")
|
|
198
|
+
summary.add_column(justify="left")
|
|
199
|
+
queued = sum(1 for update in self._updates.values() if update.status == "queued")
|
|
200
|
+
running = sum(1 for update in self._updates.values() if update.status == "running")
|
|
201
|
+
completed = sum(1 for update in self._updates.values() if update.status == "completed")
|
|
202
|
+
failed = sum(1 for update in self._updates.values() if update.status == "failed")
|
|
203
|
+
totals = self.snapshot_totals()
|
|
204
|
+
summary.add_row(
|
|
205
|
+
f"queued={queued} running={running} completed={completed} failed={failed}",
|
|
206
|
+
f"tokens={totals['total_tokens']} cost=${totals['total_cost']:.6f}",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
table = Table(title="Generation Progress")
|
|
210
|
+
table.add_column("#", justify="right", no_wrap=True)
|
|
211
|
+
table.add_column("status", no_wrap=True)
|
|
212
|
+
table.add_column("elapsed", no_wrap=True)
|
|
213
|
+
table.add_column("tokens", justify="right", no_wrap=True)
|
|
214
|
+
table.add_column("cost", justify="right", no_wrap=True)
|
|
215
|
+
table.add_column("model", no_wrap=True)
|
|
216
|
+
table.add_column("prompt")
|
|
217
|
+
table.add_column("details")
|
|
218
|
+
active_updates = [
|
|
219
|
+
update
|
|
220
|
+
for update in self._updates.values()
|
|
221
|
+
if update.status in {"queued", "running"}
|
|
222
|
+
]
|
|
223
|
+
for update in sorted(active_updates, key=lambda item: item.prompt_index):
|
|
224
|
+
metrics = update.metrics
|
|
225
|
+
model = metrics.model if metrics and metrics.model else "--"
|
|
226
|
+
details = "--"
|
|
227
|
+
if update.trace_path is not None:
|
|
228
|
+
details = str(update.trace_path.name)
|
|
229
|
+
elif update.error:
|
|
230
|
+
details = update.error
|
|
231
|
+
table.add_row(
|
|
232
|
+
str(update.prompt_index),
|
|
233
|
+
update.status,
|
|
234
|
+
_format_elapsed(update),
|
|
235
|
+
_format_api_tokens(metrics),
|
|
236
|
+
_format_cost(metrics),
|
|
237
|
+
model,
|
|
238
|
+
update.prompt_preview,
|
|
239
|
+
details,
|
|
240
|
+
)
|
|
241
|
+
return Group(Panel.fit(summary, title="Batch Summary"), table)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.command()
|
|
245
|
+
def init(
|
|
246
|
+
path: Path = typer.Argument(Path("."), help="Directory to initialize"),
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Initialize a new teich project."""
|
|
249
|
+
console.print(Panel.fit("Initialize Project", style="bold blue"))
|
|
250
|
+
|
|
251
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
|
|
253
|
+
# Create config.yaml
|
|
254
|
+
config_path = path / "config.yaml"
|
|
255
|
+
if not config_path.exists():
|
|
256
|
+
config_path.write_text(CONFIG_TEMPLATE, encoding="utf-8")
|
|
257
|
+
console.print(f"[green]Created: {config_path}[/green]")
|
|
258
|
+
else:
|
|
259
|
+
console.print(f"[yellow]Already exists: {config_path}[/yellow]")
|
|
260
|
+
|
|
261
|
+
# Create prompts.csv
|
|
262
|
+
prompts_path = path / "prompts.csv"
|
|
263
|
+
if not prompts_path.exists():
|
|
264
|
+
prompts_path.write_text(PROMPTS_TEMPLATE, encoding="utf-8")
|
|
265
|
+
console.print(f"[green]Created: {prompts_path}[/green]")
|
|
266
|
+
else:
|
|
267
|
+
console.print(f"[yellow]Already exists: {prompts_path}[/yellow]")
|
|
268
|
+
|
|
269
|
+
console.print(f"\n[bold green]Initialized in {path.absolute()}[/bold green]")
|
|
270
|
+
console.print("\n[yellow]Next:[/yellow]")
|
|
271
|
+
console.print("1. Set OPENAI_API_KEY in config.yaml or env")
|
|
272
|
+
console.print("2. Add prompt rows to prompts.csv")
|
|
273
|
+
console.print("3. Run: [cyan]uvx teich generate -c config.yaml[/cyan]")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
CONFIG_TEMPLATE = '''# Teich Configuration
|
|
277
|
+
agent:
|
|
278
|
+
provider: codex
|
|
279
|
+
|
|
280
|
+
model:
|
|
281
|
+
model: codex-mini-latest
|
|
282
|
+
approval_policy: never
|
|
283
|
+
sandbox: danger-full-access
|
|
284
|
+
reasoning_effort: null
|
|
285
|
+
|
|
286
|
+
# Optional: API/model provider configuration
|
|
287
|
+
# api:
|
|
288
|
+
# provider: openai
|
|
289
|
+
# base_url: null
|
|
290
|
+
# api_key: null
|
|
291
|
+
|
|
292
|
+
# Optional: MCP servers
|
|
293
|
+
# mcp_servers:
|
|
294
|
+
# - name: filesystem
|
|
295
|
+
# command: npx
|
|
296
|
+
# args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
|
|
297
|
+
|
|
298
|
+
# Load prompts from file (optional)
|
|
299
|
+
prompts_file: prompts.csv
|
|
300
|
+
|
|
301
|
+
# Or define prompts here
|
|
302
|
+
prompts: []
|
|
303
|
+
|
|
304
|
+
output:
|
|
305
|
+
traces_dir: ./output
|
|
306
|
+
sandbox_dir: ./sandbox
|
|
307
|
+
pretty_name: "My Agent Traces"
|
|
308
|
+
readme_file_name: README.md
|
|
309
|
+
tags:
|
|
310
|
+
- agent-traces
|
|
311
|
+
- codex
|
|
312
|
+
|
|
313
|
+
max_concurrency: 1
|
|
314
|
+
timeout_seconds: 600
|
|
315
|
+
|
|
316
|
+
# Set via env: OPENAI_API_KEY or here
|
|
317
|
+
openai_api_key: null
|
|
318
|
+
developer_instructions: null
|
|
319
|
+
'''
|
|
320
|
+
|
|
321
|
+
PROMPTS_TEMPLATE = '''image,github_repo,prompt
|
|
322
|
+
None,None,"Build a simple todo list app in React"
|
|
323
|
+
None,None,"Create a Python script that fetches weather data from an API"
|
|
324
|
+
None,armand0e/perplexica-mcp,"Add a small usability improvement and update the tests"
|
|
325
|
+
'''
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def main():
|
|
329
|
+
app()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
if __name__ == "__main__":
|
|
333
|
+
main()
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Configuration management for teich."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
GITHUB_REPO_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_env_alias(*names: str) -> str | None:
|
|
18
|
+
for name in names:
|
|
19
|
+
value = os.getenv(name)
|
|
20
|
+
if value:
|
|
21
|
+
return value
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MCPConfig(BaseModel):
|
|
26
|
+
"""MCP server configuration."""
|
|
27
|
+
name: str
|
|
28
|
+
command: str | None = None
|
|
29
|
+
args: list[str] = Field(default_factory=list)
|
|
30
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
31
|
+
env_vars: list[str] = Field(default_factory=list)
|
|
32
|
+
cwd: str | None = None
|
|
33
|
+
url: str | None = None
|
|
34
|
+
bearer_token_env_var: str | None = None
|
|
35
|
+
http_headers: dict[str, str] = Field(default_factory=dict)
|
|
36
|
+
env_http_headers: dict[str, str] = Field(default_factory=dict)
|
|
37
|
+
startup_timeout_sec: int | None = None
|
|
38
|
+
tool_timeout_sec: int | None = None
|
|
39
|
+
enabled: bool = True
|
|
40
|
+
required: bool = False
|
|
41
|
+
enabled_tools: list[str] = Field(default_factory=list)
|
|
42
|
+
disabled_tools: list[str] = Field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
@model_validator(mode="after")
|
|
45
|
+
def validate_transport(self) -> MCPConfig:
|
|
46
|
+
if not self.command and not self.url:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"MCP server '{self.name}' must define either command or url"
|
|
49
|
+
)
|
|
50
|
+
if self.command and self.url:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"MCP server '{self.name}' cannot define both command and url"
|
|
53
|
+
)
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class APIConfig(BaseModel):
|
|
58
|
+
"""API configuration for OpenAI-compatible endpoints."""
|
|
59
|
+
provider: str = "openai" # openai, openrouter, azure, etc.
|
|
60
|
+
base_url: str | None = None # e.g., https://openrouter.ai/api/v1
|
|
61
|
+
api_key: str | None = None # Override global api_key for this provider
|
|
62
|
+
wire_api: str = "responses"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AgentConfig(BaseModel):
|
|
66
|
+
"""Agent runtime selection."""
|
|
67
|
+
provider: str = "codex"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ModelConfig(BaseModel):
|
|
71
|
+
"""Model configuration."""
|
|
72
|
+
model: str = "codex-mini-latest"
|
|
73
|
+
approval_policy: str = "never"
|
|
74
|
+
sandbox: str = "danger-full-access"
|
|
75
|
+
reasoning_effort: str | None = None
|
|
76
|
+
approval_mode: str | None = "none"
|
|
77
|
+
pi_model_overrides: dict[str, object] = Field(default_factory=dict)
|
|
78
|
+
|
|
79
|
+
@model_validator(mode="after")
|
|
80
|
+
def normalize_legacy_approval_mode(self) -> ModelConfig:
|
|
81
|
+
if self.approval_mode:
|
|
82
|
+
self.approval_policy = {
|
|
83
|
+
"none": "never",
|
|
84
|
+
"suggest": "on-request",
|
|
85
|
+
"auto-edit": "on-request",
|
|
86
|
+
"full-auto": "on-request",
|
|
87
|
+
}.get(self.approval_mode, self.approval_policy)
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class OutputConfig(BaseModel):
|
|
92
|
+
"""Output configuration."""
|
|
93
|
+
traces_dir: Path = Field(default=Path("./output"))
|
|
94
|
+
sandbox_dir: Path = Field(default=Path("./sandbox"))
|
|
95
|
+
pretty_name: str = "Agentic Training Traces"
|
|
96
|
+
tags: list[str] = Field(default_factory=lambda: ["agent-traces", "codex"])
|
|
97
|
+
readme_file_name: str = "README.md"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PromptInput(BaseModel):
|
|
101
|
+
"""Structured prompt input row."""
|
|
102
|
+
image: str | None = None
|
|
103
|
+
github_repo: str | None = None
|
|
104
|
+
prompt: str
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _normalize_optional_text(value: object) -> str | None:
|
|
108
|
+
if value is None:
|
|
109
|
+
return None
|
|
110
|
+
text = value if isinstance(value, str) else str(value)
|
|
111
|
+
normalized = text.strip()
|
|
112
|
+
if not normalized or normalized.lower() == "none":
|
|
113
|
+
return None
|
|
114
|
+
return normalized
|
|
115
|
+
|
|
116
|
+
@field_validator("image", "github_repo", mode="before")
|
|
117
|
+
@classmethod
|
|
118
|
+
def normalize_optional_fields(cls, value: object) -> str | None:
|
|
119
|
+
return cls._normalize_optional_text(value)
|
|
120
|
+
|
|
121
|
+
@field_validator("prompt", mode="before")
|
|
122
|
+
@classmethod
|
|
123
|
+
def normalize_prompt(cls, value: object) -> str:
|
|
124
|
+
if value is None:
|
|
125
|
+
return ""
|
|
126
|
+
return (value if isinstance(value, str) else str(value)).strip()
|
|
127
|
+
|
|
128
|
+
@field_validator("prompt")
|
|
129
|
+
@classmethod
|
|
130
|
+
def validate_prompt(cls, value: str) -> str:
|
|
131
|
+
if not value:
|
|
132
|
+
raise ValueError("Prompt cannot be empty")
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
@field_validator("github_repo")
|
|
136
|
+
@classmethod
|
|
137
|
+
def validate_github_repo(cls, value: str | None) -> str | None:
|
|
138
|
+
if value is None:
|
|
139
|
+
return None
|
|
140
|
+
if not GITHUB_REPO_ID_PATTERN.fullmatch(value):
|
|
141
|
+
raise ValueError(
|
|
142
|
+
"github_repo must be in owner/repo format, e.g. armand0e/perplexica-mcp"
|
|
143
|
+
)
|
|
144
|
+
return value
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Config(BaseModel):
|
|
148
|
+
"""Main configuration."""
|
|
149
|
+
agent: AgentConfig = Field(default_factory=AgentConfig)
|
|
150
|
+
model: ModelConfig = Field(default_factory=ModelConfig)
|
|
151
|
+
api: APIConfig = Field(default_factory=APIConfig)
|
|
152
|
+
mcp_servers: list[MCPConfig] = Field(default_factory=list)
|
|
153
|
+
prompts: list[str] = Field(default_factory=list)
|
|
154
|
+
prompts_file: Path | None = None
|
|
155
|
+
output: OutputConfig = Field(default_factory=OutputConfig)
|
|
156
|
+
max_concurrency: int = Field(default=1, ge=1)
|
|
157
|
+
timeout_seconds: int = 600
|
|
158
|
+
openai_api_key: str | None = None
|
|
159
|
+
developer_instructions: str | None = None
|
|
160
|
+
|
|
161
|
+
@field_validator("prompts_file")
|
|
162
|
+
@classmethod
|
|
163
|
+
def validate_prompts_file(cls, v: Path | None) -> Path | None:
|
|
164
|
+
if v is not None and not v.exists():
|
|
165
|
+
raise ValueError(f"Prompts file not found: {v}")
|
|
166
|
+
return v
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def from_yaml(cls, path: Path) -> Config:
|
|
170
|
+
"""Load config from YAML file.
|
|
171
|
+
|
|
172
|
+
Environment variables override config file values (for testing):
|
|
173
|
+
TEICH_MODEL - Override model ID
|
|
174
|
+
TEICH_BASE_URL - Override API base URL
|
|
175
|
+
TEICH_API_KEY - Override API key
|
|
176
|
+
TEICH_PROVIDER - Override provider name
|
|
177
|
+
"""
|
|
178
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
179
|
+
data = yaml.safe_load(f) or {}
|
|
180
|
+
|
|
181
|
+
api_config = data.get("api")
|
|
182
|
+
if isinstance(api_config, dict) and "model" in api_config:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
"Unsupported config key 'api.model'. Set the model under 'model.model' instead."
|
|
185
|
+
)
|
|
186
|
+
prompts_file = data.get("prompts_file")
|
|
187
|
+
if isinstance(prompts_file, str) and prompts_file.strip():
|
|
188
|
+
prompts_file_path = Path(prompts_file)
|
|
189
|
+
if not prompts_file_path.is_absolute():
|
|
190
|
+
data["prompts_file"] = (path.parent / prompts_file_path).resolve()
|
|
191
|
+
|
|
192
|
+
# Apply environment variable overrides
|
|
193
|
+
if model_env := _get_env_alias("TEICH_MODEL", "AGENTIC_DATAGEN_MODEL"):
|
|
194
|
+
data.setdefault("model", {})["model"] = model_env
|
|
195
|
+
if base_url_env := _get_env_alias("TEICH_BASE_URL", "AGENTIC_DATAGEN_BASE_URL"):
|
|
196
|
+
data.setdefault("api", {})["base_url"] = base_url_env
|
|
197
|
+
if api_key_env := _get_env_alias("TEICH_API_KEY", "AGENTIC_DATAGEN_API_KEY"):
|
|
198
|
+
data.setdefault("api", {})["api_key"] = api_key_env
|
|
199
|
+
if provider_env := _get_env_alias("TEICH_PROVIDER", "AGENTIC_DATAGEN_PROVIDER"):
|
|
200
|
+
data.setdefault("api", {})["provider"] = provider_env
|
|
201
|
+
|
|
202
|
+
return cls(**data)
|
|
203
|
+
|
|
204
|
+
def get_api_key(self) -> str | None:
|
|
205
|
+
"""Get effective API key (from api config or global)."""
|
|
206
|
+
return self.api.api_key or self.openai_api_key
|
|
207
|
+
|
|
208
|
+
def get_effective_model(self) -> str:
|
|
209
|
+
"""Get effective model identifier."""
|
|
210
|
+
return self.model.model
|
|
211
|
+
|
|
212
|
+
def get_base_url(self) -> str | None:
|
|
213
|
+
"""Get effective base URL."""
|
|
214
|
+
return self.api.base_url
|
|
215
|
+
|
|
216
|
+
def get_agent_provider(self) -> str:
|
|
217
|
+
"""Get active agent provider."""
|
|
218
|
+
return self.agent.provider.strip().lower() or "codex"
|
|
219
|
+
|
|
220
|
+
def get_prompts(self) -> list[str]:
|
|
221
|
+
"""Get prompt text only for all configured prompt inputs."""
|
|
222
|
+
return [prompt_input.prompt for prompt_input in self.get_prompt_inputs()]
|
|
223
|
+
|
|
224
|
+
def get_prompt_inputs(self) -> list[PromptInput]:
|
|
225
|
+
"""Get structured prompt inputs from config and prompts_file."""
|
|
226
|
+
prompt_inputs = [PromptInput(prompt=prompt) for prompt in self.prompts]
|
|
227
|
+
if self.prompts_file:
|
|
228
|
+
prompt_inputs.extend(self._load_prompt_inputs_from_file(self.prompts_file))
|
|
229
|
+
for prompt_input in prompt_inputs:
|
|
230
|
+
if prompt_input.image is not None:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
"Prompt image inputs are not supported yet. Leave the image column blank or set it to None."
|
|
233
|
+
)
|
|
234
|
+
return prompt_inputs
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _load_prompt_inputs_from_file(path: Path) -> list[PromptInput]:
|
|
238
|
+
if path.suffix.lower() == ".csv":
|
|
239
|
+
return Config._load_prompt_inputs_from_csv(path)
|
|
240
|
+
return Config._load_prompt_inputs_from_text(path)
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _load_prompt_inputs_from_text(path: Path) -> list[PromptInput]:
|
|
244
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
245
|
+
return [
|
|
246
|
+
PromptInput(prompt=line.strip())
|
|
247
|
+
for line in handle
|
|
248
|
+
if line.strip() and not line.startswith("#")
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def _load_prompt_inputs_from_csv(path: Path) -> list[PromptInput]:
|
|
253
|
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
|
254
|
+
reader = csv.DictReader(handle)
|
|
255
|
+
fieldnames = [name.strip().lower() for name in reader.fieldnames or [] if isinstance(name, str)]
|
|
256
|
+
if "prompt" not in fieldnames:
|
|
257
|
+
raise ValueError("Prompt CSV must include a 'prompt' column")
|
|
258
|
+
prompt_inputs: list[PromptInput] = []
|
|
259
|
+
for row in reader:
|
|
260
|
+
normalized_row = {
|
|
261
|
+
key.strip().lower(): value
|
|
262
|
+
for key, value in row.items()
|
|
263
|
+
if isinstance(key, str)
|
|
264
|
+
}
|
|
265
|
+
if not any(
|
|
266
|
+
isinstance(value, str) and value.strip()
|
|
267
|
+
for value in normalized_row.values()
|
|
268
|
+
):
|
|
269
|
+
continue
|
|
270
|
+
prompt_inputs.append(
|
|
271
|
+
PromptInput(
|
|
272
|
+
image=normalized_row.get("image"),
|
|
273
|
+
github_repo=normalized_row.get("github_repo"),
|
|
274
|
+
prompt=normalized_row.get("prompt") or "",
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
return prompt_inputs
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def load_config(path: Path) -> Config:
|
|
281
|
+
"""Load configuration from YAML file.
|
|
282
|
+
|
|
283
|
+
Environment variables (for testing):
|
|
284
|
+
TEICH_MODEL - Override model ID
|
|
285
|
+
TEICH_BASE_URL - Override API base URL
|
|
286
|
+
TEICH_API_KEY - Override API key
|
|
287
|
+
TEICH_PROVIDER - Override provider name
|
|
288
|
+
"""
|
|
289
|
+
return Config.from_yaml(path)
|