Skip to content

Commit dc474ad

Browse files
committed
feat(examples): add counterfactual trace optimization loop
1 parent e113610 commit dc474ad

32 files changed

Lines changed: 3997 additions & 0 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# 方案设计
2+
3+
本方案将评测优化闭环的核心从“根据失败文本猜测原因”改为“通过同一评测器验证最小行为反事实”。系统先检查训练集、验证集、提示词和预算契约,并将样例标记为 trusted、suspect 或 invalid。只有 trusted 且具有修复证据的失败能够驱动优化。
4+
5+
对于失败轨迹,系统深拷贝实际对话,分别替换最终回复、工具名称、工具参数或受限组合,保持预期对话不变,再调用 `AgentEvaluator.get_executer()` 评分。单一干预修复失败时形成强归因;只有组合有效时判定为复合失败。数据、评测器和基础设施异常不进入优化摘要。若局部替换与原工具响应不一致,证据会降低置信度。
6+
7+
归因结果映射到 router、skill 和 system 三类 `TargetPrompt`。真实优化入口固定使用 `AgentOptimizer.optimize(update_source=False)`,候选必须在完整验证集重新评测,新增退化再次执行反事实诊断。Gate 采用 all-must-pass,检查验证增益、可信子集、hard fail、关键样例、严重度、成本、耗时和证据。只有 gate 接受且显式传入 `--apply` 时才调用 `TargetPrompt.write_all()`,并记录写前写后哈希。报告保存轨迹证据、候选差异、拒绝原因、随机种子及复现命令。
8+
9+
## 技术附录
10+
11+
- 反事实归因:结论来自真实 metric delta,不依赖 case ID、failure reason 或人工标签。
12+
- Prompt actionability:只有 agent behavior failure 能够选择优化表面。
13+
- Gate:关键检查必须全部通过,证据不足时以 `NEEDS_REVIEW` 拒绝。
14+
- 防过拟合:训练失败驱动优化,完整 validation 决策,新退化独立诊断。
15+
- 审计:记录输入和提示词哈希、seed、耗时、成本、候选及每项 gate 证据。
16+
- 限制:局部轨迹替换可能形成现实中不可执行的状态,因此显式记录一致性并降低置信度。
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Trust-Aware Counterfactual Trace Diagnosis Loop
2+
3+
This example closes evaluation and prompt optimization with evidence from trace interventions. Unlike loops that classify a failure from reason text or sample metadata, it deep-copies the actual trace, changes one execution surface, and sends the counterfactual `EvalCase` through the same public `AgentEvaluator` metrics. Evaluation-data, evaluator, and infrastructure failures are excluded from prompt optimization.
4+
5+
## Quick start
6+
7+
```bash
8+
python examples/optimization/eval_optimize_loop/run_counterfactual_probe.py
9+
python examples/optimization/eval_optimize_loop/run_pipeline.py --mode fake
10+
python examples/optimization/eval_optimize_loop/run_pipeline.py --mode trace
11+
python examples/optimization/eval_optimize_loop/run_pipeline.py --mode fake --candidate-profile accepted
12+
python examples/optimization/eval_optimize_loop/run_pipeline.py --mode fake --candidate-profile ineffective
13+
```
14+
15+
Both fake and trace modes need no API key. Trace mode replays `actual_conversation`; `conversation` remains the expected trace. The fake optimizer is prompt-sensitive and introduces an intentionally broad billing rule without reading case IDs.
16+
17+
## Real optimizer
18+
19+
Fake and trace modes are fully runnable and verified end to end. `pipeline.optimizer.run_real_optimizer()` wires the public `AgentOptimizer.optimize(..., update_source=False)` API to selected `TargetPrompt` files and is verified with a mock/spy, including actionable-case filtering and unified optimization fields. It has not been run against a real model in this example. Production use requires a business `call_agent`, model credentials, and a trace-capture adapter for candidate regression diagnosis.
20+
21+
## Write-back
22+
23+
`--apply` is necessary but not sufficient. Files are written only after every gate check passes. The implementation records baseline hashes, calls `TargetPrompt.write_all()` once, verifies changed hashes, and restores the baseline on failure. The bundled candidate is rejected, so running with `--apply` leaves all source prompts unchanged.
24+
25+
## Outputs
26+
27+
- `prototype_output/counterfactual_probe.{json,md}`: feasibility evidence.
28+
- `sample_output/optimizer_failure_digest.json`: actionable and excluded failures.
29+
- `sample_output/optimization_report.{json,md}`: baseline, reliability, attribution, candidate deltas, regression diagnosis, gate, cost, duration, hashes, and reproduction command.
30+
31+
## Known limits
32+
33+
Counterfactual traces can create states that a real agent could not produce, especially when a tool name changes but its response does not. The loop therefore restricts combinations, validates trace shape, exposes invalid interventions, and rejects insufficient evidence. Exact metrics are deterministic; LLM judges require repeated sampling before a case can be trusted. Windows absolute evalset paths are converted to relative paths because the current SDK interprets a drive-letter colon as a case selector.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Trust-aware counterfactual trace optimization example."""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Deterministic semantic fake components."""
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Prompt-sensitive fake behavior generator; never reads eval IDs."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
7+
from trpc_agent_sdk.evaluation import EvalCase, Invocation
8+
9+
10+
def user_text(case: EvalCase) -> str:
11+
return " ".join(part.text or "" for part in case.conversation[0].user_content.parts).lower()
12+
13+
14+
def generate_trace(case: EvalCase, prompts: dict[str, str]) -> EvalCase:
15+
"""Generate behavior from user semantics and prompt rules."""
16+
changed = case.model_copy(deep=True)
17+
text = user_text(case)
18+
router = prompts.get("router_prompt", "")
19+
skill = prompts.get("skill_prompt", "")
20+
system = prompts.get("system_prompt", "")
21+
tool_name = None
22+
tool_args = None
23+
if "shipping" in text or "shipment" in text:
24+
tracking = re.search(r"t-\d+", text).group(0).upper()
25+
tool_name, tool_args = "track_shipment", {"tracking_id": tracking}
26+
response = f'{{"tracking_id":"{tracking}","status":"in_transit"}}'
27+
elif "billing" in text or "invoice" in text:
28+
invoice = re.search(r"i-\d+", text).group(0).upper()
29+
tool_name = "create_refund" if "BILLING_TO_REFUND=ON" in router else "get_invoice"
30+
tool_args = {"invoice_id": invoice}
31+
response = f'{{"invoice_id":"{invoice}","status":"open"}}'
32+
else:
33+
order_match = re.search(r"o-\d+", text)
34+
if order_match is None:
35+
response = '{"status":"unsupported"}'
36+
raw = {
37+
"user_content": case.conversation[0].user_content.model_dump(mode="json"),
38+
"final_response": {"role": "model", "parts": [{"text": response}]},
39+
}
40+
changed.actual_conversation = [Invocation.model_validate(raw)]
41+
return changed
42+
order = order_match.group(0).upper()
43+
if "summarize" in text:
44+
response = (
45+
f'{{"status":"pending","order_id":"{order}"}}'
46+
if "JSON_STATUS=ALWAYS_REQUIRED" in system
47+
else f'{{"order_id":"{order}"}}'
48+
)
49+
else:
50+
reason = "duplicate" if "duplicate" in text else "damaged"
51+
strict = "REFUND_ROUTE=STRICT" in router
52+
tool_name = "create_refund" if strict or reason == "duplicate" else "get_invoice"
53+
tool_args = {"order_id": order}
54+
if "REFUND_REASON=REQUIRED" in skill or reason == "damaged":
55+
tool_args["reason"] = reason
56+
response = f'{{"status":"submitted","order_id":"{order}"}}'
57+
raw = {
58+
"user_content": case.conversation[0].user_content.model_dump(mode="json"),
59+
"final_response": {"role": "model", "parts": [{"text": response}]},
60+
}
61+
if tool_name:
62+
raw["intermediate_data"] = {"tool_uses": [{"name": tool_name, "args": tool_args}]}
63+
changed.actual_conversation = [Invocation.model_validate(raw)]
64+
return changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Fake optimizer with the same candidate contract as the real adapter."""
2+
3+
from __future__ import annotations
4+
5+
6+
async def optimize(prompts: dict[str, str], targets: list[str], profile: str = "overfit") -> dict:
7+
candidate = dict(prompts)
8+
if profile == "ineffective":
9+
return {
10+
"total_rounds": 1,
11+
"rounds": [
12+
{
13+
"round": 1,
14+
"profile": profile,
15+
"targets": [],
16+
"candidate_prompts": candidate,
17+
"cost": 0.0,
18+
"tokens": 0,
19+
"duration_seconds": 0.0,
20+
}
21+
],
22+
"best_prompts": candidate,
23+
"cost": 0.0,
24+
"tokens": 0,
25+
"seed": 42,
26+
"candidate_profile": profile,
27+
}
28+
if profile not in ("accepted", "overfit"):
29+
raise ValueError(f"unknown candidate profile: {profile}")
30+
if "router_prompt" in targets:
31+
candidate["router_prompt"] += "\nREFUND_ROUTE=STRICT\n"
32+
if profile == "overfit":
33+
candidate["router_prompt"] += "BILLING_TO_REFUND=ON\n"
34+
if "skill_prompt" in targets:
35+
candidate["skill_prompt"] += "\nREFUND_REASON=REQUIRED\n"
36+
if "system_prompt" in targets:
37+
candidate["system_prompt"] += "\nJSON_STATUS=ALWAYS_REQUIRED\n"
38+
return {
39+
"total_rounds": 1,
40+
"rounds": [
41+
{
42+
"round": 1,
43+
"profile": profile,
44+
"targets": list(targets),
45+
"candidate_prompts": candidate,
46+
"cost": 0.0,
47+
"tokens": 0,
48+
"duration_seconds": 0.0,
49+
}
50+
],
51+
"best_prompts": candidate,
52+
"cost": 0.0,
53+
"tokens": 0,
54+
"seed": 42,
55+
"candidate_profile": profile,
56+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"min_validation_delta":0.01,"max_cost":1.0,"max_latency_seconds":180,"max_counterfactual_evaluations_per_case":7,"protected_cases":["val_billing"]}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"evaluate":{"metrics":[{"metric_name":"tool_trajectory_avg_score","threshold":1.0},{"metric_name":"final_response_avg_score","threshold":1.0}],"num_runs":1},"optimize":{"algorithm":{"name":"gepa_reflective","seed":42,"reflection_lm":{"model_name":"${TRPC_AGENT_MODEL_NAME}","base_url":"${TRPC_AGENT_BASE_URL}","api_key":"${TRPC_AGENT_API_KEY}"},"max_metric_calls":20}}}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Pipeline components for the counterfactual trace optimization loop."""
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Budgeted counterfactual evaluation using the official evaluator."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from trpc_agent_sdk.evaluation import EvalCase
8+
9+
from .diagnosis import attribute_from_evidence
10+
from .interventions import InterventionKind, build_counterfactual
11+
from .models import CounterfactualEvidence, FailureAttribution
12+
from .probe import evaluate_trace_cases
13+
14+
15+
async def diagnose_trace_case(case: EvalCase, workspace: Path, max_evaluations: int = 7) -> FailureAttribution:
16+
baseline = (await evaluate_trace_cases([case], workspace))[case.eval_id]
17+
evidence = []
18+
singles = [
19+
InterventionKind.REPLACE_FINAL_RESPONSE,
20+
InterventionKind.REPLACE_TOOL_NAME,
21+
InterventionKind.REPLACE_TOOL_ARGUMENTS,
22+
InterventionKind.NORMALIZE_FORMAT,
23+
]
24+
combinations = [
25+
InterventionKind.REPLACE_TOOL_NAME_AND_ARGUMENTS,
26+
InterventionKind.REPLACE_TOOL_NAME_AND_FINAL_RESPONSE,
27+
InterventionKind.REPLACE_TOOL_ARGUMENTS_AND_FINAL_RESPONSE,
28+
]
29+
for kind in singles + combinations:
30+
if len(evidence) >= max_evaluations:
31+
break
32+
built = build_counterfactual(case, kind)
33+
if not built.valid or built.eval_case is None:
34+
evidence.append(
35+
CounterfactualEvidence(
36+
kind.value,
37+
False,
38+
built.status,
39+
False,
40+
[],
41+
[],
42+
baseline,
43+
{},
44+
structurally_valid=built.structurally_valid,
45+
semantically_coherent=built.semantically_coherent,
46+
coherence_warnings=list(built.coherence_warnings),
47+
)
48+
)
49+
continue
50+
after = (await evaluate_trace_cases([built.eval_case], workspace))[built.eval_case.eval_id]
51+
repaired = sorted(k for k, v in baseline.items() if v < 1 and after.get(k, v) >= 1)
52+
unchanged = sorted(k for k, v in baseline.items() if after.get(k) == v)
53+
evidence.append(
54+
CounterfactualEvidence(
55+
kind.value,
56+
True,
57+
built.status,
58+
all(v >= 1 for v in after.values()),
59+
repaired,
60+
unchanged,
61+
baseline,
62+
after,
63+
structurally_valid=built.structurally_valid,
64+
semantically_coherent=built.semantically_coherent,
65+
coherence_warnings=list(built.coherence_warnings),
66+
)
67+
)
68+
if evidence[-1].changed_fail_to_pass and kind in singles:
69+
break
70+
return attribute_from_evidence(case.eval_id, evidence, evaluations_used=len(evidence), budget=max_evaluations)

0 commit comments

Comments
 (0)