Spaces:
Sleeping
Sleeping
| """E2E tau-bench retail test with logfire tracing. | |
| Phases: | |
| 1. Baseline: run N retail tasks with no skillbook -> collect traces, rewards. | |
| 2. Learn: feed traces through the new agentic SkillManager -> build skillbook. | |
| 3. Replay: run the same N tasks with the trained skillbook -> new rewards. | |
| The TauBenchRunner already emits rich logfire spans ("tau task run", | |
| "tau task trace", "tau task outcome"). PydanticAI is auto-instrumented by | |
| configure_logfire(), so every SkillManager tool call shows up in logfire | |
| as a span. | |
| Usage:: | |
| uv run python test_sm_tau_retail.py | |
| Env: | |
| LOGFIRE_TOKEN — write token (instrumented runs) | |
| LOGFIRE_READ_TOKEN — read token (used by the script to fetch | |
| spans for verification after the run) | |
| AWS_BEARER_TOKEN_BEDROCK — Bedrock auth for haiku-4.5 | |
| OPENAI_API_KEY — for tau-bench's mandatory gpt-4.1 user sim | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import sys | |
| import time | |
| from types import MappingProxyType | |
| from typing import Any | |
| from dotenv import find_dotenv, load_dotenv | |
| load_dotenv(find_dotenv()) | |
| # Tau2 hardcodes gpt-4.1 (via OpenAI) for the NL-assertion judge, the | |
| # env interface, etc. Redirect to a Bedrock-hosted model before any | |
| # tau2 modules import these constants so the Bedrock-only rule holds. | |
| _BEDROCK_JUDGE = "bedrock/openai.gpt-oss-120b-1:0" | |
| import tau2.config as _tau2_config # noqa: E402 | |
| import tau2.evaluator.evaluator_nl_assertions as _nl_mod # noqa: E402 | |
| for _mod in (_tau2_config, _nl_mod): | |
| for _attr in ( | |
| "DEFAULT_LLM_AGENT", | |
| "DEFAULT_LLM_USER", | |
| "DEFAULT_LLM_NL_ASSERTIONS", | |
| "DEFAULT_LLM_ENV_INTERFACE", | |
| ): | |
| if hasattr(_mod, _attr): | |
| setattr(_mod, _attr, _BEDROCK_JUDGE) | |
| # Retail tasks' reward_basis includes NL_ASSERTION. tau2.run.run_task's | |
| # default evaluation_type is ALL (no NL eval), which raises. Force | |
| # ALL_WITH_NL_ASSERTIONS so the judge actually runs and a numeric reward | |
| # is produced. | |
| import tau2.run as _tau2_run # noqa: E402 | |
| from tau2.evaluator.evaluator import EvaluationType as _EvalType # noqa: E402 | |
| _original_run_task = _tau2_run.run_task | |
| def _run_task_with_nl(*args, **kwargs): | |
| kwargs.setdefault("evaluation_type", _EvalType.ALL_WITH_NL_ASSERTIONS) | |
| return _original_run_task(*args, **kwargs) | |
| _tau2_run.run_task = _run_task_with_nl | |
| # Configure logfire BEFORE any pydantic_ai agents are built so | |
| # instrumentation attaches cleanly. | |
| from ace.observability import configure_logfire | |
| _LOGFIRE_OK = configure_logfire() | |
| import logfire # noqa: E402 | |
| RUN_TAG = f"sm_retail_e2e_{int(time.time())}" | |
| logfire.info("test_sm_tau_retail.start", run_tag=RUN_TAG) | |
| # Emit a root span so we can query logfire for everything this run emitted. | |
| _root_span = logfire.span("sm_retail_e2e_run", run_tag=RUN_TAG) | |
| _root_span.__enter__() | |
| from ace.core.context import ACEStepContext # noqa: E402 | |
| from ace.core.recursive_agent import AgenticConfig # noqa: E402 | |
| from ace.core.skillbook import Skillbook # noqa: E402 | |
| from ace.implementations.rr.config import RecursiveConfig # noqa: E402 | |
| from ace.implementations.skill_manager import SkillManager # noqa: E402 | |
| from ace.steps.rr_step import RRStep # noqa: E402 | |
| from ace.steps.update import UpdateStep # noqa: E402 | |
| from ace_eval.e2e.benchmarks.tau_bench import TauBenchRunner # noqa: E402 | |
| AGENT_MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" | |
| SM_MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" | |
| # Bedrock-only: user sim uses gpt-oss-120b on Bedrock instead of the | |
| # tau-bench-canonical OpenAI gpt-4.1. Expect slightly lower rewards per | |
| # tau-bench's guidance; acceptable for validating the SM loop. | |
| USER_MODEL = "bedrock/openai.gpt-oss-120b-1:0" | |
| TASK_INDICES = (0, 1, 2) # small slice for speed | |
| MAX_STEPS = 50 | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s %(levelname)s %(name)s: %(message)s", | |
| ) | |
| # quieten noisy loggers | |
| for name in ( | |
| "httpx", | |
| "LiteLLM", | |
| "litellm", | |
| "tau2", | |
| "ace.core.recursive_agent", | |
| "pipeline", | |
| ): | |
| logging.getLogger(name).setLevel(logging.WARNING) | |
| log = logging.getLogger("sm.tau_retail") | |
| def _run_phase( | |
| runner: TauBenchRunner, | |
| task_indices: tuple[int, ...], | |
| *, | |
| phase: str, | |
| skillbook_prompt: str | None, | |
| ) -> list[dict[str, Any]]: | |
| """Run each task, return list of outcome dicts.""" | |
| log.info("phase=%s indices=%s skillbook=%s", phase, task_indices, bool(skillbook_prompt)) | |
| results = [] | |
| with logfire.span( | |
| "phase", phase=phase, run_tag=RUN_TAG, task_count=len(task_indices) | |
| ): | |
| for idx in task_indices: | |
| out = runner.run_task( | |
| idx, | |
| skillbook_prompt=skillbook_prompt, | |
| run_phase=phase, | |
| trial=0, | |
| ) | |
| log.info( | |
| " task %d: reward=%.2f outcome=%s wall=%.1fs", | |
| idx, | |
| out.reward, | |
| out.outcome.value, | |
| out.wall_clock_seconds or 0.0, | |
| ) | |
| results.append( | |
| { | |
| "task_index": idx, | |
| "reward": out.reward, | |
| "outcome": out.outcome.value, | |
| "trace": dict(out.trace) if out.trace else None, | |
| "wall": out.wall_clock_seconds or 0.0, | |
| "error": out.error, | |
| } | |
| ) | |
| return results | |
| def _feed_trace_to_pipeline( | |
| trace: dict[str, Any], | |
| *, | |
| reflect_step: RRStep, | |
| update_step: UpdateStep, | |
| skillbook: Skillbook, | |
| ) -> tuple[Any, Any]: | |
| """Run RRStep → UpdateStep on a single trace.""" | |
| from ace.core.context import SkillbookView | |
| ctx = ACEStepContext( | |
| sample=None, | |
| skillbook=SkillbookView(skillbook), | |
| trace=MappingProxyType(trace), | |
| injected_skill_ids=(), | |
| ) | |
| ctx1 = reflect_step(ctx) | |
| ctx2 = update_step(ctx1) | |
| return ctx1.reflections, ctx2.skill_manager_output | |
| def _skill_summary(skillbook: Skillbook) -> str: | |
| lines = [f"{len(skillbook.skills())} skills total:"] | |
| for s in skillbook.skills(): | |
| counters = f"u={s.used_count},+{s.helpful_count},-{s.harmful_count},={s.neutral_count}" | |
| snippet = s.content[:100] + ("…" if len(s.content) > 100 else "") | |
| lines.append(f" [{s.id}] ({counters}) {snippet}") | |
| return "\n".join(lines) | |
| def _fetch_logfire_spans(run_tag: str) -> dict[str, Any] | None: | |
| """Pull back spans for this run via the Logfire read API. | |
| Returns a dict with counts and a few representative span names, or | |
| None if read-token not configured / HTTP fails. | |
| """ | |
| token = os.environ.get("LOGFIRE_READ_TOKEN") | |
| if not token: | |
| log.warning("LOGFIRE_READ_TOKEN not set; skipping verification") | |
| return None | |
| import httpx | |
| try: | |
| # Give logfire a few seconds to flush | |
| logfire.force_flush() | |
| except Exception: | |
| pass | |
| # Logfire read API: GET /v1/query with ?sql=... and Bearer auth. | |
| # We tag only our top-level spans with run_tag. Broader view: recent | |
| # spans that likely belong to this run — SM tool names + pydantic-ai | |
| # chat/agent-run spans — so we can see whether SM tools actually fired. | |
| url = "https://logfire-us.pydantic.dev/v1/query" | |
| q = ( | |
| "SELECT span_name, attributes " | |
| "FROM records " | |
| f"WHERE (attributes->>'run_tag' = '{run_tag}' " | |
| " OR span_name IN ('rr.session','add_skill','update_skill','remove_skill'," | |
| " 'tag_skill','search_skills','read_skill','execute_code'," | |
| " 'agent run','chat','tau task run','tau task outcome')) " | |
| " AND start_timestamp > now() - INTERVAL '30 minutes' " | |
| "ORDER BY start_timestamp DESC LIMIT 500" | |
| ) | |
| r = None | |
| for method in ("GET", "POST"): | |
| try: | |
| if method == "GET": | |
| r = httpx.get( | |
| url, | |
| params={"sql": q}, | |
| headers={"Authorization": f"Bearer {token}"}, | |
| timeout=30.0, | |
| ) | |
| else: | |
| r = httpx.post( | |
| url, | |
| headers={ | |
| "Authorization": f"Bearer {token}", | |
| "Content-Type": "application/sql", | |
| }, | |
| content=q, | |
| timeout=30.0, | |
| ) | |
| if r.status_code == 200: | |
| break | |
| except Exception as e: | |
| log.warning("logfire query (%s) crashed: %s", method, e) | |
| continue | |
| if r is None or r.status_code != 200: | |
| log.warning( | |
| "logfire query failed: status=%s body=%s", | |
| r.status_code if r is not None else "none", | |
| (r.text[:400] if r is not None else ""), | |
| ) | |
| return None | |
| try: | |
| data = r.json() | |
| except Exception: | |
| log.warning("logfire response parse failed; body=%s", r.text[:400]) | |
| return None | |
| # Logfire returns column-oriented arrow-like payloads: {columns: [{name,values},...]} | |
| name_counter: dict[str, int] = {} | |
| total = 0 | |
| if isinstance(data, dict) and isinstance(data.get("columns"), list): | |
| span_col = next( | |
| (c for c in data["columns"] if c.get("name") == "span_name"), None | |
| ) | |
| if span_col: | |
| for name in span_col.get("values", []): | |
| if isinstance(name, str): | |
| name_counter[name] = name_counter.get(name, 0) + 1 | |
| total += 1 | |
| elif isinstance(data, list): | |
| for row in data: | |
| if isinstance(row, dict): | |
| name = row.get("span_name") or row.get("name") | |
| if isinstance(name, str): | |
| name_counter[name] = name_counter.get(name, 0) + 1 | |
| total += 1 | |
| return {"total": total, "span_counts": name_counter} | |
| def main() -> int: | |
| log.info("logfire=%s run_tag=%s", _LOGFIRE_OK, RUN_TAG) | |
| log.info("agent=%s sm=%s user=%s", AGENT_MODEL, SM_MODEL, USER_MODEL) | |
| # --- Build roles & skillbook | |
| skillbook = Skillbook() | |
| # RR handles long multi-turn traces via execute_code; simple Reflector | |
| # collapses them into a single LLM turn and gives up. | |
| reflect_step = RRStep( | |
| SM_MODEL, | |
| config=RecursiveConfig(max_requests=15, max_tokens=200_000), | |
| ) | |
| skill_manager = SkillManager( | |
| SM_MODEL, | |
| config=AgenticConfig(max_requests=15), | |
| ) | |
| update_step = UpdateStep(skill_manager, skillbook) | |
| # --- Build tau runner | |
| runner = TauBenchRunner( | |
| domain="retail", | |
| agent_model=AGENT_MODEL, | |
| user_model=USER_MODEL, | |
| user_strategy="llm", | |
| max_num_steps=MAX_STEPS, | |
| seed=300, | |
| ) | |
| log.info( | |
| "retail total_tasks=%s picked=%s", runner.total_tasks, TASK_INDICES | |
| ) | |
| # --- Phase 1: baseline | |
| baseline = _run_phase(runner, TASK_INDICES, phase="baseline", skillbook_prompt=None) | |
| baseline_reward = sum(r["reward"] for r in baseline) / len(baseline) | |
| log.info("baseline mean reward: %.2f", baseline_reward) | |
| # --- Phase 2: learn from baseline traces | |
| log.info("--- learning phase ---") | |
| for b in baseline: | |
| if b["trace"] is None: | |
| continue | |
| with logfire.span( | |
| "learn_from_trace", | |
| run_tag=RUN_TAG, | |
| task_index=b["task_index"], | |
| reward=b["reward"], | |
| ): | |
| reflections, sm_out = _feed_trace_to_pipeline( | |
| b["trace"], | |
| reflect_step=reflect_step, | |
| update_step=update_step, | |
| skillbook=skillbook, | |
| ) | |
| log.info( | |
| " task %d: reflection key_insight=%r ops=%d", | |
| b["task_index"], | |
| (reflections[0].key_insight if reflections else "")[:120], | |
| len(sm_out.operations) if sm_out else 0, | |
| ) | |
| log.info("--- skillbook after learning ---\n%s", _skill_summary(skillbook)) | |
| # --- Phase 3: replay with trained skillbook | |
| sb_prompt = skillbook.as_prompt() | |
| log.info("skillbook prompt bytes: %d", len(sb_prompt)) | |
| if not sb_prompt: | |
| log.warning("empty skillbook — skipping replay phase") | |
| replay_reward = None | |
| replay = [] | |
| else: | |
| replay = _run_phase( | |
| runner, TASK_INDICES, phase="replay", skillbook_prompt=sb_prompt | |
| ) | |
| replay_reward = sum(r["reward"] for r in replay) / len(replay) | |
| log.info("replay mean reward: %.2f", replay_reward) | |
| # --- Verify: pull back logfire spans | |
| log.info("--- verifying logfire spans ---") | |
| span_report = _fetch_logfire_spans(RUN_TAG) | |
| if span_report is None: | |
| log.info("no logfire verification performed") | |
| else: | |
| log.info("logfire spans retrieved: %s", span_report) | |
| # --- Summary | |
| print("\n" + "=" * 72) | |
| print("SUMMARY") | |
| print("=" * 72) | |
| print(f"run_tag: {RUN_TAG}") | |
| print(f"tasks: {TASK_INDICES}") | |
| print(f"baseline reward: {baseline_reward:.2f} ({[r['reward'] for r in baseline]})") | |
| if replay_reward is not None: | |
| print(f"replay reward: {replay_reward:.2f} ({[r['reward'] for r in replay]})") | |
| print(f"delta: {replay_reward - baseline_reward:+.2f}") | |
| print(f"skills created: {len(skillbook.skills())}") | |
| print(f"counters (sum): used={sum(s.used_count for s in skillbook.skills())} " | |
| f"helpful={sum(s.helpful_count for s in skillbook.skills())} " | |
| f"harmful={sum(s.harmful_count for s in skillbook.skills())} " | |
| f"neutral={sum(s.neutral_count for s in skillbook.skills())}") | |
| if span_report: | |
| print(f"logfire spans: {span_report.get('total','?')} records") | |
| # top 10 span names | |
| counts = span_report.get("span_counts", {}) or {} | |
| top = sorted(counts.items(), key=lambda kv: -kv[1])[:10] | |
| for n, c in top: | |
| print(f" {c:4d} {n}") | |
| return 0 | |
| if __name__ == "__main__": | |
| try: | |
| rc = main() | |
| finally: | |
| try: | |
| _root_span.__exit__(None, None, None) | |
| logfire.force_flush() | |
| except Exception: | |
| pass | |
| sys.exit(rc) | |