Skip to content

Commit 781cb4a

Browse files
committed
feat(optimization): add reproducible eval optimize loop
1 parent 80d769b commit 781cb4a

16 files changed

Lines changed: 1058 additions & 754 deletions

examples/optimization/eval_optimize_loop/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
# runs/latest prompt snapshots are kept in VCS as example deliverables.
33
__pycache__/
44
_sdk_eval_metrics.json
5+
runs/latest/agent_optimizer/
Lines changed: 89 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,110 @@
1-
# eval_optimize_loop
1+
# Evaluation + Optimization Loop
22

3-
> Part of the `examples/optimization` series. Where [`quickstart`](../quickstart)
4-
> drives a live `AgentOptimizer` (GEPA) against a real agent, this example focuses
5-
> on the **closed loop around** optimization — baseline evaluation, failure
6-
> attribution, candidate validation, an acceptance gate, and an auditable report —
7-
> and runs fully reproducibly in fake/trace mode **without an API key**.
3+
## 1. Purpose
84

9-
This example implements a reproducible Evaluation + Optimization closed loop:
5+
This example implements the issue requirement for a reproducible Evaluation + Optimization pipeline. It is not only an `AgentOptimizer` quickstart: it wraps optimization with baseline evaluation, failure attribution, validation regression, gate decisions, and audit artifacts.
6+
7+
The default `fake` mode runs without model credentials. The `live` mode uses a real `LlmAgent` bridge and invokes `AgentOptimizer.optimize` against a `TargetPrompt`.
8+
9+
## 2. Pipeline Stages
10+
11+
The pipeline runs six stages:
12+
13+
1. Baseline evaluation: score train and validation sets separately, including metric scores, pass/fail, reasons, and key trace fields.
14+
2. Failure attribution: cluster failures into `final_response_mismatch`, `tool_call_error`, `parameter_error`, `llm_rubric_not_met`, `knowledge_recall_insufficient`, and `format_error`.
15+
3. Optimization execution: fake mode applies a deterministic candidate; live mode calls `AgentOptimizer.optimize` with `TargetPrompt.add_path("system_prompt", ...)`.
16+
4. Candidate validation: rerun train and validation sets and compute per-case deltas such as `new_pass`, `new_fail`, `score_up`, and `score_down`.
17+
5. Acceptance gate: require validation gain, no new hard fail, no key-case regression, no train-up/validation-down overfit, and cost within budget.
18+
6. Audit persistence: write prompt snapshots, scores, deltas, gate reasons, cost, duration, seed, and config snapshots.
19+
20+
## 3. Directory Layout
1021

1122
```text
12-
baseline evaluation
13-
-> failure attribution
14-
-> prompt candidate generation
15-
-> validation regression
16-
-> acceptance gate
17-
-> auditable JSON/Markdown report
23+
examples/optimization/eval_optimize_loop/
24+
├── agent/
25+
│ ├── __init__.py
26+
│ └── agent.py
27+
├── prompts/
28+
│ └── system.md
29+
├── train.evalset.json
30+
├── val.evalset.json
31+
├── case_meta.json
32+
├── optimizer.json
33+
├── optimizer.sdk.json
34+
├── run.py
35+
├── optimization_report.json
36+
└── optimization_report.md
1837
```
1938

20-
Run without API keys:
39+
## 4. Inputs
40+
41+
- `train.evalset.json`: training evaluation set.
42+
- `val.evalset.json`: validation evaluation set; it must be a different file from train.
43+
- `optimizer.json`: outer-loop configuration for mode, metrics, fake candidate patch, and gate thresholds.
44+
- `prompts/system.md`: baseline prompt source registered as the optimization target.
45+
- `case_meta.json`: out-of-schema metadata for key cases, rubric kinds, and attribution hints.
46+
- `optimizer.sdk.json`: live-only SDK optimizer config passed to `AgentOptimizer.optimize`.
47+
48+
## 5. Outputs
49+
50+
- `optimization_report.json`: machine-readable audit report with baseline, candidate, delta, gate, attribution, optimizer status, cost, duration, seed, and config snapshot.
51+
- `optimization_report.md`: human-readable decision summary.
52+
- `runs/latest/baseline_prompt.md`: exact baseline prompt snapshot.
53+
- `runs/latest/candidate_prompt.md`: candidate prompt snapshot.
54+
- `runs/latest/agent_optimizer/`: live-only raw SDK artifacts, including `RoundRecord`-backed round files, `result.json`, `summary.txt`, and `best_prompts/`.
55+
56+
## 6. Run Modes
57+
58+
Fake mode:
2159

2260
```bash
23-
# First run may spend time on a one-off `uv sync`; the loop itself is ~seconds.
24-
uv run python examples/optimization/eval_optimize_loop/run.py
61+
python examples/optimization/eval_optimize_loop/run.py --mode fake
2562
```
2663

27-
Set `YUN_LOG_LEVEL=DEBUG` for more verbose logs (default `INFO`).
64+
Live mode:
2865

29-
Inputs:
66+
```bash
67+
set TRPC_AGENT_API_KEY=...
68+
set TRPC_AGENT_BASE_URL=...
69+
set TRPC_AGENT_MODEL_NAME=...
70+
python examples/optimization/eval_optimize_loop/run.py --mode live
71+
```
72+
73+
`fake` mode uses a deterministic fake model, fake judge, and scripted candidate so the full loop runs without API keys. `live` mode uses `agent/agent.py`, creates a fresh `LlmAgent` for each call, and invokes `AgentOptimizer.optimize`.
74+
75+
## 7. Customizing The Agent
76+
77+
Edit `agent/agent.py` when connecting a real business agent.
78+
79+
Key constraints:
3080

31-
- `prompts/baseline_system.md` — target prompt being optimized.
32-
- `train.evalset.json` / `val.evalset.json` — SDK-clean evalsets (trace mode).
33-
- `case_meta.json` — per-case `key` / `rubric` / `tool_intent` (kept out of the
34-
evalset so `EvalSet` stays schema-clean).
35-
- `optimizer.json` — metric weights, scripted candidate patch, and gate thresholds.
81+
- `make_call_agent(prompt_path)` must return an async function with the exact optimizer contract `async (query: str) -> str`.
82+
- `create_agent(prompt_path)` must re-read the prompt file every time so candidates written by `AgentOptimizer` take effect immediately.
83+
- `TargetPrompt.add_path("system_prompt", path)` must point to the same prompt file that the agent actually reads.
84+
- For HTTP, CLI, remote config, or multi-agent pipelines, keep the outer contract the same and replace only the bridge implementation.
3685

37-
Outputs:
86+
The outer report still computes richer trace-style scoring. The SDK optimizer itself receives final-text responses through `call_agent`, so `optimizer.sdk.json` intentionally avoids metrics that require full session traces.
3887

39-
- `optimization_report.json` / `optimization_report.md`
40-
- `runs/latest/baseline_prompt.md` / `runs/latest/candidate_prompt.md`
88+
## 8. Design And Validation
4189

42-
The sample has 6 cases:
90+
Failure attribution is rule-based over structured signals, not case ids. Each case records final response, tool trajectory, rubric sub-scores, and expected/actual tool calls. Rubric failures map to `format_error` or `llm_rubric_not_met`; tool mismatches map to tool, parameter, spurious-call, or knowledge-recall categories.
4391

44-
- train: two optimizable failures and one optimization-ineffective format case.
45-
- validation: one new pass, one hard regression, and one soft degradation.
92+
The gate is validation-first. A candidate is accepted only if validation mean improves by the configured threshold, no new hard fail appears, key validation cases do not regress, train improvement does not coincide with validation loss, and cost is within budget.
4693

47-
## 设计说明(四支柱)
94+
The bundled fake candidate intentionally improves two train cases and one validation case while damaging two key validation cases. The expected sample decision is `REJECT`, demonstrating overfit rejection.
4895

49-
**失败归因(阶段 2)。** 归因完全基于结构化评测信号,不依赖 case 命名。每条 case
50-
记录三项 metric 子分(final_response / tool_trajectory / rubric)与关键轨迹(query、
51-
expected/actual 工具与回复)。`classify_tool_failure` 据「期望轨迹 vs 实际轨迹」判类:
52-
期望调用权威检索工具却没调用 → `knowledge_recall_insufficient`;调全了期望工具又多调
53-
`spurious_tool_call`;首工具名对但参数不同 → `parameter_error`;否则 `tool_call_error`
54-
rubric 维度由 `case_meta.json` 显式声明(`json_format`/`no_tool`/`single_tool`),失败时
55-
映射为 `format_error``llm_rubric_not_met`。归因只统计 baseline 失败。
96+
Verified fake command:
5697

57-
**接受策略(阶段 5)。** Gate 以验证集为先,五项可配置约束全过才 ACCEPT:① 验证集均分
58-
提升 ≥ `min_val_score_gain`;② 无新增 hard fail;③ 无「关键 case」退化(关键性由
59-
`case_meta.json``key=true` 标记,而非把所有验证 case 一概视为关键);④ 非过拟合
60-
(训练涨而验证跌);⑤ 优化成本 ≤ `max_cost_usd`。各检查相互独立,便于定位拒绝原因。
98+
```bash
99+
C:\Users\27303\PycharmProjects\Yun\.venv\Scripts\python.exe examples\optimization\eval_optimize_loop\run.py --mode fake
100+
```
101+
102+
Observed sample result:
61103

62-
**防过拟合。** 第 ④ 项专门拦截「训练大幅提升、验证退化」:本例候选给 baseline 注入
63-
激进检索行为,训练集 +0.53 但验证集回落,gate 据此 REJECT。关键 case 退化(③)与新增
64-
hard fail(②)提供正交的二次保险,即便总分变化很小也能拦住有害候选。
104+
```text
105+
train: 0.25 -> 0.7833
106+
validation: 0.7333 -> 0.6667
107+
decision: REJECT
108+
```
65109

66-
**产物审计(阶段 6)。** `optimization_report.json` 持久化 baseline/candidate 逐 case 分数与
67-
轨迹、逐 case delta、失败归因、gate 各检查、决策理由、成本/耗时/seed、prompt 的 SHA-256 与
68-
config 快照;`runs/latest/` 留存 baseline 与候选 prompt 全文。`.md` 顶部由 `narrate_report`
69-
依据 gate/delta 数据生成「人话总结」,确定性、无需模型,换输入也不会失真。SDK 桥接通过
70-
`EvalSet.model_validate_json` 校验评测集,并用 trace-only `AgentEvaluator` 跑一次冒烟,证明
71-
管线确实接到真实 SDK 评测器;fake/trace 模式仅在评分/优化处用确定性替身以保证无 key 可复现。
110+
Known limits: live mode requires SDK dependencies plus `TRPC_AGENT_API_KEY`, `TRPC_AGENT_BASE_URL`, and `TRPC_AGENT_MODEL_NAME`; no-key environments should use `--mode fake`.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Agent bridge package for the eval_optimize_loop example."""
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Live agent bridge for the eval_optimize_loop example.
7+
8+
The optimizer contract is intentionally small: ``call_agent`` is an async
9+
function that accepts one user query and returns the final response text. This
10+
module re-reads the prompt file on every invocation so prompt candidates written
11+
by AgentOptimizer take effect immediately.
12+
13+
The public bridge in this file mirrors the SDK docs:
14+
15+
* ``create_agent`` builds a fresh ``LlmAgent`` from the current prompt file.
16+
* ``run_agent`` drives that agent through ``Runner`` and ``InMemorySessionService``.
17+
* ``make_call_agent`` returns the exact async callable required by
18+
``AgentOptimizer.optimize`` when a ``TargetPrompt`` is registered.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import os
24+
import uuid
25+
from pathlib import Path
26+
from typing import Any
27+
from typing import Awaitable
28+
from typing import Callable
29+
30+
from trpc_agent_sdk.agents import LlmAgent
31+
from trpc_agent_sdk.models import OpenAIModel
32+
from trpc_agent_sdk.runners import Runner
33+
from trpc_agent_sdk.sessions import InMemorySessionService
34+
from trpc_agent_sdk.tools import FunctionTool
35+
from trpc_agent_sdk.types import Content
36+
from trpc_agent_sdk.types import Part
37+
38+
39+
APP_NAME = "eval_optimize_loop"
40+
41+
42+
def lookup_order(order_id: str) -> str:
43+
"""FunctionTool body used by the live ``LlmAgent`` example."""
44+
data = {
45+
"A100": "Order A100 is in transit and arrives on Friday.",
46+
"A200": "Order A200 is delivered.",
47+
}
48+
return data.get(order_id, f"No order record found for {order_id}.")
49+
50+
51+
def search_policy(topic: str) -> str:
52+
"""FunctionTool body for policy and warranty lookup examples."""
53+
topic_lower = topic.lower()
54+
if "damaged" in topic_lower or "refund" in topic_lower:
55+
return "Damaged items are eligible for a full refund within 30 days."
56+
if "model z" in topic_lower or "warranty" in topic_lower:
57+
return "Model Z has a 24-month warranty."
58+
return "No matching policy snippet was found."
59+
60+
61+
def get_model_config() -> tuple[str, str, str]:
62+
"""Read live model credentials consumed by ``OpenAIModel``."""
63+
api_key = os.getenv("TRPC_AGENT_API_KEY", "")
64+
base_url = os.getenv("TRPC_AGENT_BASE_URL", "")
65+
model_name = os.getenv("TRPC_AGENT_MODEL_NAME", "")
66+
if not api_key or not base_url or not model_name:
67+
raise ValueError(
68+
"Live mode requires TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL, and "
69+
"TRPC_AGENT_MODEL_NAME. Use --mode fake for the no-key path."
70+
)
71+
return api_key, base_url, model_name
72+
73+
74+
def create_agent(prompt_path: Path) -> LlmAgent:
75+
"""Create a fresh ``LlmAgent`` from the current prompt file.
76+
77+
Re-reading here is the critical TargetPrompt contract: when
78+
``AgentOptimizer`` writes a candidate prompt, the next call immediately uses
79+
that candidate without restarting the process.
80+
"""
81+
api_key, base_url, model_name = get_model_config()
82+
instruction = Path(prompt_path).read_text(encoding="utf-8").strip()
83+
return LlmAgent(
84+
name="support_assistant",
85+
description="A support assistant whose system prompt is under optimization.",
86+
model=OpenAIModel(model_name=model_name, api_key=api_key, base_url=base_url),
87+
instruction=instruction,
88+
tools=[FunctionTool(lookup_order), FunctionTool(search_policy)],
89+
)
90+
91+
92+
async def run_agent(query: str, prompt_path: Path) -> dict[str, Any]:
93+
"""Run the live agent once and collect final text plus tool calls.
94+
95+
``AgentOptimizer.optimize`` only needs final response text, but the outer
96+
issue-level report also wants key trajectory information. This richer helper
97+
supports both.
98+
"""
99+
agent = create_agent(prompt_path)
100+
session_service = InMemorySessionService()
101+
runner = Runner(app_name=APP_NAME, agent=agent, session_service=session_service)
102+
session_id = str(uuid.uuid4())
103+
user_id = "optimizer"
104+
await session_service.create_session(
105+
app_name=APP_NAME,
106+
user_id=user_id,
107+
session_id=session_id,
108+
state={},
109+
)
110+
message = Content(role="user", parts=[Part.from_text(text=query)])
111+
final_text = ""
112+
tools: list[dict[str, Any]] = []
113+
async for event in runner.run_async(
114+
user_id=user_id,
115+
session_id=session_id,
116+
new_message=message,
117+
):
118+
if not event.content or not event.content.parts:
119+
continue
120+
for part in event.content.parts:
121+
function_call = getattr(part, "function_call", None)
122+
if function_call is not None:
123+
tools.append(
124+
{
125+
"name": getattr(function_call, "name", None),
126+
"args": dict(getattr(function_call, "args", {}) or {}),
127+
}
128+
)
129+
if event.is_final_response():
130+
for part in event.content.parts:
131+
if getattr(part, "text", None) and not getattr(part, "thought", False):
132+
final_text += part.text
133+
return {"text": final_text.strip(), "tools": tools}
134+
135+
136+
def make_call_agent(prompt_path: Path) -> Callable[[str], Awaitable[str]]:
137+
"""Return the fixed async ``(query: str) -> str`` bridge required by GEPA."""
138+
139+
async def call_agent(query: str) -> str:
140+
return (await run_agent(query=query, prompt_path=prompt_path))["text"]
141+
142+
return call_agent
Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,35 @@
11
{
2-
"_comment": "Per-case metadata kept out of the SDK evalset so EvalSet stays schema-clean. 'key' marks cases that must not regress (gate uses it for critical-regression). 'rubric' selects the rubric dimension scored by run.score_case. 'tool_intent' is a trace-level attribution hint: 'authoritative_search' means the expected trajectory relies on the authoritative search tool, so a wrong tool is attributed to knowledge-recall insufficiency rather than a generic tool error.",
3-
"train_ip_lookup_optimizable": {"key": false, "rubric": "none"},
4-
"train_calendar_optimizable": {"key": false, "rubric": "none"},
5-
"train_strict_json_ineffective": {"key": false, "rubric": "json_format"},
6-
"val_search_fallback_new_pass": {"key": false, "rubric": "none", "tool_intent": "authoritative_search"},
7-
"val_smalltalk_no_tool_regression": {"key": true, "rubric": "no_tool"},
8-
"val_weather_soft_degradation": {"key": true, "rubric": "single_tool"}
2+
"_comment": "Per-case metadata for attribution, gate checks, and fake/live trace scoring. It is kept outside evalsets so EvalSet schema validation remains clean.",
3+
"train_order_lookup_optimizable": {
4+
"category": "tool_call_error",
5+
"key": false,
6+
"rubric": "none"
7+
},
8+
"train_refund_policy_optimizable": {
9+
"category": "knowledge_recall_insufficient",
10+
"key": false,
11+
"rubric": "none",
12+
"authoritative_tool": "search_policy"
13+
},
14+
"train_json_format_ineffective": {
15+
"category": "format_error",
16+
"key": false,
17+
"rubric": "json_format"
18+
},
19+
"val_warranty_new_pass": {
20+
"category": "knowledge_recall_insufficient",
21+
"key": false,
22+
"rubric": "none",
23+
"authoritative_tool": "search_policy"
24+
},
25+
"val_smalltalk_regression": {
26+
"category": "spurious_tool_call",
27+
"key": true,
28+
"rubric": "no_tool"
29+
},
30+
"val_order_soft_degradation": {
31+
"category": "spurious_tool_call",
32+
"key": true,
33+
"rubric": "single_tool"
34+
}
935
}

0 commit comments

Comments
 (0)