chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
test-results.xml
|
||||
|
||||
# GAIA data directories
|
||||
data_gaia_hub/
|
||||
**/data_gaia_hub/
|
||||
gaia/**/*.jsonl
|
||||
|
||||
# Lightning data directories
|
||||
lightning/**/data/tau2
|
||||
|
||||
# TAU2 data directories
|
||||
tau2/**/data/
|
||||
tau2/**/results/
|
||||
@@ -0,0 +1,22 @@
|
||||
# Lab Package (agent-framework-lab)
|
||||
|
||||
Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives.
|
||||
|
||||
## Structure
|
||||
|
||||
This package contains experimental sub-packages:
|
||||
|
||||
- `gaia/` - GAIA benchmark integration
|
||||
- `lightning/` - Lightning-based training utilities
|
||||
- `tau2/` - Tau-bench evaluation framework
|
||||
- `namespace/` - Experimental namespace utilities
|
||||
|
||||
## Note
|
||||
|
||||
Lab packages are experimental and may change frequently. They are not included in the standard `agent-framework[all]` installation.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install agent-framework-lab
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Agent Framework Lab
|
||||
|
||||
This is the experimental package for Microsoft Agent Framework, `agent-framework-lab`, which contains
|
||||
various lab modules built on top of the core framework.
|
||||
Lab modules are not part of the core framework and may experience breaking changes or be deprecated in the future.
|
||||
|
||||
## What are Lab Modules?
|
||||
|
||||
Lab modules are extensions to the core Agent Framework that fall into
|
||||
one of the following categories:
|
||||
|
||||
1. Incubation of new features that may get incorporated by the core framework.
|
||||
2. Research prototypes built on the core framework.
|
||||
3. Benchmarks and experimentation tools.
|
||||
|
||||
## Lab Modules
|
||||
|
||||
- [**gaia**](./gaia/): Evaluate your agents using the GAIA benchmark for general assistant tasks
|
||||
- [**tau2**](./tau2/): Evaluate your agents using the TAU2 benchmark for customer support tasks
|
||||
- [**lightning**](./lightning/): RL training for agents using Agent Lightning
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
agent-framework-lab/
|
||||
├── pyproject.toml # Single package configuration for agent-framework-lab
|
||||
├── README.md # This file
|
||||
├── LICENSE # License file
|
||||
├── namespace/ # Centralized namespace package files
|
||||
│ └── agent_framework/
|
||||
│ └── lab/
|
||||
│ ├── gaia/ # Re-exports from agent_framework_lab_gaia
|
||||
│ ├── lightning/ # Re-exports from agent_framework_lab_lightning
|
||||
│ └── tau2/ # Re-exports from agent_framework_lab_tau2
|
||||
├── gaia/ # GAIA module implementation
|
||||
│ └── agent_framework_lab_gaia/
|
||||
├── lightning/ # Lightning module implementation
|
||||
│ └── agent_framework_lab_lightning/
|
||||
└── tau2/ # TAU2 module implementation
|
||||
└── agent_framework_lab_tau2/
|
||||
```
|
||||
|
||||
This structure maintains a single PyPI package `agent-framework-lab` while supporting modular imports through the namespace package mechanism.
|
||||
|
||||
## Installation
|
||||
|
||||
To install each lab module, use the extras syntax with `pip`:
|
||||
|
||||
```bash
|
||||
pip install "agent-framework-lab[gaia]"
|
||||
pip install "agent-framework-lab[tau2]"
|
||||
pip install "agent-framework-lab[lightning]"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Import and use lab modules from the `agent_framework.lab` namespace.
|
||||
For example, to use the GAIA module:
|
||||
|
||||
```python
|
||||
# Using GAIA module
|
||||
from agent_framework.lab.gaia import GAIA
|
||||
```
|
||||
|
||||
## Running Tests Locally
|
||||
|
||||
For machine-safe local runs, prefer package-scoped commands first:
|
||||
|
||||
```bash
|
||||
uv run --directory packages/lab poe test
|
||||
uv run --directory packages/lab pytest -q -m "not integration"
|
||||
```
|
||||
|
||||
When you need to run lab tests from the repository root, scope the root task to the lab package:
|
||||
|
||||
```bash
|
||||
uv run poe test -P lab
|
||||
```
|
||||
|
||||
Lightning observability tests intentionally exercise heavier tracing paths and are marked as `resource_intensive`:
|
||||
|
||||
```bash
|
||||
uv run --directory packages/lab pytest lightning/tests/test_lightning.py -m "resource_intensive" -q
|
||||
```
|
||||
|
||||
## Should I consume Lab Modules?
|
||||
|
||||
If you are looking for stable and production-ready features, you should not use lab modules. Stick to the core framework.
|
||||
|
||||
If you are looking for experimentation, research, or want to
|
||||
benchmark different approaches -- most importantly, if you don't mind breaking changes and potential deprecations --
|
||||
then lab modules are for you.
|
||||
|
||||
## Contributing to Lab Modules
|
||||
|
||||
### Microsoft-maintained modules
|
||||
|
||||
For Microsoft-maintained modules in this repository, please follow standard contribution guidelines and submit pull requests directly to this repository.
|
||||
|
||||
### Community modules
|
||||
|
||||
If you want to contribute a community-maintained lab module:
|
||||
|
||||
1. Create a new repository on GitHub for your module
|
||||
2. Tag your repository with `agent-framework-lab` for discoverability
|
||||
3. Submit a PR to add a link to your repository in the [Lab Modules](#lab-modules) section above
|
||||
4. Use the PR title format: `[New Lab Module] Your Module Name`
|
||||
|
||||
We will review your submission based on the guidelines below.
|
||||
|
||||
### Guidelines
|
||||
|
||||
1. **Purpose**: Community modules should fit into one of the three categories of lab modules (incubation, research, benchmarks)
|
||||
2. **Namespace**: Community modules should avoid the `agent_framework.lab` namespace (reserved for modules maintained in this repository)
|
||||
3. **Dependencies**: Minimize external dependencies, always include `agent-framework` as a base dependency
|
||||
4. **Documentation**: Include comprehensive README with installation instructions and usage examples
|
||||
5. **Tests**: Write comprehensive tests with good coverage
|
||||
6. **Type hints**: Always include type hints and a `py.typed` file
|
||||
7. **Versioning**: Use semantic versioning, start with `0.1.0` for initial releases
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent Framework Lab - GAIA
|
||||
|
||||
The GAIA benchmark can be used for evaluating agents and workflows built using the Agent Framework.
|
||||
It includes built-in benchmarks as well as utilities for running custom evaluations.
|
||||
|
||||
> **Note**: This module is part of the consolidated `agent-framework-lab` package. Install the package with the `gaia` extra to use this module.
|
||||
|
||||
## Setup
|
||||
|
||||
Install the `agent-framework-lab` package with GAIA dependencies:
|
||||
|
||||
```bash
|
||||
pip install "agent-framework-lab[gaia]"
|
||||
```
|
||||
|
||||
Set up Hugging Face token:
|
||||
|
||||
```bash
|
||||
export HF_TOKEN="hf\*..." # must have access to gaia-benchmark/GAIA
|
||||
```
|
||||
|
||||
## Create an evaluation script
|
||||
|
||||
Create a Python script (e.g., `run_gaia.py`) with the following content:
|
||||
|
||||
```python
|
||||
from agent_framework.lab.gaia import GAIA, Task, Prediction, GAIATelemetryConfig
|
||||
|
||||
async def run_task(task: Task) -> Prediction:
|
||||
return Prediction(prediction="answer here", messages=[])
|
||||
|
||||
async def main() -> None:
|
||||
# Optional: Enable telemetry for detailed tracing
|
||||
telemetry_config = GAIATelemetryConfig(
|
||||
enable_tracing=True,
|
||||
trace_to_file=True,
|
||||
file_path="gaia_traces.jsonl"
|
||||
)
|
||||
|
||||
runner = GAIA(telemetry_config=telemetry_config)
|
||||
await runner.run(run_task, level=1, max_n=5, parallel=2)
|
||||
```
|
||||
|
||||
See the [gaia_sample.py](./samples/gaia_sample.py) for more detail.
|
||||
|
||||
## View results
|
||||
|
||||
We provide a console viewer for reading GAIA results:
|
||||
|
||||
```bash
|
||||
uv run gaia_viewer "gaia_results_<timestamp>.jsonl" --detailed
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""GAIA benchmark module for Agent Framework."""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._types import Evaluation, Evaluator, Prediction, Task, TaskResult, TaskRunner
|
||||
from .gaia import GAIA, GAIATelemetryConfig, gaia_scorer, viewer_main
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"GAIA",
|
||||
"Evaluation",
|
||||
"Evaluator",
|
||||
"GAIATelemetryConfig",
|
||||
"Prediction",
|
||||
"Task",
|
||||
"TaskResult",
|
||||
"TaskRunner",
|
||||
"gaia_scorer",
|
||||
"viewer_main",
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Common types for agent evaluation."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
__all__ = [
|
||||
"Evaluation",
|
||||
"Evaluator",
|
||||
"Prediction",
|
||||
"Task",
|
||||
"TaskResult",
|
||||
"TaskRunner",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""Represents a task to be evaluated."""
|
||||
|
||||
task_id: str
|
||||
question: str
|
||||
answer: str | None = None
|
||||
level: int | None = None
|
||||
file_name: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Prediction:
|
||||
"""Represents a prediction made by an agent for a task."""
|
||||
|
||||
prediction: str
|
||||
messages: list[Any] | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.messages is None:
|
||||
self.messages = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class Evaluation:
|
||||
"""Represents the evaluation result of a prediction."""
|
||||
|
||||
is_correct: bool
|
||||
score: float
|
||||
details: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskResult:
|
||||
"""Complete result for a single task evaluation."""
|
||||
|
||||
task_id: str
|
||||
task: Task
|
||||
prediction: Prediction
|
||||
evaluation: Evaluation
|
||||
runtime_seconds: float | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TaskRunner(Protocol):
|
||||
"""Protocol for running tasks."""
|
||||
|
||||
async def __call__(self, task: Task) -> Prediction:
|
||||
"""Run a single task and return the prediction."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Evaluator(Protocol):
|
||||
"""Protocol for evaluating predictions."""
|
||||
|
||||
async def __call__(self, task: Task, prediction: Prediction) -> Evaluation:
|
||||
"""Evaluate a prediction for a given task."""
|
||||
...
|
||||
@@ -0,0 +1,712 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""GAIA benchmark implementation for Agent Framework."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
from opentelemetry.trace import NoOpTracer, SpanKind, get_tracer
|
||||
from tqdm import tqdm
|
||||
|
||||
from ._types import Evaluation, Evaluator, Prediction, Task, TaskResult, TaskRunner
|
||||
|
||||
__all__ = ["GAIA", "GAIATelemetryConfig", "gaia_scorer"]
|
||||
|
||||
|
||||
class _OrjsonModule(Protocol):
|
||||
def dumps(self, obj: object, /, default: Callable[[Any], object] | None = None) -> bytes: ...
|
||||
|
||||
def loads(self, obj: str | bytes | bytearray, /) -> object: ...
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_orjson() -> _OrjsonModule | None:
|
||||
try:
|
||||
import orjson as runtime_orjson
|
||||
except ImportError:
|
||||
return None
|
||||
return cast(_OrjsonModule, runtime_orjson)
|
||||
|
||||
|
||||
def _dump_json_line(value: object) -> str:
|
||||
if (runtime_orjson := _get_orjson()) is not None:
|
||||
return runtime_orjson.dumps(value, default=str).decode("utf-8")
|
||||
return json.dumps(value, default=str)
|
||||
|
||||
|
||||
def _load_json_value(value: str | bytes) -> object:
|
||||
if (runtime_orjson := _get_orjson()) is not None:
|
||||
return runtime_orjson.loads(value)
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
class GAIATelemetryConfig:
|
||||
"""Configuration for GAIA telemetry and tracing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
enable_tracing: bool = False,
|
||||
otlp_endpoint: str | None = None,
|
||||
trace_to_file: bool = False,
|
||||
file_path: str | None = None,
|
||||
):
|
||||
"""Initialize telemetry configuration.
|
||||
|
||||
Args:
|
||||
enable_tracing: Whether to enable OpenTelemetry tracing
|
||||
otlp_endpoint: OTLP endpoint for trace export
|
||||
trace_to_file: Whether to export traces to local file
|
||||
file_path: Path for local file export (defaults to gaia_traces.json)
|
||||
|
||||
Note:
|
||||
For Azure Monitor integration, configure using environment variables
|
||||
(OTEL_EXPORTER_OTLP_ENDPOINT, etc.) or call ``configure_azure_monitor()``
|
||||
before creating the GAIA instance.
|
||||
"""
|
||||
self.enable_tracing = enable_tracing
|
||||
self.otlp_endpoint = otlp_endpoint
|
||||
self.trace_to_file = trace_to_file
|
||||
self.file_path = file_path or "gaia_traces.json"
|
||||
|
||||
def configure_otel_providers(self) -> None:
|
||||
"""Set up OpenTelemetry based on configuration."""
|
||||
if not self.enable_tracing:
|
||||
return
|
||||
|
||||
# If only file tracing is requested (no OTLP),
|
||||
# skip the default configure_otel_providers which adds console exporter
|
||||
if self.trace_to_file and not self.otlp_endpoint:
|
||||
# Set up minimal tracing with only file export
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.trace import set_tracer_provider
|
||||
|
||||
tracer_provider = TracerProvider()
|
||||
set_tracer_provider(tracer_provider)
|
||||
self._setup_file_export()
|
||||
else:
|
||||
# Use full observability setup for OTLP
|
||||
from agent_framework.observability import configure_otel_providers
|
||||
|
||||
# Set OTLP endpoint env var if provided
|
||||
if self.otlp_endpoint:
|
||||
import os
|
||||
|
||||
os.environ.setdefault("OTEL_EXPORTER_OTLP_ENDPOINT", self.otlp_endpoint)
|
||||
|
||||
configure_otel_providers(
|
||||
enable_sensitive_data=True, # Enable for detailed task traces
|
||||
)
|
||||
|
||||
# Set up local file export if requested
|
||||
if self.trace_to_file:
|
||||
self._setup_file_export()
|
||||
|
||||
def _setup_file_export(self) -> None:
|
||||
"""Set up local file export for traces."""
|
||||
try:
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
|
||||
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult
|
||||
from opentelemetry.trace import get_tracer_provider
|
||||
|
||||
class FileSpanExporter(SpanExporter):
|
||||
def __init__(self, file_path: str):
|
||||
self.file_path = file_path
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(os.path.abspath(file_path)), exist_ok=True)
|
||||
|
||||
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
||||
try:
|
||||
with open(self.file_path, "a", encoding="utf-8") as f:
|
||||
for span in spans:
|
||||
span_data = {
|
||||
"trace_id": format(span.context.trace_id, "032x") if span.context else "unknown",
|
||||
"span_id": format(span.context.span_id, "016x") if span.context else "unknown",
|
||||
"name": span.name,
|
||||
"start_time": span.start_time,
|
||||
"end_time": span.end_time,
|
||||
"duration_ns": (span.end_time - span.start_time)
|
||||
if (span.end_time and span.start_time)
|
||||
else None,
|
||||
"attributes": dict(span.attributes) if span.attributes else {},
|
||||
"status": {
|
||||
"status_code": span.status.status_code.name if span.status else "UNSET",
|
||||
"description": span.status.description if span.status else None,
|
||||
},
|
||||
}
|
||||
f.write(json.dumps(span_data, default=str) + "\n")
|
||||
return SpanExportResult.SUCCESS
|
||||
except Exception:
|
||||
return SpanExportResult.FAILURE
|
||||
|
||||
def shutdown(self) -> None:
|
||||
pass
|
||||
|
||||
tracer_provider = get_tracer_provider()
|
||||
if isinstance(tracer_provider, TracerProvider):
|
||||
file_exporter = FileSpanExporter(self.file_path)
|
||||
tracer_provider.add_span_processor(BatchSpanProcessor(file_exporter))
|
||||
|
||||
except ImportError:
|
||||
print("Warning: Could not set up file export for traces. Missing dependencies.")
|
||||
|
||||
|
||||
def _normalize_number_str(number_str: str) -> float:
|
||||
"""Normalize a number string for comparison."""
|
||||
for ch in ["$", "%", ","]:
|
||||
number_str = number_str.replace(ch, "")
|
||||
try:
|
||||
return float(number_str)
|
||||
except ValueError:
|
||||
return float("inf")
|
||||
|
||||
|
||||
def _split_string(s: str, chars: list[str] | None = None) -> list[str]:
|
||||
"""Split string by multiple delimiters."""
|
||||
if chars is None:
|
||||
chars = [",", ";"]
|
||||
return re.split(f"[{''.join(chars)}]", s)
|
||||
|
||||
|
||||
def _normalize_str(s: str, remove_punct: bool = True) -> str:
|
||||
"""Normalize string for comparison."""
|
||||
no_spaces = re.sub(r"\s", "", s or "")
|
||||
if remove_punct:
|
||||
table = str.maketrans("", "", string.punctuation)
|
||||
return no_spaces.lower().translate(table)
|
||||
return no_spaces.lower()
|
||||
|
||||
|
||||
def gaia_scorer(model_answer: str | None, ground_truth: str) -> bool:
|
||||
"""Official GAIA scoring function.
|
||||
|
||||
Args:
|
||||
model_answer: The model's answer
|
||||
ground_truth: The ground truth answer
|
||||
|
||||
Returns:
|
||||
True if the answer is correct, False otherwise
|
||||
"""
|
||||
|
||||
def is_float(x: Any) -> bool:
|
||||
try:
|
||||
float(x)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if model_answer is None:
|
||||
model_answer = "None"
|
||||
|
||||
if is_float(ground_truth):
|
||||
# numeric exact match after normalization
|
||||
return abs(_normalize_number_str(model_answer) - float(ground_truth)) < 1e-6
|
||||
if any(ch in ground_truth for ch in [",", ";"]):
|
||||
# list with per-element compare (number or string)
|
||||
gt_elems = _split_string(ground_truth)
|
||||
ma_elems = _split_string(model_answer)
|
||||
if len(gt_elems) != len(ma_elems):
|
||||
return False
|
||||
comparisons: list[bool] = []
|
||||
for ma, gt in zip(ma_elems, gt_elems, strict=False):
|
||||
if is_float(gt):
|
||||
comparisons.append(abs(_normalize_number_str(ma) - float(gt)) < 1e-6)
|
||||
else:
|
||||
comparisons.append(_normalize_str(ma, remove_punct=False) == _normalize_str(gt, remove_punct=False))
|
||||
return all(comparisons)
|
||||
# string normalize + exact
|
||||
return _normalize_str(model_answer) == _normalize_str(ground_truth)
|
||||
|
||||
|
||||
def _coerce_record(raw: object) -> dict[str, Any] | None:
|
||||
if isinstance(raw, dict):
|
||||
raw_dict = cast(dict[object, Any], raw)
|
||||
if all(isinstance(key, str) for key in raw_dict):
|
||||
return cast(dict[str, Any], raw_dict)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_level(level: object) -> int | None:
|
||||
if isinstance(level, int):
|
||||
return level
|
||||
if isinstance(level, str) and level.isdigit():
|
||||
return int(level)
|
||||
return None
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> Iterable[dict[str, Any]]:
|
||||
"""Read JSONL file and yield parsed records."""
|
||||
with path.open("rb") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
parsed = _load_json_value(line)
|
||||
|
||||
record = _coerce_record(parsed)
|
||||
if record is not None:
|
||||
yield record
|
||||
|
||||
|
||||
def _load_gaia_local(repo_dir: Path, wanted_levels: list[int] | None = None, max_n: int | None = None) -> list[Task]:
|
||||
"""Load GAIA tasks from local repository directory."""
|
||||
tasks: list[Task] = []
|
||||
|
||||
# First try to load from parquet files (new format)
|
||||
# Prioritize validation split over test split (validation has answers)
|
||||
parquet_files = sorted(
|
||||
repo_dir.rglob("metadata*.parquet"), key=lambda p: (0 if "validation" in str(p) else 1, str(p))
|
||||
)
|
||||
|
||||
for p in parquet_files:
|
||||
try:
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
pq_any = cast(Any, pq)
|
||||
table: Any = pq_any.read_table(p)
|
||||
rows = cast(list[object], table.to_pylist())
|
||||
for row in rows:
|
||||
record = _coerce_record(row)
|
||||
if record is None:
|
||||
continue
|
||||
|
||||
# Robustly extract fields used across variants
|
||||
q_obj = record.get("Question") or record.get("question") or record.get("query") or record.get("prompt")
|
||||
ans = record.get("Final answer") or record.get("answer") or record.get("final_answer")
|
||||
if not isinstance(q_obj, str):
|
||||
continue
|
||||
q = q_obj
|
||||
|
||||
qid = str(
|
||||
record.get("task_id")
|
||||
or record.get("question_id")
|
||||
or record.get("id")
|
||||
or record.get("uuid")
|
||||
or f"{p.stem}:{len(tasks)}"
|
||||
)
|
||||
lvl = _parse_level(record.get("Level") or record.get("level"))
|
||||
fname_obj = record.get("file_name") or record.get("filename")
|
||||
fname = fname_obj if isinstance(fname_obj, str) else None
|
||||
|
||||
# Only evaluate examples with public answers (dev/validation split)
|
||||
# Skip if no question, no answer, or answer is placeholder like "?"
|
||||
if ans is None or str(ans).strip() in ["?", ""]:
|
||||
continue
|
||||
|
||||
if wanted_levels and (lvl not in wanted_levels):
|
||||
continue
|
||||
|
||||
tasks.append(
|
||||
Task(task_id=qid, question=q, answer=str(ans), level=lvl, file_name=fname, metadata=record)
|
||||
)
|
||||
except ImportError:
|
||||
print("Warning: pyarrow not installed. Install with: pip install pyarrow")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load parquet file {p}: {e}")
|
||||
continue
|
||||
|
||||
# Fall back to jsonl files (old format) if no parquet files found
|
||||
if not tasks:
|
||||
for p in repo_dir.rglob("metadata.jsonl"):
|
||||
for rec in _read_jsonl(p):
|
||||
# Robustly extract fields used across variants
|
||||
q_obj = rec.get("Question") or rec.get("question") or rec.get("query") or rec.get("prompt")
|
||||
ans = rec.get("Final answer") or rec.get("answer") or rec.get("final_answer")
|
||||
if not isinstance(q_obj, str):
|
||||
continue
|
||||
q = q_obj
|
||||
|
||||
qid = str(
|
||||
rec.get("task_id")
|
||||
or rec.get("question_id")
|
||||
or rec.get("id")
|
||||
or rec.get("uuid")
|
||||
or f"{p.stem}:{len(tasks)}"
|
||||
)
|
||||
lvl = _parse_level(rec.get("Level") or rec.get("level"))
|
||||
fname_obj = rec.get("file_name") or rec.get("filename")
|
||||
fname = fname_obj if isinstance(fname_obj, str) else None
|
||||
|
||||
# Only evaluate examples with public answers (dev/validation split)
|
||||
# Skip if no question, no answer, or answer is placeholder like "?"
|
||||
if ans is None or str(ans).strip() in ["?", ""]:
|
||||
continue
|
||||
|
||||
if wanted_levels and (lvl not in wanted_levels):
|
||||
continue
|
||||
|
||||
tasks.append(Task(task_id=qid, question=q, answer=str(ans), level=lvl, file_name=fname, metadata=rec))
|
||||
|
||||
# Shuffle to help with rate-limits and fairness if max_n is provided
|
||||
random.shuffle(tasks)
|
||||
if max_n:
|
||||
tasks = tasks[:max_n]
|
||||
return tasks
|
||||
|
||||
|
||||
class GAIA:
|
||||
"""GAIA benchmark runner for Agent Framework.
|
||||
|
||||
GAIA (General AI Assistant) is a benchmark for general-purpose AI assistants.
|
||||
This class provides utilities to run the benchmark with custom agents.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
evaluator: Evaluator | None = None,
|
||||
data_dir: str | None = None,
|
||||
hf_token: str | None = None,
|
||||
telemetry_config: GAIATelemetryConfig | None = None,
|
||||
):
|
||||
"""Initialize GAIA benchmark runner.
|
||||
|
||||
Args:
|
||||
evaluator: Custom evaluator function. If None, uses default GAIA scorer.
|
||||
data_dir: Directory to cache GAIA data. Defaults to a temporary directory.
|
||||
hf_token: Hugging Face token for accessing the GAIA dataset.
|
||||
telemetry_config: Configuration for telemetry and tracing. If None, no tracing is performed.
|
||||
"""
|
||||
self.evaluator = evaluator or self._default_evaluator
|
||||
self.data_dir = Path(data_dir or Path(tempfile.gettempdir()) / "data_gaia_hub")
|
||||
self.hf_token = hf_token
|
||||
self.telemetry_config = telemetry_config or GAIATelemetryConfig()
|
||||
|
||||
# Set up telemetry
|
||||
self.telemetry_config.configure_otel_providers()
|
||||
|
||||
# Initialize tracer
|
||||
if self.telemetry_config.enable_tracing:
|
||||
self.tracer = get_tracer("gaia_benchmark", "1.0.0")
|
||||
else:
|
||||
self.tracer = NoOpTracer()
|
||||
|
||||
async def _default_evaluator(self, task: Task, prediction: Prediction) -> Evaluation:
|
||||
"""Default evaluator using GAIA official scoring."""
|
||||
is_correct = gaia_scorer(prediction.prediction, task.answer or "")
|
||||
return Evaluation(is_correct=is_correct, score=1.0 if is_correct else 0.0)
|
||||
|
||||
def _ensure_data(self) -> Path:
|
||||
"""Ensure GAIA data is available locally."""
|
||||
if self.data_dir.exists() and any(self.data_dir.rglob("metadata.jsonl")):
|
||||
return self.data_dir
|
||||
|
||||
# Download data if not available
|
||||
token = self.hf_token or os.environ.get("HF_TOKEN")
|
||||
if not token:
|
||||
raise RuntimeError(
|
||||
"HF_TOKEN environment variable or hf_token parameter is required "
|
||||
"to access the GAIA dataset. Please set your Hugging Face token "
|
||||
"with access to gaia-benchmark/GAIA."
|
||||
)
|
||||
|
||||
import huggingface_hub
|
||||
|
||||
hf_hub = cast(Any, huggingface_hub)
|
||||
local_dir = hf_hub.snapshot_download(
|
||||
repo_id="gaia-benchmark/GAIA",
|
||||
repo_type="dataset",
|
||||
revision="682dd723ee1e1697e00360edccf2366dc8418dd9",
|
||||
token=token,
|
||||
local_dir=str(self.data_dir),
|
||||
force_download=False,
|
||||
)
|
||||
if not isinstance(local_dir, str):
|
||||
raise TypeError("snapshot_download returned unexpected non-string path")
|
||||
return Path(local_dir)
|
||||
|
||||
async def _run_single_task(
|
||||
self, task: Task, task_runner: TaskRunner, semaphore: asyncio.Semaphore, timeout: int | None = None
|
||||
) -> TaskResult:
|
||||
"""Run a single task with error handling and timing."""
|
||||
async with semaphore:
|
||||
with self.tracer.start_as_current_span(
|
||||
"gaia.task.run",
|
||||
kind=SpanKind.INTERNAL,
|
||||
attributes={
|
||||
"gaia.task.id": task.task_id,
|
||||
"gaia.task.level": task.level or 0,
|
||||
"gaia.task.has_file": task.file_name is not None,
|
||||
"gaia.task.timeout": timeout or 0,
|
||||
},
|
||||
) as span:
|
||||
start_time = time.time()
|
||||
try:
|
||||
# Add task execution span
|
||||
with self.tracer.start_as_current_span(
|
||||
"gaia.task.execute",
|
||||
kind=SpanKind.INTERNAL,
|
||||
attributes={
|
||||
"gaia.task.question_length": len(task.question or ""),
|
||||
"gaia.task.file_name": task.file_name or "",
|
||||
},
|
||||
):
|
||||
if timeout:
|
||||
prediction = await asyncio.wait_for(task_runner(task), timeout=timeout)
|
||||
else:
|
||||
prediction = await task_runner(task)
|
||||
|
||||
# Add evaluation span
|
||||
with self.tracer.start_as_current_span("gaia.task.evaluate", kind=SpanKind.INTERNAL):
|
||||
evaluation = await self.evaluator(task, prediction)
|
||||
|
||||
runtime_seconds = time.time() - start_time
|
||||
|
||||
# Add results to span
|
||||
if span:
|
||||
span.set_attributes({
|
||||
"gaia.task.runtime_seconds": runtime_seconds,
|
||||
"gaia.task.is_correct": evaluation.is_correct,
|
||||
"gaia.task.score": evaluation.score,
|
||||
"gaia.task.prediction_length": len(prediction.prediction or ""),
|
||||
})
|
||||
|
||||
return TaskResult(
|
||||
task_id=task.task_id,
|
||||
task=task,
|
||||
prediction=prediction,
|
||||
evaluation=evaluation,
|
||||
runtime_seconds=runtime_seconds,
|
||||
)
|
||||
except Exception as e:
|
||||
runtime_seconds = time.time() - start_time
|
||||
|
||||
# Record error in span
|
||||
if span:
|
||||
span.set_attributes({
|
||||
"gaia.task.runtime_seconds": runtime_seconds,
|
||||
"gaia.task.error": str(e),
|
||||
"gaia.task.is_correct": False,
|
||||
"gaia.task.score": 0.0,
|
||||
})
|
||||
span.record_exception(e)
|
||||
|
||||
return TaskResult(
|
||||
task_id=task.task_id,
|
||||
task=task,
|
||||
prediction=Prediction(prediction="", messages=[]),
|
||||
evaluation=Evaluation(is_correct=False, score=0.0),
|
||||
runtime_seconds=runtime_seconds,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
task_runner: TaskRunner,
|
||||
level: int | list[int] = 1,
|
||||
max_n: int | None = None,
|
||||
parallel: int = 1,
|
||||
timeout: int | None = None,
|
||||
out: str | None = None,
|
||||
) -> list[TaskResult]:
|
||||
"""Run the GAIA benchmark.
|
||||
|
||||
Args:
|
||||
task_runner: Function that takes a Task and returns a Prediction
|
||||
level: GAIA level(s) to run (1, 2, 3, or list of levels)
|
||||
max_n: Maximum number of tasks to run per level
|
||||
parallel: Number of parallel tasks to run
|
||||
timeout: Timeout per task in seconds
|
||||
out: Output file to save results including detailed traces (optional)
|
||||
|
||||
Returns:
|
||||
List of TaskResult objects
|
||||
"""
|
||||
with self.tracer.start_as_current_span(
|
||||
"gaia.benchmark.run",
|
||||
kind=SpanKind.INTERNAL,
|
||||
attributes={
|
||||
"gaia.benchmark.levels": str(level),
|
||||
"gaia.benchmark.max_n": max_n or 0,
|
||||
"gaia.benchmark.parallel": parallel,
|
||||
"gaia.benchmark.timeout": timeout or 0,
|
||||
},
|
||||
) as benchmark_span:
|
||||
# Ensure data is available
|
||||
with self.tracer.start_as_current_span("gaia.data.ensure", kind=SpanKind.INTERNAL):
|
||||
data_path = self._ensure_data()
|
||||
|
||||
# Parse level parameter
|
||||
levels = [level] if isinstance(level, int) else level
|
||||
|
||||
# Load tasks
|
||||
with self.tracer.start_as_current_span(
|
||||
"gaia.tasks.load",
|
||||
kind=SpanKind.INTERNAL,
|
||||
attributes={
|
||||
"gaia.tasks.levels": str(levels),
|
||||
"gaia.tasks.max_n": max_n or 0,
|
||||
},
|
||||
) as load_span:
|
||||
tasks = _load_gaia_local(data_path, wanted_levels=levels, max_n=max_n)
|
||||
|
||||
if load_span:
|
||||
load_span.set_attributes({
|
||||
"gaia.tasks.loaded_count": len(tasks),
|
||||
})
|
||||
|
||||
if not tasks:
|
||||
raise RuntimeError(
|
||||
f"No GAIA tasks found for levels {levels}. "
|
||||
"Make sure you have dataset access and selected valid levels."
|
||||
)
|
||||
|
||||
# Update benchmark span with task info
|
||||
if benchmark_span:
|
||||
benchmark_span.set_attributes({
|
||||
"gaia.benchmark.total_tasks": len(tasks),
|
||||
})
|
||||
|
||||
# Run tasks
|
||||
semaphore = asyncio.Semaphore(parallel)
|
||||
results: list[TaskResult] = []
|
||||
|
||||
tasks_coroutines = [self._run_single_task(task, task_runner, semaphore, timeout) for task in tasks]
|
||||
|
||||
with self.tracer.start_as_current_span("gaia.tasks.execute_all", kind=SpanKind.INTERNAL):
|
||||
for coro in tqdm(
|
||||
asyncio.as_completed(tasks_coroutines), total=len(tasks_coroutines), desc="Evaluating tasks"
|
||||
):
|
||||
result = await coro
|
||||
results.append(result)
|
||||
|
||||
# Calculate summary statistics
|
||||
correct = sum(1 for r in results if r.evaluation.is_correct)
|
||||
accuracy = correct / len(results) if results else 0.0
|
||||
avg_runtime = sum(r.runtime_seconds or 0 for r in results) / len(results) if results else 0.0
|
||||
|
||||
# Update benchmark span with final results
|
||||
if benchmark_span:
|
||||
benchmark_span.set_attributes({
|
||||
"gaia.benchmark.accuracy": accuracy,
|
||||
"gaia.benchmark.correct_count": correct,
|
||||
"gaia.benchmark.total_count": len(results),
|
||||
"gaia.benchmark.avg_runtime_seconds": avg_runtime,
|
||||
})
|
||||
|
||||
# Save results if requested
|
||||
if out:
|
||||
with self.tracer.start_as_current_span(
|
||||
"gaia.results.save", kind=SpanKind.INTERNAL, attributes={"gaia.results.output_file": out}
|
||||
):
|
||||
self._save_results(results, out)
|
||||
|
||||
return results
|
||||
|
||||
def _save_results(self, results: list[TaskResult], output_path: str) -> None:
|
||||
"""Save results with detailed trace information to JSONL file."""
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
for result in results:
|
||||
# Convert messages to serializable format
|
||||
serializable_messages: list[dict[str, Any] | str] = []
|
||||
if result.prediction.messages:
|
||||
for msg in result.prediction.messages:
|
||||
if hasattr(msg, "model_dump"):
|
||||
# Pydantic model
|
||||
serializable_messages.append(msg.model_dump())
|
||||
elif hasattr(msg, "__dict__"):
|
||||
# Regular object with attributes
|
||||
serializable_messages.append(cast(dict[str, Any], getattr(msg, "__dict__", {})))
|
||||
else:
|
||||
# Fallback to string representation
|
||||
serializable_messages.append(str(msg))
|
||||
|
||||
record = {
|
||||
"task_id": result.task_id,
|
||||
"level": result.task.level,
|
||||
"question": result.task.question,
|
||||
"answer": result.task.answer,
|
||||
"prediction": result.prediction.prediction,
|
||||
"is_correct": result.evaluation.is_correct,
|
||||
"score": result.evaluation.score,
|
||||
"runtime_seconds": result.runtime_seconds,
|
||||
"error": result.error,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
# Include detailed trace information
|
||||
"task_metadata": result.task.metadata,
|
||||
"file_name": result.task.file_name,
|
||||
"messages": serializable_messages,
|
||||
"prediction_metadata": result.prediction.metadata,
|
||||
"evaluation_details": result.evaluation.details,
|
||||
}
|
||||
f.write(_dump_json_line(record) + "\n")
|
||||
|
||||
|
||||
def viewer_main() -> None:
|
||||
"""Main function for the gaia_viewer script."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="View GAIA benchmark results")
|
||||
parser.add_argument("results_file", help="Path to results JSONL file")
|
||||
parser.add_argument("--detailed", action="store_true", help="Show detailed view")
|
||||
parser.add_argument("--level", type=int, help="Filter by level")
|
||||
parser.add_argument("--correct-only", action="store_true", help="Show only correct answers")
|
||||
parser.add_argument("--incorrect-only", action="store_true", help="Show only incorrect answers")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load results
|
||||
results: list[dict[str, Any]] = []
|
||||
with open(args.results_file, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
parsed = _load_json_value(line)
|
||||
record = _coerce_record(parsed)
|
||||
if record is not None:
|
||||
results.append(record)
|
||||
|
||||
# Apply filters
|
||||
if args.level is not None:
|
||||
results = [r for r in results if r.get("level") == args.level]
|
||||
|
||||
if args.correct_only:
|
||||
results = [r for r in results if r.get("is_correct")]
|
||||
elif args.incorrect_only:
|
||||
results = [r for r in results if not r.get("is_correct")]
|
||||
|
||||
# Display results
|
||||
if not results:
|
||||
print("No results match the filters.")
|
||||
return
|
||||
|
||||
total = len(results)
|
||||
correct = sum(1 for r in results if r.get("is_correct"))
|
||||
accuracy = correct / total if total > 0 else 0.0
|
||||
|
||||
print("GAIA Results Summary:")
|
||||
print(f"Total: {total}, Correct: {correct}, Accuracy: {accuracy:.3f}")
|
||||
print("-" * 80)
|
||||
|
||||
for i, result in enumerate(results, 1):
|
||||
status = "✓" if result.get("is_correct") else "✗"
|
||||
level = result.get("level", "?")
|
||||
task_id = result.get("task_id", "unknown")
|
||||
|
||||
print(f"[{i}/{total}] {status} Level {level} - {task_id}")
|
||||
|
||||
if args.detailed:
|
||||
print(f"Question: {result.get('question', 'N/A')[:100]}...")
|
||||
print(f"Answer: {result.get('answer', 'N/A')}")
|
||||
print(f"Prediction: {result.get('prediction', 'N/A')}")
|
||||
if result.get("error"):
|
||||
print(f"Error: {result.get('error')}")
|
||||
if result.get("runtime_seconds"):
|
||||
print(f"Runtime: {result.get('runtime_seconds'):.2f}s")
|
||||
print("-" * 40)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
viewer_main()
|
||||
@@ -0,0 +1 @@
|
||||
py.typed
|
||||
@@ -0,0 +1,67 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Azure AI Agent factory for GAIA benchmark.
|
||||
|
||||
This module provides a factory function to create an Azure AI agent
|
||||
configured for GAIA benchmark tasks.
|
||||
|
||||
Required Environment Variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT: Azure AI project endpoint URL
|
||||
FOUNDRY_MODEL: Name of the model deployment to use
|
||||
|
||||
Optional Environment Variables:
|
||||
BING_CONNECTION_ID: ID of the Bing connection for web search
|
||||
|
||||
Authentication:
|
||||
Uses Azure CLI credentials via AzureCliCredential.
|
||||
Run `az login` before executing to authenticate.
|
||||
|
||||
Example:
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.azure.com"
|
||||
export FOUNDRY_MODEL="gpt-4o"
|
||||
export BING_CONNECTION_ID="connection-id"
|
||||
az login
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_gaia_agent() -> AsyncIterator[Agent]:
|
||||
"""Create an Azure AI agent configured for GAIA benchmark tasks.
|
||||
|
||||
The agent is configured with:
|
||||
- Bing Search tool for web information retrieval
|
||||
- Code Interpreter tool for calculations and data analysis
|
||||
|
||||
Yields:
|
||||
Agent: A configured agent ready to run GAIA tasks.
|
||||
|
||||
Example:
|
||||
async with create_gaia_agent() as agent:
|
||||
result = await agent.run("What is the capital of France?")
|
||||
print(result.text)
|
||||
"""
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=credential,
|
||||
).as_agent(
|
||||
name="GaiaAgent",
|
||||
instructions="Solve tasks to your best ability. Use Bing Search to find "
|
||||
"information and Code Interpreter to perform calculations and data analysis.",
|
||||
tools=[
|
||||
FoundryChatClient.get_web_search_tool(),
|
||||
FoundryChatClient.get_code_interpreter_tool(),
|
||||
],
|
||||
) as agent,
|
||||
):
|
||||
yield agent
|
||||
@@ -0,0 +1,295 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""GAIA Benchmark Sample.
|
||||
|
||||
Run the GAIA (General AI Assistant) benchmark with configurable agent providers,
|
||||
telemetry options, and benchmark parameters.
|
||||
|
||||
Agent Providers:
|
||||
- Azure AI (default): See azure_ai_agent.py for required environment variables
|
||||
- OpenAI: See openai_agent.py for required environment variables
|
||||
|
||||
Prerequisites:
|
||||
1. Set HF_TOKEN environment variable with your Hugging Face token:
|
||||
- Get token: https://huggingface.co/settings/tokens
|
||||
- Request dataset access: https://huggingface.co/datasets/gaia-benchmark/GAIA
|
||||
- Set: export HF_TOKEN="your-huggingface-token"
|
||||
|
||||
2. Configure your chosen agent provider (see agent module files for details)
|
||||
|
||||
Telemetry:
|
||||
When using --otlp-endpoint or --trace-file, OpenTelemetry will export trace data
|
||||
in JSON format to the console in addition to the configured endpoints. This is
|
||||
expected behavior from the OpenTelemetry SDK and provides visibility into the
|
||||
telemetry being captured. The traces are also exported to:
|
||||
- OTLP endpoint (e.g., Aspire Dashboard) if --otlp-endpoint is specified
|
||||
- Local file if --trace-file is specified
|
||||
|
||||
To suppress console output, redirect stderr: `python gaia_sample.py 2>/dev/null`
|
||||
|
||||
Usage:
|
||||
# Run with default settings (Azure AI agent)
|
||||
uv run python gaia_sample.py
|
||||
|
||||
# Run with OpenAI agent
|
||||
uv run python gaia_sample.py --agent-provider openai
|
||||
|
||||
# Run with telemetry export to Aspire Dashboard
|
||||
uv run python gaia_sample.py --otlp-endpoint http://localhost:4318
|
||||
|
||||
# See all options
|
||||
uv run python gaia_sample.py --help
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
from agent_framework.lab.gaia import GAIA, Evaluation, GAIATelemetryConfig, Prediction, Task
|
||||
|
||||
|
||||
async def evaluate_task(task: Task, prediction: Prediction) -> Evaluation:
|
||||
"""Evaluate the prediction for a given task."""
|
||||
# Simple evaluation: check if the prediction contains the answer
|
||||
is_correct = (task.answer or "").lower() in prediction.prediction.lower()
|
||||
return Evaluation(is_correct=is_correct, score=1 if is_correct else 0)
|
||||
|
||||
|
||||
async def main(
|
||||
otlp_endpoint: str | None = None,
|
||||
trace_file: str | None = None,
|
||||
result_file: str | None = None,
|
||||
data_dir: str | None = None,
|
||||
agent_provider: str = "azure-ai",
|
||||
level: int | list[int] = 1,
|
||||
max_n: int = 2,
|
||||
parallel: int = 1,
|
||||
timeout: int = 120,
|
||||
) -> None:
|
||||
"""Run GAIA benchmark with telemetry configuration.
|
||||
|
||||
Args:
|
||||
otlp_endpoint: Optional OTLP endpoint URL for exporting traces (e.g., http://localhost:4318)
|
||||
trace_file: Optional file path to export traces to. If None, traces won't be saved to file.
|
||||
result_file: Optional file path to save benchmark results. If None, results won't be saved to file.
|
||||
data_dir: Directory to cache GAIA dataset. If None, uses temp directory.
|
||||
agent_provider: Agent provider to use: 'azure-ai' or 'openai' (default: 'azure-ai')
|
||||
level: GAIA level(s) to run (1, 2, or 3)
|
||||
max_n: Maximum number of tasks to run per level
|
||||
parallel: Number of parallel tasks to run
|
||||
timeout: Timeout per task in seconds
|
||||
"""
|
||||
# Check for required Hugging Face token
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Suppress console logging for traces and verbose SDK output
|
||||
logging.getLogger("opentelemetry").setLevel(logging.ERROR)
|
||||
logging.getLogger("azure").setLevel(logging.WARNING)
|
||||
logging.getLogger("agent_framework").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
# Suppress OpenTelemetry exporters console output
|
||||
import os as _os
|
||||
|
||||
_os.environ.setdefault("OTEL_PYTHON_LOG_LEVEL", "error")
|
||||
|
||||
# Print trace export configuration
|
||||
print("\n=== Telemetry Configuration ===")
|
||||
if trace_file:
|
||||
print(f"📁 Trace file: {os.path.abspath(trace_file)}")
|
||||
else:
|
||||
print("📁 Trace file: disabled")
|
||||
|
||||
if otlp_endpoint:
|
||||
print(f"🌐 OTLP endpoint: {otlp_endpoint}")
|
||||
else:
|
||||
print("🌐 OTLP endpoint: disabled")
|
||||
|
||||
if result_file:
|
||||
print(f"📊 Results file: {os.path.abspath(result_file)}")
|
||||
else:
|
||||
print("📊 Results file: disabled")
|
||||
|
||||
print("\n=== Run Configuration ===")
|
||||
print(f"🤖 Agent provider: {agent_provider}")
|
||||
if data_dir:
|
||||
print(f"📂 Data directory: {os.path.abspath(data_dir)}")
|
||||
else:
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
default_data_dir = Path(tempfile.gettempdir()) / "data_gaia_hub"
|
||||
print(f"📂 Data directory: {default_data_dir} (default)")
|
||||
print(f"🎯 Level: {level}")
|
||||
print(f"🔢 Max tasks: {max_n}")
|
||||
print(f"⚡ Parallel: {parallel}")
|
||||
print(f"⏱️ Timeout: {timeout}s")
|
||||
print()
|
||||
|
||||
# Import the appropriate agent factory based on provider
|
||||
if agent_provider == "azure-ai":
|
||||
from azure_ai_agent import create_gaia_agent
|
||||
elif agent_provider == "openai":
|
||||
from openai_agent import create_gaia_agent
|
||||
else:
|
||||
raise ValueError(f"Unknown agent provider: {agent_provider}. Use 'azure-ai' or 'openai'.")
|
||||
|
||||
# Configure telemetry for tracing
|
||||
telemetry_config = GAIATelemetryConfig(
|
||||
enable_tracing=True, # Enable OpenTelemetry tracing
|
||||
trace_to_file=trace_file is not None, # Export traces to local file only if path provided
|
||||
file_path=trace_file, # Custom file path for traces (can be None)
|
||||
otlp_endpoint=otlp_endpoint, # Optional OTLP endpoint for Aspire Dashboard or other collectors
|
||||
)
|
||||
|
||||
# Create a single agent once and reuse it for all tasks
|
||||
async with create_gaia_agent() as agent:
|
||||
|
||||
async def run_task(task: Task) -> Prediction:
|
||||
"""Run a single GAIA task and return the prediction using the shared agent."""
|
||||
input_message = f"Task: {task.question}"
|
||||
if task.file_name:
|
||||
input_message += f"\nFile: {task.file_name}"
|
||||
result = await agent.run(input_message)
|
||||
return Prediction(prediction=result.text, messages=result.messages)
|
||||
|
||||
# Create the GAIA benchmark runner with telemetry configuration
|
||||
runner = GAIA(
|
||||
evaluator=evaluate_task,
|
||||
telemetry_config=telemetry_config,
|
||||
data_dir=data_dir,
|
||||
)
|
||||
|
||||
# Run the benchmark with the task runner.
|
||||
# By default, this will check for locally cached benchmark data and checkout
|
||||
# the latest version from HuggingFace if not found.
|
||||
# Note: The GAIA dataset has been updated to use Parquet format.
|
||||
# If you encounter issues, try using validation split which has labeled data.
|
||||
results = await runner.run(
|
||||
run_task,
|
||||
level=level,
|
||||
max_n=max_n,
|
||||
parallel=parallel,
|
||||
timeout=timeout,
|
||||
out=result_file, # Output file to save results including detailed traces (optional, None = no file output)
|
||||
)
|
||||
|
||||
# Print summary similar to the viewer in gaia.py
|
||||
total = len(results)
|
||||
correct = sum(1 for r in results if r.evaluation.is_correct)
|
||||
accuracy = correct / total if total > 0 else 0.0
|
||||
avg_runtime = sum(r.runtime_seconds or 0 for r in results) / total if total > 0 else 0.0
|
||||
|
||||
print("\n=== GAIA Benchmark Summary ===")
|
||||
print(f"📝 Total: {total}, ✅ Correct: {correct}, 🎯 Accuracy: {accuracy:.3f}")
|
||||
print(f"⏱️ Average runtime: {avg_runtime:.2f}s")
|
||||
if result_file:
|
||||
print(f"💾 Detailed results saved to: {result_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
# Parse command line arguments
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run GAIA benchmark with optional telemetry export to OTLP endpoint and/or file",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Run with default settings
|
||||
python gaia_sample.py
|
||||
|
||||
# Run with custom data directory
|
||||
python gaia_sample.py --data-dir ./gaia_data
|
||||
|
||||
# Run with OpenAI agent provider
|
||||
python gaia_sample.py --agent-provider openai
|
||||
|
||||
# Run with trace file export
|
||||
python gaia_sample.py --trace-file gaia_benchmark_traces.jsonl
|
||||
|
||||
# Run level 2 tasks with 5 maximum tasks
|
||||
python gaia_sample.py --level 2 --max-n 5
|
||||
|
||||
# Run with OTLP export to Aspire Dashboard and custom settings
|
||||
python gaia_sample.py --otlp-endpoint http://localhost:4318 --level 1 --max-n 10 --parallel 2
|
||||
|
||||
# Run with all options configured
|
||||
python gaia_sample.py --agent-provider openai \
|
||||
--trace-file traces.jsonl \
|
||||
--result-file results.jsonl \
|
||||
--otlp-endpoint http://localhost:4318 --level 1 --max-n 5 --parallel 2 --timeout 180
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--otlp-endpoint",
|
||||
type=str,
|
||||
default=None,
|
||||
help="OTLP endpoint URL for exporting traces (e.g., http://localhost:4318 for Aspire Dashboard)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trace-file",
|
||||
type=str,
|
||||
default=None,
|
||||
help="File path to export traces to (e.g., gaia_benchmark_traces.jsonl). "
|
||||
"If not set, traces won't be saved to file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--result-file",
|
||||
type=str,
|
||||
default="gaia_results_level1.jsonl",
|
||||
help="File path to save benchmark results (default: gaia_results_level1.jsonl)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--data-dir",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Directory to cache GAIA dataset. If not set, uses system temp directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--agent-provider",
|
||||
type=str,
|
||||
default="azure-ai",
|
||||
choices=["azure-ai", "openai"],
|
||||
help="Agent provider to use: 'azure-ai' or 'openai' (default: 'azure-ai')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--level",
|
||||
type=int,
|
||||
default=1,
|
||||
choices=[1, 2, 3],
|
||||
help="GAIA benchmark level to run: 1, 2, or 3 (default: 1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-n",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Maximum number of tasks to run per level (default: 2)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--parallel",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of parallel tasks to run (default: 1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=120,
|
||||
help="Timeout per task in seconds (default: 120)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(
|
||||
main(
|
||||
otlp_endpoint=args.otlp_endpoint,
|
||||
trace_file=args.trace_file,
|
||||
result_file=args.result_file,
|
||||
data_dir=args.data_dir,
|
||||
agent_provider=args.agent_provider,
|
||||
level=args.level,
|
||||
max_n=args.max_n,
|
||||
parallel=args.parallel,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""OpenAI Agent factory for GAIA benchmark.
|
||||
|
||||
This module provides a factory function to create an OpenAI agent
|
||||
configured for GAIA benchmark tasks using the OpenAI Responses API.
|
||||
|
||||
Required Environment Variables:
|
||||
OPENAI_API_KEY: Your OpenAI API key
|
||||
OPENAI_CHAT_MODEL: Model to use with Responses API (e.g., gpt-4o, gpt-4o-mini)
|
||||
|
||||
Optional Environment Variables:
|
||||
OPENAI_BASE_URL: Custom API base URL if using a proxy or compatible service
|
||||
OPENAI_ORG_ID: Organization ID for OpenAI API (if applicable)
|
||||
|
||||
Authentication:
|
||||
Uses OPENAI_API_KEY environment variable.
|
||||
Get your API key from: https://platform.openai.com/api-keys
|
||||
|
||||
Example:
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export OPENAI_CHAT_MODEL="gpt-4o"
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_gaia_agent() -> AsyncIterator[Agent]:
|
||||
"""Create an OpenAI agent configured for GAIA benchmark tasks.
|
||||
|
||||
Uses OpenAI Responses API for enhanced capabilities.
|
||||
|
||||
The agent is configured with:
|
||||
- Web Search tool for information retrieval
|
||||
- Code Interpreter tool for calculations and data analysis
|
||||
|
||||
Yields:
|
||||
Agent: A configured agent ready to run GAIA tasks.
|
||||
|
||||
Example:
|
||||
async with create_gaia_agent() as agent:
|
||||
result = await agent.run("What is the capital of France?")
|
||||
print(result.text)
|
||||
"""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
async with client.as_agent(
|
||||
name="GaiaAgent",
|
||||
instructions="Solve tasks to your best ability. Use Web Search to find "
|
||||
"information and Code Interpreter to perform calculations and data analysis.",
|
||||
tools=[
|
||||
OpenAIChatClient.get_web_search_tool(),
|
||||
OpenAIChatClient.get_code_interpreter_tool(),
|
||||
],
|
||||
) as agent:
|
||||
yield agent
|
||||
@@ -0,0 +1,36 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for GAIA benchmark implementation."""
|
||||
|
||||
from agent_framework_lab_gaia import gaia_scorer
|
||||
|
||||
|
||||
class TestGAIAScorer:
|
||||
"""Test the GAIA scoring function."""
|
||||
|
||||
def test_numeric_exact_match(self):
|
||||
"""Test numeric exact matching."""
|
||||
assert gaia_scorer("42", "42") is True
|
||||
assert gaia_scorer("42.0", "42") is True
|
||||
assert gaia_scorer("42", "42.0") is True
|
||||
assert gaia_scorer("42", "43") is False
|
||||
|
||||
def test_string_normalization(self):
|
||||
"""Test string normalization and matching."""
|
||||
assert gaia_scorer("Hello World", "hello world") is True
|
||||
assert gaia_scorer("Hello, World!", "helloworld") is True
|
||||
assert gaia_scorer("test", "TEST") is True
|
||||
assert gaia_scorer("test", "different") is False
|
||||
|
||||
def test_list_matching(self):
|
||||
"""Test list matching with comma/semicolon separation."""
|
||||
assert gaia_scorer("1,2,3", "1,2,3") is True
|
||||
assert gaia_scorer("1; 2; 3", "1,2,3") is True
|
||||
assert gaia_scorer("apple,banana", "apple,banana") is True
|
||||
assert gaia_scorer("1,2,3", "1,2,4") is False
|
||||
assert gaia_scorer("1,2", "1,2,3") is False
|
||||
|
||||
def test_none_handling(self):
|
||||
"""Test handling of None values."""
|
||||
assert gaia_scorer("None", "test") is False
|
||||
assert gaia_scorer("", "test") is False
|
||||
@@ -0,0 +1,2 @@
|
||||
assets/ filter=lfs diff=lfs merge=lfs -text
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -0,0 +1,191 @@
|
||||
# Agent Framework Lab - Lightning
|
||||
|
||||
**Agent Framework Lab Lightning** is a specialized package that integrates [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) with [Agent-lightning](https://github.com/microsoft/agent-lightning) to provide reinforcement learning (RL) training capabilities for AI agents.
|
||||
|
||||
This package enables you to train and fine-tune agents using advanced RL algorithms from VERL (e.g., GRPO, PPO, Reinforce++) with support for distributed training, multi-GPU setups, and comprehensive monitoring. It also supports complex multi-turn agent interactions during training and optimization techniques like prompt optimization. See the [Agent-lightning documentation](https://microsoft.github.io/agent-lightning/stable/) for details.
|
||||
|
||||
> **Note**: This module is part of the consolidated `agent-framework-lab` package. Install the package with the `lightning` extra to use this module.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the `agent-framework-lab` package with Lightning dependencies:
|
||||
|
||||
```bash
|
||||
pip install "agent-framework-lab[lightning]"
|
||||
```
|
||||
|
||||
### Optional Dependencies
|
||||
|
||||
```bash
|
||||
# For math-related training
|
||||
pip install -e ".[lightning,math]"
|
||||
|
||||
# For tau2 benchmarking
|
||||
pip install -e ".[lightning,tau2]"
|
||||
```
|
||||
|
||||
To prepare for RL training, you'll also need to install dependencies like PyTorch, Ray, and vLLM. See the [Agent-lightning setup instructions](https://microsoft.github.io/agent-lightning/stable/tutorials/installation/) for more details.
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
The basic usage pattern follows these steps:
|
||||
|
||||
1. **Prepare your dataset** as a list of samples (typically dictionaries)
|
||||
2. **Create an agent function** that processes samples and returns evaluation scores
|
||||
3. **Decorate with `@agentlightning.rollout`** to enable training
|
||||
4. **Configure and run training** with the `agentlightning.Trainer` class
|
||||
|
||||
### Example Implementation
|
||||
|
||||
```python
|
||||
from agent_framework.lab.lightning import AgentFrameworkTracer
|
||||
from agentlightning import rollout, Trainer, LLM, Dataset
|
||||
from agentlightning.algorithm.verl import VERL
|
||||
|
||||
TaskType = Any
|
||||
|
||||
@rollout
|
||||
async def math_agent(task: TaskType, llm: LLM) -> float:
|
||||
"""A function that solves a math problem and returns the evaluation score."""
|
||||
async with (
|
||||
MCPStdioTool(name="calculator", command="uvx", args=["mcp-server-calculator"]) as mcp_server,
|
||||
Agent(
|
||||
client=OpenAIChatClient(
|
||||
model=llm.model,
|
||||
api_key="your-api-key",
|
||||
base_url=llm.endpoint,
|
||||
),
|
||||
name="MathAgent",
|
||||
instructions="Solve the math problem and output answer after ###",
|
||||
temperature=llm.sampling_parameters.get("temperature", 0.0),
|
||||
) as agent,
|
||||
):
|
||||
result = await agent.run(task["question"], tools=mcp_server)
|
||||
# Your evaluation logic here...
|
||||
return evaluation_score
|
||||
|
||||
# Training configuration
|
||||
config = {
|
||||
"data": {"train_batch_size": 8},
|
||||
"trainer": {"total_epochs": 2, "n_gpus_per_node": 1},
|
||||
# ... additional config
|
||||
}
|
||||
|
||||
# Initialize agent-framework tracer to send telemetry data to agent-lightning's observability backend
|
||||
tracer = AgentFrameworkTracer()
|
||||
|
||||
trainer = Trainer(algorithm=VERL(config), tracer=tracer, n_workers=2)
|
||||
# Both train_dataset and val_dataset are lists of TaskType
|
||||
trainer.fit(math_agent, train_dataset, val_data=val_dataset)
|
||||
```
|
||||
|
||||
## Example 1: Training a Math Agent
|
||||
|
||||
This example trains an agent that uses an MCP calculator tool to solve math problems. The dataset is a small subset from the [Calc-X](https://huggingface.co/datasets/MU-NLPC/Calc-X) dataset. The Agent-lightning team has also experimented with a similar agent using a larger dataset. See [this example](https://github.com/microsoft/agent-lightning/tree/a63197355cc23b5b235c49fe7c20b54f9d4ebcd2/examples/calc_x) for more details.
|
||||
|
||||
Running this example requires a minimum of 40GB GPU memory. If you don't have enough GPU memory, you can use a smaller model like `Qwen2.5-0.5B-Instruct`, though the results won't be as good. To run the example:
|
||||
|
||||
```bash
|
||||
cd samples
|
||||
# Run the ray cluster (see the troubleshooting section for more details)
|
||||
ray start --head --dashboard-host=0.0.0.0
|
||||
# Run the training script
|
||||
python train_math_agent.py
|
||||
```
|
||||
|
||||
To debug the agent used in the example, you can run the script with the `--debug` flag:
|
||||
|
||||
```bash
|
||||
python train_math_agent.py --debug
|
||||
```
|
||||
|
||||
The training curve below shows results with Qwen2.5-1.5B-Instruct and GRPO. Validation accuracy increases from 10% to 35% in the first 8 steps, then begins to overfit.
|
||||
|
||||

|
||||
|
||||
## Example 2: Training a Tau2 Agent
|
||||
|
||||
This advanced example demonstrates training on complex multi-agent scenarios using the Tau2 benchmark. It features a multi-agent setup with an assistant agent and a user simulator agent, training the assistant while keeping the user simulator fixed. The example incorporates a multi-step workflow with tool usage and complex evaluation metrics. Currently, training uses the airline domain with a 50/50 split between training and validation data.
|
||||
|
||||
Before running this example, please read the [agent-lightning-lab-tau2](../tau2/README.md) documentation and follow the setup instructions.
|
||||
|
||||
To run the example:
|
||||
|
||||
```bash
|
||||
# Set required environment variables
|
||||
export TAU2_DATA_DIR="/path/to/tau2/data"
|
||||
|
||||
# Used for user simulator and LLM judge
|
||||
export OPENAI_BASE_URL="your-endpoint"
|
||||
export OPENAI_API_KEY="your-key"
|
||||
|
||||
# Used for tracking on Weights & Biases
|
||||
export WANDB_API_KEY="your-key"
|
||||
|
||||
# Run the ray cluster
|
||||
ray start --head --dashboard-host=0.0.0.0
|
||||
|
||||
# Train the tau2 agent
|
||||
cd samples
|
||||
python samples/train_tau2_agent.py
|
||||
|
||||
# Debug mode
|
||||
python samples/train_tau2_agent.py --debug
|
||||
```
|
||||
|
||||
This example uses more advanced Agent-lightning features compared to the math example. It's based on the `LitAgent` class rather than the `@rollout` decorator and involves concepts like resources and agent filtering. We recommend reading the [Agent-lightning documentation](https://microsoft.github.io/agent-lightning/stable/) to learn more.
|
||||
|
||||
Results with Qwen2.5-1.5B-Instruct and GRPO are shown below. Validation accuracy improves from 28% to 40% over 8 epochs.
|
||||
|
||||

|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Ray Connection Issues
|
||||
|
||||
Agent-lightning uses VERL for RL training, which depends on Ray. To avoid issues, it's recommended to start Ray manually beforehand. If you encounter Ray startup problems:
|
||||
|
||||
```bash
|
||||
# Stop existing Ray processes
|
||||
ray stop
|
||||
|
||||
# Start Ray with debugging enabled
|
||||
env RAY_DEBUG=legacy HYDRA_FULL_ERROR=1 VLLM_USE_V1=1 ray start --head --dashboard-host=0.0.0.0
|
||||
```
|
||||
|
||||
**Important**: Run Ray commands in the same directory as your training script. Set any required environment variables (`WANDB_API_KEY`, `HF_TOKEN`) before starting Ray.
|
||||
|
||||
### GPU Memory Issues
|
||||
|
||||
1. **Reduce `gpu_memory_utilization`** to <0.8
|
||||
2. **Enable FSDP offloading**:
|
||||
```python
|
||||
"fsdp_config": {
|
||||
"param_offload": True,
|
||||
"optimizer_offload": True,
|
||||
}
|
||||
```
|
||||
3. **Decrease batch sizes**:
|
||||
- `train_batch_size`
|
||||
- `ppo_mini_batch_size`
|
||||
- `log_prob_micro_batch_size_per_gpu`
|
||||
|
||||
### Agent Debugging
|
||||
|
||||
Always test your agent before training:
|
||||
|
||||
```bash
|
||||
# Use debug mode to validate agent behavior
|
||||
python your_training_script.py --debug
|
||||
|
||||
# Check agent responses and evaluation logic
|
||||
# Ensure proper tool integration and result extraction
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This package is part of the Microsoft Agent Framework Lab. Please see the main repository for contribution guidelines.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""RL Module for Microsoft Agent Framework."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
from agentlightning.tracer import (
|
||||
AgentOpsTracer,
|
||||
)
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
|
||||
class AgentFrameworkTracer(AgentOpsTracer):
|
||||
"""Tracer for Agent-framework.
|
||||
|
||||
Tracer that enables OpenTelemetry observability for the Agent-framework,
|
||||
so that the traces are visible to Agent-lightning.
|
||||
"""
|
||||
|
||||
def init(self) -> None:
|
||||
"""Initialize the agent-framework-lab-lightning for training."""
|
||||
enable_instrumentation()
|
||||
super().init()
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Teardown the agent-framework-lab-lightning for training."""
|
||||
super().teardown()
|
||||
|
||||
|
||||
__all__: list[str] = ["AgentFrameworkTracer"]
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ab35a2bd18794a32b76437671ae7e5749992c8aa781030b51eca2e56acfb362d
|
||||
size 153014
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8fc96ee605b8d8e154892161cb01a42338d7781f5181afb060dac99d36eeb2c0
|
||||
size 189488
|
||||
@@ -0,0 +1,20 @@
|
||||
{"id": "svamp__chal-551", "question": "Robin has some packages of gum. There are 7 pieces in each package. Robin has 6 extra pieces of gum. In all the number of pieces of gums robin has is 41. How many packages does Robin have?", "chain": "<gadget id=\"calculator\">41 - 6</gadget>\n<output>35</output>\n\n<gadget id=\"calculator\">35 / 7</gadget>\n<output>5</output>\n\n<result>5</result>", "result": "5", "source": "calc"}
|
||||
{"id": "ape210k__00027150", "question": "2 meters of floral cloth, how much rice is left after 80% is used", "chain": "<gadget id=\"calculator\">80 / 100</gadget>\n<output>4/5 = around 0.8</output>\n\n<gadget id=\"calculator\">1 - (4/5)</gadget>\n<output>1/5 = around 0.2</output>\n\n<gadget id=\"calculator\">2 * (1/5)</gadget>\n<output>2/5 = around 0.4</output>\n\n<result>2/5 = around 0.4</result>", "result": "2/5", "source": "calc"}
|
||||
{"id": "ape210k__00287396", "question": "There are two schools, A and B. School A has 525 students, and school B has 50 fewer students than school A. How many students are there in school B?", "chain": "<gadget id=\"calculator\">525 * 2</gadget>\n<output>1_050</output>\n\n<gadget id=\"calculator\">1_050 - 50</gadget>\n<output>1_000</output>\n\n<result>1_000</result>", "result": "1_000", "source": "calc"}
|
||||
{"id": "gsm8k__xtQ5d23fzgEAhUdB", "question": "If Mark weighs 150 pounds and Susan weighs 20 pounds less than Mark. And their friend Bob weighs twice as much as Susan. What is the average weight of the 3 friends?", "chain": "Susan weighs 150 pounds - 20 pounds = \n<gadget id=\"calculator\">150-20</gadget>\n<output>130</output>\n130 pounds.\nBob weighs 2 * 130 pounds = \n<gadget id=\"calculator\">2*130</gadget>\n<output>260</output>\n260 pounds.\nThe friends total weight is 150 + 130 + 260 pounds = \n<gadget id=\"calculator\">150+130+260</gadget>\n<output>540</output>\n540 pounds.\nThe friends' average weight is 540 pounds / 3 = \n<gadget id=\"calculator\">540/3</gadget>\n<output>180</output>\n180 pounds.\n\n<result>180</result>", "result": "180", "source": "calc"}
|
||||
{"id": "svamp__chal-741", "question": "He had a total of 40 saltwater animals in different aquariums. Each aquarium has 2 animals in it. How many aquariums did he have?", "chain": "<gadget id=\"calculator\">40 / 2</gadget>\n<output>20</output>\n\n<result>20</result>", "result": "20", "source": "calc"}
|
||||
{"id": "asdiv_a__nluds-0023", "question": "You have collected 7 crickets. How many more crickets do you need to collect to have 11 crickets?", "chain": "<gadget id=\"calculator\">11 - 7</gadget>\n<output>4</output>\n\n<result>4</result>", "result": "4", "source": "calc"}
|
||||
{"id": "gsm8k__0oOjz5Ub66DF4inZ", "question": "There are 6 trees in Chris's yard. Ferdinand has half the number of trees that Chris has. Harry has 5 more than twice the number of trees that Ferdinand has. How many more trees are in Harry's yard than Ferdinand's yard?", "chain": "Ferdinand:6/2=\n<gadget id=\"calculator\">6/2</gadget>\n<output>3</output>\n3 trees.\nHarry:5+2(3)=5+6=11 trees\n11-3=\n<gadget id=\"calculator\">11-3</gadget>\n<output>8</output>\n8 trees.\n\n<result>8</result>", "result": "8", "source": "calc"}
|
||||
{"id": "ape210k__00565195", "question": "During the May 1st period, Xiaoqiang\u2019s family went on a trip to other places. The planned consumption was 2,000 yuan, but the actual consumption was 1,800 yuan. How much less was the actual consumption than the plan?", "chain": "<gadget id=\"calculator\">2_000 - 1_800</gadget>\n<output>200</output>\n\n<gadget id=\"calculator\">200 / 2_000</gadget>\n<output>1/10 = around 0.1</output>\n\n<result>1/10 = around 0.1</result>", "result": "1/10", "source": "calc"}
|
||||
{"id": "mawps__E0wRRdRDwTmdqH2u", "question": "Milton had 238 peach. William clasped some peach. Now Milton has 51 peach. How many did William claspeds?", "chain": "<gadget id=\"calculator\">238 - 51</gadget>\n<output>187</output>\n\n<result>187</result>", "result": "187", "source": "calc"}
|
||||
{"id": "asdiv_a__nluds-0318", "question": "The map led them through the forest and into a cave. To open the cave doors, they need to put weights on the switch. If the switch already has 234 lbs. of weights and the total needed is 712 lbs.,, how much more weight to they need to add?", "chain": "<gadget id=\"calculator\">712 - 234</gadget>\n<output>478</output>\n\n<result>478</result>", "result": "478", "source": "calc"}
|
||||
{"id": "ape210k__00965281", "question": "The annual interest rate of the five-year national debt is 2.75%. If a person buys a national debt of 20,000 yuan, what is the total amount of principal and interest after maturity?", "chain": "<gadget id=\"calculator\">2.75 / 100</gadget>\n<output>0.0275</output>\n\n<gadget id=\"calculator\">20_000 * 0.0275 * 5</gadget>\n<output>2_750</output>\n\n<gadget id=\"calculator\">20_000 + 2_750</gadget>\n<output>22_750</output>\n\n<result>22_750</result>", "result": "22_750", "source": "calc"}
|
||||
{"id": "svamp__chal-289", "question": "Jack received 6 emails in the morning and 8 emails in the afternoon. How many more emails did Jack receive in the afternoon than in the morning?", "chain": "<gadget id=\"calculator\">8 - 6</gadget>\n<output>2</output>\n\n<result>2</result>", "result": "2", "source": "calc"}
|
||||
{"id": "ape210k__00829979", "question": "The fifth grade students participate in the big break exercise, and 12 people or 18 people can be divided into a row. If the number of students is less than 200, how many students can participate in the big break exercise this time?", "chain": "<gadget id=\"calculator\">36 * 5</gadget>\n<output>180</output>\n\n<result>180</result>", "result": "180", "source": "calc"}
|
||||
{"id": "ape210k__00909867", "question": "A and B process 1200 parts at the same time, and the plan is to complete it in 6 hours. A processes 80 parts per hour. To complete the work on time, how many parts does B need to process per hour? (column equations to solve problems)", "chain": "<gadget id=\"calculator\">80 * 6</gadget>\n<output>480</output>\n\n<gadget id=\"calculator\">1_200 - 480</gadget>\n<output>720</output>\n\n<gadget id=\"calculator\">720 / 6</gadget>\n<output>120</output>\n\n<result>120</result>", "result": "120", "source": "calc"}
|
||||
{"id": "svamp__chal-972", "question": "A mailman has to give 4 pieces of junk mail to each house in each of the 16 blocks. If there are 17 houses in each block, how many pieces of junk mail should he give in total?", "chain": "<gadget id=\"calculator\">4 * 17</gadget>\n<output>68</output>\n\n<gadget id=\"calculator\">68 * 16</gadget>\n<output>1_088</output>\n\n<result>1088</result>", "result": "1_088", "source": "calc"}
|
||||
{"id": "aqua_rat__j7vMuYEEajqH6GTH", "question": "5 horses are in a race. Mr.Jain selects two of horses at random and bets on them. The probability that he selected the winning horse is Choose the correct choice: A) 1/5 B) 2/5 C) 3/5 D) 4/5 E) 6/5", "chain": "There are 5 horses. Probability of winning for each horse = 1/5. Probability of winning with 2 selected horses= (1/5)+(1/5)= 2/5. Answer is 2/5. ANSWER:2/5\n<result>B</result>", "result": "B", "source": "calc"}
|
||||
{"id": "asdiv_a__nluds-0263", "question": "Feeling good about what he did, Mr. Anderson decided to continue giving to others. He went around the city and gave clothes to homeless people. If he gave 589 shirts and 345 trousers,, how many pieces of clothing did he gave out in total?", "chain": "<gadget id=\"calculator\">589 + 345</gadget>\n<output>934</output>\n\n<result>934</result>", "result": "934", "source": "calc"}
|
||||
{"id": "svamp__chal-968", "question": "Mary is baking a cake. The recipe calls for 10 cups of flour 2 cups of sugar and 80 cups of salt. She already put in 7 cups of flour. How many more cups of flour than cups of sugar does she need to add now?", "chain": "<gadget id=\"calculator\">10 - 7</gadget>\n<output>3</output>\n\n<gadget id=\"calculator\">3 - 2</gadget>\n<output>1</output>\n\n<result>1</result>", "result": "1", "source": "calc"}
|
||||
{"id": "gsm8k__aIzJoU5IRgriERup", "question": "A tub of ice cream costing $13 is now sold at $11. A packet of milk was sold at a discount of $0.5. How much will you save if you buy 2 tubs of ice cream and 4 packets of milk?", "chain": "The discount for each tub of ice cream is $13 - $11 = $\n<gadget id=\"calculator\">13-11</gadget>\n<output>2</output>\n2.\nSo the discount for 2 tubs of ice cream is $2 x 2 = $\n<gadget id=\"calculator\">2*2</gadget>\n<output>4</output>\n4.\nThe total discount for 4 packets of milk is $0.5 x 4 = $\n<gadget id=\"calculator\">0.5*4</gadget>\n<output>2</output>\n2.\nYou will save $4 + $2 = $6 for 2 tubs of ice cream and 4 packets of milk.\n\n<result>6</result>", "result": "6", "source": "calc"}
|
||||
{"id": "ape210k__00623575", "question": "In the art group, boys are girls (4/5), how much less boys than girls.", "chain": "<gadget id=\"calculator\">4 / 5</gadget>\n<output>4/5 = around 0.8</output>\n\n<gadget id=\"calculator\">1 - (4/5)</gadget>\n<output>1/5 = around 0.2</output>\n\n<gadget id=\"calculator\">(1/5) / 1</gadget>\n<output>1/5 = around 0.2</output>\n\n<result>1/5 = around 0.2</result>", "result": "1/5", "source": "calc"}
|
||||
@@ -0,0 +1,64 @@
|
||||
{"id": "ape210k__00384263", "question": "6.6 minus x (3/2) times equals 5.6.", "chain": "<gadget id=\"calculator\">6.6 - 5.6</gadget>\n<output>1</output>\n\n<gadget id=\"calculator\">3 / 2</gadget>\n<output>3/2 = around 1.5</output>\n\n<gadget id=\"calculator\">1 / (3/2)</gadget>\n<output>2/3 = around 0.666667</output>\n\n<result>2/3 = around 0.666667</result>", "result": "2/3", "source": "calc"}
|
||||
{"id": "ape210k__00469689", "question": "How many degrees of 75\u00b0 can form a right angle?", "chain": "<gadget id=\"calculator\">90 - 75</gadget>\n<output>15</output>\n\n<result>15</result>", "result": "15", "source": "calc"}
|
||||
{"id": "ape210k__00352031", "question": "Wang Li puts a piece of cake on the left side of the balance, and (1/4) piece of cake and a weight of 90 grams on the right side. At this time, the balance is just balanced. How much does a whole cake weigh in grams?", "chain": "<gadget id=\"calculator\">1 / 4</gadget>\n<output>1/4 = around 0.25</output>\n\n<gadget id=\"calculator\">90 / (1/4)</gadget>\n<output>360</output>\n\n<result>360</result>", "result": "360", "source": "calc"}
|
||||
{"id": "ape210k__00569876", "question": "Column calculation: Subtract 30% of (2/5) from 1, divide the difference by (11/50), what is the quotient?", "chain": "<gadget id=\"calculator\">2 / 5</gadget>\n<output>2/5 = around 0.4</output>\n\n<gadget id=\"calculator\">30 / 100</gadget>\n<output>3/10 = around 0.3</output>\n\n<gadget id=\"calculator\">(2/5) * (3/10)</gadget>\n<output>3/25 = around 0.12</output>\n\n<gadget id=\"calculator\">1 - (3/25)</gadget>\n<output>22/25 = around 0.88</output>\n\n<gadget id=\"calculator\">11 / 50</gadget>\n<output>11/50 = around 0.22</output>\n\n<gadget id=\"calculator\">(22/25) / (11/50)</gadget>\n<output>4</output>\n\n<result>4</result>", "result": "4", "source": "calc"}
|
||||
{"id": "ape210k__00767581", "question": "It takes Xiaofang 12 seconds to walk from the first floor to the third floor. At this speed, how many seconds does it take her to go from the third floor to the seventh floor?", "chain": "<gadget id=\"calculator\">3 - 1</gadget>\n<output>2</output>\n\n<gadget id=\"calculator\">12 / 2</gadget>\n<output>6</output>\n\n<gadget id=\"calculator\">7 - 3</gadget>\n<output>4</output>\n\n<gadget id=\"calculator\">6 * 4</gadget>\n<output>24</output>\n\n<result>24</result>", "result": "24", "source": "calc"}
|
||||
{"id": "aqua_rat__0bgRP2fAiH8URR9A", "question": "36 men can complete a piece of work in 18 days. In how many days will 108 men complete the same work ?\nChoose the correct choice\nA) 24 B) 77 C) 6 D) 29 E) 21", "chain": "Explanation: Less Men, means more Days {Indirect Proportion} Let the number of days be x then, 108 : 36 :: 18 : x x = 6 Answer: 6) 6 days\n<result>C</result>", "result": "C", "source": "calc"}
|
||||
{"id": "ape210k__00469555", "question": "The average score of Li Hong's Chinese unit test in the first three units of this semester is 92 points, and the average score of the first two units is 93 points. What is the score of the third language test?", "chain": "<gadget id=\"calculator\">92 * 3</gadget>\n<output>276</output>\n\n<gadget id=\"calculator\">93 * 2</gadget>\n<output>186</output>\n\n<gadget id=\"calculator\">276 - 186</gadget>\n<output>90</output>\n\n<result>90</result>", "result": "90", "source": "calc"}
|
||||
{"id": "aqua_rat__r54xvzEL3O9nU60t", "question": "Barbata invests $2400 in the National Bank at 5%. How much additional money must she invest at 10% so that the total annual income will be equal to 6% of her entire investment?\nPick one:\nA) 120\nB) 600\nC) 1000\nD) 360\nE) 240", "chain": "Let the additional invested amount for 10% interest be x; Equation will be; 2400+0.05*2400+x+0.10x = 2400+x+0.06(2400+x) 0.05*2400+0.10x = 0.06x+0.06*2400 0.04x = 2400(0.06-0.05) x = 2400*0.01/0.04= \n<gadget id=\"calculator\">2400*0.01/0.04</gadget>\n<output>600</output>\n600 Ans:600\n<result>B</result>", "result": "B", "source": "calc"}
|
||||
{"id": "ape210k__00851767", "question": "There are 30 boys in the dance group, and there are fewer girls than boys (1/5). How many girls are there?", "chain": "<gadget id=\"calculator\">1 / 5</gadget>\n<output>1/5 = around 0.2</output>\n\n<gadget id=\"calculator\">1 - (1/5)</gadget>\n<output>4/5 = around 0.8</output>\n\n<gadget id=\"calculator\">30 * (4/5)</gadget>\n<output>24</output>\n\n<result>24</result>", "result": "24", "source": "calc"}
|
||||
{"id": "ape210k__00935567", "question": "Uncle Wang has 20 goats, 14 sheep, and 408 rabbits. How many times more rabbits are there than sheep?", "chain": "<gadget id=\"calculator\">20 + 14</gadget>\n<output>34</output>\n\n<gadget id=\"calculator\">408 / 34</gadget>\n<output>12</output>\n\n<result>12</result>", "result": "12", "source": "calc"}
|
||||
{"id": "math_qa__tyaZVO6Q2uEE7wBw", "question": "How much greater is the combined area in square inches of the front and back of a rectangular sheet of paper measuring 11 inches by 11 inches than that of a rectangular sheet of paper measuring 5.5 inches by 11 inches?\tChoose the correct choice from the following choices:\nA) 50 % B) 87 % C) 100 % D) 187 % E) 200 %", "chain": "<gadget id=\"calculator\">11 * 11</gadget>\n<output>121</output>\n\n<gadget id=\"calculator\">121 * 2</gadget>\n<output>242</output>\n\n<gadget id=\"calculator\">5.5 * 11</gadget>\n<output>60.5</output>\n\n<gadget id=\"calculator\">60.5 * 2</gadget>\n<output>121</output>\n\n<gadget id=\"calculator\">242 - 121</gadget>\n<output>121</output>\n\n<gadget id=\"calculator\">121 / 121</gadget>\n<output>1</output>\n\n<gadget id=\"calculator\">1 * 100</gadget>\n<output>100</output>\n\n<result>C</result>", "result": "C", "source": "calc"}
|
||||
{"id": "aqua_rat__Qtp9RDyp7cecUmpb", "question": "0.01 is what percent of 0.1? Choices: A) 1% B) 10% C) 100% D) 50% E) 25%", "chain": "Required percentage = 0.01*100/0.1 = 100/10= \n<gadget id=\"calculator\">100/10</gadget>\n<output>10</output>\n10% Answer is 10%\n<result>B</result>", "result": "B", "source": "calc"}
|
||||
{"id": "ape210k__01121109", "question": "It is known that the sum of 9 consecutive natural numbers is 315, so what is the largest number among them.", "chain": "<gadget id=\"calculator\">315 / 9</gadget>\n<output>35</output>\n\n<gadget id=\"calculator\">35 + 1 + 1 + 1 + 1</gadget>\n<output>39</output>\n\n<result>39</result>", "result": "39", "source": "calc"}
|
||||
{"id": "aqua_rat__JwrYawp1nZkf5o7a", "question": "Josh spends a total of $5.5 buying S items in the convenience store. If each of the items is either a 5 cents single bubblegum, or a 50 cents bubblegum pack, then S may be which of the following?\nChoose the correct choice from the following answers.\nA) 99\nB) 100\nC) 101\nD) 112\nE) 113", "chain": "S items in the convenience store$5.5 = 550 cents 550 = 50a + 5b =>110 = 10a + b b = 110 - 10a = 10(11-a) Hence b is even and multiple of 10. Possible values of b: b = 10,20,30,40,50,60,70,80,90,100 a = 11,9,8,7,6,5,4,3,2,1 The total (a+b) is 21,29,38,47,56,65,74,83,92,101 The only option is 101. Hence 101.\n<result>C</result>", "result": "C", "source": "calc"}
|
||||
{"id": "ape210k__00348953", "question": "The road repair team repaired a road, 185 meters a day. It has been repaired for 20 days, and another 128 meters will be completed. How long is this road?", "chain": "<gadget id=\"calculator\">185 * 20</gadget>\n<output>3_700</output>\n\n<gadget id=\"calculator\">3_700 + 128</gadget>\n<output>3_828</output>\n\n<result>3_828</result>", "result": "3_828", "source": "calc"}
|
||||
{"id": "ape210k__00285692", "question": "The lateral expansion of a cylinder is a square with side length 8 cm. What is the lateral area of this cylinder in cm**2.", "chain": "<gadget id=\"calculator\">8 ** 2</gadget>\n<output>64</output>\n\n<result>64</result>", "result": "64", "source": "calc"}
|
||||
{"id": "ape210k__00391316", "question": "The greatest common factor of two numbers A and B is 5, and the least common multiple is 60. Where the number of A is 15, what is the number of B?", "chain": "<gadget id=\"calculator\">4 * 5</gadget>\n<output>20</output>\n\n<result>20</result>", "result": "20", "source": "calc"}
|
||||
{"id": "aqua_rat__yG3x6Th3XteHm4gg", "question": "The probability is 1/2 that a certain coin turns up heads on any given toss. If the coin is tossed five times, what is the probability that the coin turns up tails on at least one of the tosses? Choose the most appropriate option.\nA) 7/8 B) 15/16 C) 31/32 D) 21/32 E) 31/64", "chain": "P(5 heads)= 1/2*1/2*1/2*1/2*1/2=1/32. P(at least one tail)=1-1/32=31/32. The answer is 31/32.\n<result>C</result>", "result": "C", "source": "calc"}
|
||||
{"id": "ape210k__00459584", "question": "Xiaohua took a photo and wanted to make a wooden photo frame for the photo. The length of the photo frame is 25 cm and the width is 20 cm. At least how many centimeters of wooden strips should be prepared?", "chain": "<gadget id=\"calculator\">25 + 20</gadget>\n<output>45</output>\n\n<gadget id=\"calculator\">45 * 2</gadget>\n<output>90</output>\n\n<result>90</result>", "result": "90", "source": "calc"}
|
||||
{"id": "aqua_rat__eDeVHpDSC7yeRy8K", "question": "Two cards are drawn at random from a pack of 52 cards.what is the probability that either both are black or both are queen\nChoose the correct choice from the following\nA) 44/221\nB) 55/221\nC) 76/221\nD) 45/221\nE) 63/221", "chain": "WE HAVE N(S)=52C2=(52*51)/(2*1)= \n<gadget id=\"calculator\">(52*51)/(2*1)</gadget>\n<output>1_326</output>\n1326. LET A=EVENT OF GETTING 55/221OTH 55/221LACK CARDS 55/221=EVENT OF GETTING 55/221OTH QUEENS A\uf0c755/221=EVENT OF GETTING QUEEN OF 55/221LACK CARDS N(A)=26C2=(26*25)/(2*1)= \n<gadget id=\"calculator\">(26*25)/(2*1)</gadget>\n<output>325</output>\n325, N(55/221)=4C2=(4*3)/(2*1)= \n<gadget id=\"calculator\">(4*3)/(2*1)</gadget>\n<output>6</output>\n6 AND N(A\uf0c755/221)=2C2=1 P(A)=N(A)/N(S)=325/1326; P(55/221)=N(55/221)/N(S)=6/1326 AND P(A\uf0c755/221)=N(A\uf0c755/221)/N(S)=1/1326 P(A\uf0c855/221)=P(A)+P(55/221)-P(A\uf0c755/221)=(325+6-1/1326)=330/1326=55/221 Option: 55/221\n<result>B</result>", "result": "B", "source": "calc"}
|
||||
{"id": "aqua_rat__8P5OAZaXVQlL4d8P", "question": "If two numbers are in the ratio 2:3. If 5 is added to both of the numbers then the ratio becomes 3:4 then find the smallest number?\nAnswers: A) A)10 B) B)18 C) C)20 D) D)24 E) E)26", "chain": "2:3 2x + 5 : 3x + 5 = 3 : 4 4[2x + 5] = 3[3x + 5] 8x + 20 = 9x + 15 9x - 8x = 20 - 15= \n<gadget id=\"calculator\">20 - 15</gadget>\n<output>5</output>\n5 Then smallest number is= 2 2x = 10 Correct Option 10\n<result>A</result>", "result": "A", "source": "calc"}
|
||||
{"id": "aqua_rat__1sGqyWbPyIDgCvSg", "question": "If a, b, c, d, e and f are integers and (ab + cdef) < 0, then what is the maximum number S of integers that can be negative? Choose the correct choice from the following answers.\nA) 2 B) 3 C) 4 D) 5 E) 6", "chain": "Minimuum should be 1 Maximum should be 4: 1 out of a or b to make the multiplication negative 3 out of c, d, e or f to make the multiplication negative. Negative+Negative<0 Answer:C maximum will be 5.. you dont require both the multiplicatin to be negative for entire equation to be negative... any one a or b can be negative to make ab negative and it can still be more(away from 0) than the multiplication of 4 other -ve numbers... actually by writing minimum required as 1 out of 6,you are actually meaning S= 5 out of 6 also possible as you will see 5 or 1 will give you same equation.. ans 5\n<result>D</result>", "result": "D", "source": "calc"}
|
||||
{"id": "aqua_rat__mGakVxdUhicX5FmA", "question": "Find the area of the quadrilateral of one of its diagonals is 10 cm and its off sets 7 cm and 3 cm? Choose the correct choice from the following answers\nA) 50 cm2\tB) 100 cm2\tC) 150 cm2\tD) 200 cm2\tE) 250 cm2", "chain": "1/2 * 10(7 + 3) = 50 cm2 50 cm2nswer: 50 cm2\n<result>A</result>", "result": "A", "source": "calc"}
|
||||
{"id": "ape210k__00398657", "question": "A right-angled trapezoid has an upper base of 2 cm and a waist length of 10 cm. If the upper base is increased by 6 cm, it becomes a square. What is the perimeter of the trapezoid in cm?", "chain": "<gadget id=\"calculator\">2 + 6</gadget>\n<output>8</output>\n\n<gadget id=\"calculator\">8 * 2</gadget>\n<output>16</output>\n\n<gadget id=\"calculator\">2 + 16 + 10</gadget>\n<output>28</output>\n\n<result>28</result>", "result": "28", "source": "calc"}
|
||||
{"id": "aqua_rat__1I3XjjMFYW6ivEn6", "question": "In 1998 the profits of company N were 10 percent of revenues. In 1999, the revenues of company N fell by 20 percent, but profits were 14 percent of revenues. The profits in 1999 were what percent of the profits in 1998? Answers.\nA) 80% B) 105% C) 120% D) 112% E) 138%", "chain": "0,112R = x/100*0.1R Answer 112%\n<result>D</result>", "result": "D", "source": "calc"}
|
||||
{"id": "aqua_rat__orCiKDobdncZcRw8", "question": "In a market, a dozen eggs cost as much as a pound of rice, and a half-liter of kerosene costs as much as 6 eggs. If the cost of each pound of rice is $0.24, then how many cents does a liter of kerosene cost? [One dollar has 100 cents.]\nChoose the correct choice.\nA) 0.20 B) 0.24 C) 20 D) 24 E) 55", "chain": "A dozen eggs cost as much as a pound of rice --> 12 eggs = 1 pound of rice = 24 cents; A half-liter of kerosene costs as much as 6 eggs --> 6 eggs = 1/2 liters of kerosene. How many cents does a liter of kerosene cost --> 1 liter of kerosene = 12 eggs = 12/12*24= \n<gadget id=\"calculator\">12/12*24</gadget>\n<output>24</output>\n24 cents. Answer: 24.\n<result>D</result>", "result": "D", "source": "calc"}
|
||||
{"id": "ape210k__00022661", "question": "A company saved an average of 9 tons of water per day in the second quarter of last year. How many tons of water was saved in the second quarter?", "chain": "<gadget id=\"calculator\">30 + 31 + 30</gadget>\n<output>91</output>\n\n<gadget id=\"calculator\">91 * 9</gadget>\n<output>819</output>\n\n<result>819</result>", "result": "819", "source": "calc"}
|
||||
{"id": "ape210k__00154447", "question": "(2/10) meters are used up for a piece of iron wire, and (8/10) meters are left, what is the original length of this iron wire", "chain": "<gadget id=\"calculator\">2 / 10</gadget>\n<output>1/5 = around 0.2</output>\n\n<gadget id=\"calculator\">8 / 10</gadget>\n<output>4/5 = around 0.8</output>\n\n<gadget id=\"calculator\">(1/5) + (4/5)</gadget>\n<output>1</output>\n\n<result>1</result>", "result": "1", "source": "calc"}
|
||||
{"id": "ape210k__00461545", "question": "Subtract 8 continuously from 496, and it will be 0 after how many times of subtraction.", "chain": "<gadget id=\"calculator\">496 / 8</gadget>\n<output>62</output>\n\n<result>62</result>", "result": "62", "source": "calc"}
|
||||
{"id": "ape210k__00220514", "question": "There are 54 cards in a deck of poker, ask: at least how many cards can be drawn from it to ensure that there are cards of four suits.", "chain": "<gadget id=\"calculator\">13 * 3</gadget>\n<output>39</output>\n\n<gadget id=\"calculator\">39 + 2 + 1</gadget>\n<output>42</output>\n\n<result>42</result>", "result": "42", "source": "calc"}
|
||||
{"id": "ape210k__00832821", "question": "There are several small balls of 4 different colors in the cloth bag, and the minimum number of small balls taken out can ensure that there must be 2 small balls of the same color.", "chain": "<gadget id=\"calculator\">4 + 1</gadget>\n<output>5</output>\n\n<result>5</result>", "result": "5", "source": "calc"}
|
||||
{"id": "ape210k__01121969", "question": "The fruit shop shipped 28 boxes of apples and pears each, each weighing 30 kg for pears and 25 kg for apples. How many kilograms of fruit does the fruit shop ship? (calculated in two ways)", "chain": "<gadget id=\"calculator\">28 * 30</gadget>\n<output>840</output>\n\n<gadget id=\"calculator\">28 * 25</gadget>\n<output>700</output>\n\n<gadget id=\"calculator\">840 + 700</gadget>\n<output>1_540</output>\n\n<result>1_540</result>", "result": "1_540", "source": "calc"}
|
||||
{"id": "ape210k__00910759", "question": "For a children's bicycle, the gear ratio of the front and rear gears is 12:7. If the rear gear rotates 24 times, how many times does the front gear rotate?", "chain": "<gadget id=\"calculator\">7 * 24</gadget>\n<output>168</output>\n\n<gadget id=\"calculator\">168 / 12</gadget>\n<output>14</output>\n\n<result>14</result>", "result": "14", "source": "calc"}
|
||||
{"id": "ape210k__01067071", "question": "If there is a basket of apples distributed among 6 people on average, there are 3 apples left. How many apples are there in this basket?", "chain": "<gadget id=\"calculator\">5 * 2 * 3</gadget>\n<output>30</output>\n\n<gadget id=\"calculator\">30 + 3</gadget>\n<output>33</output>\n\n<result>33</result>", "result": "33", "source": "calc"}
|
||||
{"id": "math_qa__nn3MNd29MSEsMQhG", "question": "Income and expenditure of a person are in the ratio 9 : 8. If the income of the person is Rs. 18000, then find his savings?\tChoose the correct choice:\nA) rs . 3600\nB) rs . 3603\nC) rs . 2000\nD) rs . 3632\nE) rs . 3602", "chain": "<gadget id=\"calculator\">8 / 9</gadget>\n<output>8/9 = around 0.888889</output>\n\n<gadget id=\"calculator\">(8/9) * 18_000</gadget>\n<output>16_000</output>\n\n<gadget id=\"calculator\">18_000 - 16_000</gadget>\n<output>2_000</output>\n\n<result>C</result>", "result": "C", "source": "calc"}
|
||||
{"id": "aqua_rat__ALWjr7wktcHDeMLJ", "question": "Twelve contestants at the county fair have entered their cakes to be judged in the cake decorating competition. A purple ribbon, blue ribbon, red ribbon, and white ribbon will be given to the first, second, third, and fourth place competitors, respectively. How many different ways are there to award the four ribbons to the contestants?\tAnswers\nA) 8!(4!*4!)\nB) 12!(8!*4!)\nC) 8!/4!\nD) 12!/8!\nE) 12!/4!", "chain": "The mistake you are doing is that you are neglecting the 4! ways in you can arrange 4 contestants for the 4 prizes. Number of ways you can select 4 people out of 12 = 12C4 Once you select the 4 people, you have the following arrangement, PBRW (PBRW being the 4 prizes) but the same group of people can also be chosen against BRWP etc. Thus you get 4! ways of arranging 4 prizes. Thus total possible ways = 12C4*4! = 12!/8!. 12!/8! is the correct answer.\n<result>D</result>", "result": "D", "source": "calc"}
|
||||
{"id": "ape210k__00947619", "question": "The material for the jacket is 1.55 meters per piece, and the material for the trousers is 1.05 meters per piece. How much rice cloth is needed to make a jacket and two trousers?", "chain": "<gadget id=\"calculator\">1.05 * 2</gadget>\n<output>2.1</output>\n\n<gadget id=\"calculator\">2.1 + 1.55</gadget>\n<output>3.65</output>\n\n<result>3.65</result>", "result": "3.65", "source": "calc"}
|
||||
{"id": "aqua_rat__cJf6UOtVHqOWRGHY", "question": "If m and n are positive integers of T such that m is a factor of n, how many positive multiples of m are less than or equal to 2n ?\tAnswers.\nA) 2m/n + 1 B) 2n/m + 1 C) 2n/(m+1) D) 2m/n E) 2n/m", "chain": "Lets say N=10, M=5 2N=20. so the answer should be 4 (20/5) lets try to plug in the answers: A-not an integer B-not an integer C-not an integer D-1 (not the answer) E-4 - the answer. (the only one). I would choose E. Method 2 N=M*A (A is an integer) So - A=N/M therefore in 2N A will be 2N/M Again - Answer is 2n/m.\n<result>E</result>", "result": "E", "source": "calc"}
|
||||
{"id": "ape210k__00628803", "question": "The fruit shop shipped 450 kilograms of papaya, of which Taiwan papaya accounted for (2/9), how many kilograms of Taiwan papaya?", "chain": "<gadget id=\"calculator\">2 / 9</gadget>\n<output>2/9 = around 0.222222</output>\n\n<gadget id=\"calculator\">450 * (2/9)</gadget>\n<output>100</output>\n\n<result>100</result>", "result": "100", "source": "calc"}
|
||||
{"id": "ape210k__00708383", "question": "If the radius of a circle is 1 cm, what is the circumference of its semicircle in cm?", "chain": "<gadget id=\"calculator\">1 / 2</gadget>\n<output>1/2 = around 0.5</output>\n\n<gadget id=\"calculator\">2 * 3.14 * (1/2)</gadget>\n<output>3.14</output>\n\n<gadget id=\"calculator\">2 * 1</gadget>\n<output>2</output>\n\n<gadget id=\"calculator\">3.14 + 2</gadget>\n<output>5.14</output>\n\n<result>5.14</result>", "result": "5.14", "source": "calc"}
|
||||
{"id": "ape210k__00256542", "question": "(1/(1*3))+(1/(3*5))+(1/(5*7))+...(1/(47*49)).", "chain": "<gadget id=\"calculator\">1 / 49</gadget>\n<output>1/49 = around 0.020408</output>\n\n<gadget id=\"calculator\">1 - (1/49)</gadget>\n<output>48/49 = around 0.979592</output>\n\n<gadget id=\"calculator\">1 / 2</gadget>\n<output>1/2 = around 0.5</output>\n\n<gadget id=\"calculator\">(48/49) * (1/2)</gadget>\n<output>24/49 = around 0.489796</output>\n\n<result>24/49 = around 0.489796</result>", "result": "24/49", "source": "calc"}
|
||||
{"id": "ape210k__00046559", "question": "An oil barrel is filled with half a barrel of oil, and after pouring out (3/5) of the oil, there is still 8 kilograms of oil left. How many kilograms of oil can this oil barrel hold?", "chain": "<gadget id=\"calculator\">3 / 5</gadget>\n<output>3/5 = around 0.6</output>\n\n<gadget id=\"calculator\">1 - (3/5)</gadget>\n<output>2/5 = around 0.4</output>\n\n<gadget id=\"calculator\">8 / (2/5)</gadget>\n<output>20</output>\n\n<gadget id=\"calculator\">20 * 2</gadget>\n<output>40</output>\n\n<result>40</result>", "result": "40", "source": "calc"}
|
||||
{"id": "ape210k__00838787", "question": "cuboid plastic box containing liquid medicine has a length of 0.6 meters, a width of 0.25 meters, and a depth of 0.5 meters. If the whole box of medicines is packed into small bottles that can hold 400 milliliters, how many bottles should this box contain at least?", "chain": "<gadget id=\"calculator\">100 / 400</gadget>\n<output>1/4 = around 0.25</output>\n\n<gadget id=\"calculator\">0.6 * 100 * 0.25 * 100 * 0.5 * (1/4) * 10</gadget>\n<output>1_875</output>\n\n<result>1_875</result>", "result": "1_875", "source": "calc"}
|
||||
{"id": "ape210k__01031589", "question": "Stack 2,100 cubes with side lengths of 1 cm to form a solid cuboid. Its height is 10 cm, and its length and width are greater than its height. What is the sum of the length and width of this cuboid in cm?", "chain": "<gadget id=\"calculator\">15 + 14</gadget>\n<output>29</output>\n\n<result>29</result>", "result": "29", "source": "calc"}
|
||||
{"id": "aqua_rat__7f9Pgjdk9qlo5Tf9", "question": "If the product 4864*9 P 2 is divisible by 12, the value of p?\nOptions\nA) 1 B) 5 C) 6 D) 7 E) 9", "chain": "Explanation: clearly 4864 is divisible by 4 So 9 P 2 must be divisible by 3.So(9+P+2) must be divisible by 3. so P=1. 1nswer: 1) 1\n<result>A</result>", "result": "A", "source": "calc"}
|
||||
{"id": "ape210k__00563400", "question": "It is known that \u22201+\u22202=150\u00b0, \u22201=67\u00b0, then \u22202=how much.", "chain": "<gadget id=\"calculator\">150 - 67</gadget>\n<output>83</output>\n\n<result>83</result>", "result": "83", "source": "calc"}
|
||||
{"id": "ape210k__00850129", "question": "The weight of an astronaut on the earth is 72 kg, which is 6 times his weight on the moon. How many kilograms would he weigh on the moon?", "chain": "<gadget id=\"calculator\">72 / 6</gadget>\n<output>12</output>\n\n<result>12</result>", "result": "12", "source": "calc"}
|
||||
{"id": "ape210k__00838563", "question": "In a bag of sugar, toffee accounts for 25% of the total number. After putting in 20 pieces of fruit candy, toffee accounts for 20% of the total number. How many pieces of toffee are there?", "chain": "<gadget id=\"calculator\">25 / 100</gadget>\n<output>1/4 = around 0.25</output>\n\n<gadget id=\"calculator\">80 * (1/4)</gadget>\n<output>20</output>\n\n<result>20</result>", "result": "20", "source": "calc"}
|
||||
{"id": "ape210k__00921605", "question": "To process a batch of parts, A alone can complete it in 9 days, and B alone can complete it in 12 days.", "chain": "<gadget id=\"calculator\">1 / 9</gadget>\n<output>1/9 = around 0.111111</output>\n\n<gadget id=\"calculator\">1 / 12</gadget>\n<output>1/12 = around 0.083333</output>\n\n<gadget id=\"calculator\">(1/9) + (1/12)</gadget>\n<output>7/36 = around 0.194444</output>\n\n<gadget id=\"calculator\">1 / (7/36)</gadget>\n<output>36/7 = around 5.142857</output>\n\n<result>36/7 = around 5.142857</result>", "result": "36/7", "source": "calc"}
|
||||
{"id": "ape210k__01057303", "question": "A batch of coal is shipped from the boiler room of a certain factory. According to the daily consumption of 150 kg of coal in the old boiler, it can be used for 120 days. If a new boiler is used, the coal consumption will be reduced by 20%. How many more days can this batch of coal be burned?", "chain": "<gadget id=\"calculator\">20 / 100</gadget>\n<output>1/5 = around 0.2</output>\n\n<gadget id=\"calculator\">1 - (1/5)</gadget>\n<output>4/5 = around 0.8</output>\n\n<gadget id=\"calculator\">150 * (4/5)</gadget>\n<output>120</output>\n\n<gadget id=\"calculator\">120 / 120</gadget>\n<output>1</output>\n\n<gadget id=\"calculator\">150 * 1</gadget>\n<output>150</output>\n\n<gadget id=\"calculator\">150 - 120</gadget>\n<output>30</output>\n\n<result>30</result>", "result": "30", "source": "calc"}
|
||||
{"id": "ape210k__00442282", "question": "Dissolve 1 gram of sugar in 10 grams of water, how much sugar accounts for the sugar water", "chain": "<gadget id=\"calculator\">1 + 10</gadget>\n<output>11</output>\n\n<gadget id=\"calculator\">1 / 11</gadget>\n<output>1/11 = around 0.090909</output>\n\n<result>1/11 = around 0.090909</result>", "result": "1/11", "source": "calc"}
|
||||
{"id": "ape210k__00030668", "question": "In the final exam, Dalang's average score in the four subjects was 90 before the English score was announced. After the English score was announced, his average score increased by 2 points. How many points did Dawa score in the English test?", "chain": "<gadget id=\"calculator\">90 + 2</gadget>\n<output>92</output>\n\n<gadget id=\"calculator\">92 * 5</gadget>\n<output>460</output>\n\n<gadget id=\"calculator\">90 * 4</gadget>\n<output>360</output>\n\n<gadget id=\"calculator\">460 - 360</gadget>\n<output>100</output>\n\n<result>100</result>", "result": "100", "source": "calc"}
|
||||
{"id": "ape210k__00295527", "question": "A project has been completed (2/3) by 8 workers in 24 days, and it needs to be completed in 28 days. (Continue to work) How many more people are needed?", "chain": "<gadget id=\"calculator\">2 / 3</gadget>\n<output>2/3 = around 0.666667</output>\n\n<gadget id=\"calculator\">1 - (2/3)</gadget>\n<output>1/3 = around 0.333333</output>\n\n<gadget id=\"calculator\">(2/3) / 8 / 24</gadget>\n<output>1/288 = around 0.003472</output>\n\n<gadget id=\"calculator\">28 - 24</gadget>\n<output>4</output>\n\n<gadget id=\"calculator\">(1/288) * 4</gadget>\n<output>1/72 = around 0.013889</output>\n\n<gadget id=\"calculator\">(1/3) / (1/72)</gadget>\n<output>24</output>\n\n<gadget id=\"calculator\">24 - 8</gadget>\n<output>16</output>\n\n<result>16</result>", "result": "16", "source": "calc"}
|
||||
{"id": "aqua_rat__tasT86j2f4Cpn4kL", "question": "750 students took the test on English and Maths. 35% students failed in english and 45% failed in maths. 40% of those who passed in maths also passed in english, then how many students failed in both ? Choose the correct choice:\nA) a) 162\nB) b) 15\nC) c) 60\nD) d) 38\nE) e) 12", "chain": "Passed in english = 65% Passed in maths = 55% Passed in both = 40% of 55% = 2/5 * (55%) = 22% Passed in (English + Maths - b) 15oth + Neither) 2/5 * (55 )= \n<gadget id=\"calculator\">2/5 * (55 )</gadget>\n<output>22</output>\n22% Passed in (English + Maths - b) 15oth + Neither)= 100% 65 + 55 - 22 + Neither = 100 Neither = 100 - 98= \n<gadget id=\"calculator\">100 - 98</gadget>\n<output>2</output>\n2%= 0.02 * 750= \n<gadget id=\"calculator\">0.02 * 750</gadget>\n<output>15</output>\n15 Answer: b) 15\n<result>B</result>", "result": "B", "source": "calc"}
|
||||
{"id": "aqua_rat__OeZ7ZLBAPlOfp6Wg", "question": "A batsman scored 130 runs which included 3 boundaries and 8 sixes. What percent of his total score did he make by running between the wickets? Choose the correct choice from the following answers.\nA) 45(4/11) % B) 45 % C) 53(11/13) % D) 44(5/11) % E) None of these", "chain": "Explanation : Total runs scored = 130 Total runs scored from boundaries and sixes = 3 x 4 + 8 x 6 = 60 Total runs scored by running between the wickets = 130 - 60= \n<gadget id=\"calculator\">130 - 60</gadget>\n<output>70</output>\n70 Required %= (70/130) \u00d7 100 = 700/13 = 53(11/13)% Answer : Option 53(11/13) %\n<result>C</result>", "result": "C", "source": "calc"}
|
||||
{"id": "ape210k__00084943", "question": "(19/20) minus (13/20), and how much to subtract, the difference is (1/5).", "chain": "<gadget id=\"calculator\">19 / 20</gadget>\n<output>19/20 = around 0.95</output>\n\n<gadget id=\"calculator\">13 / 20</gadget>\n<output>13/20 = around 0.65</output>\n\n<gadget id=\"calculator\">1 / 5</gadget>\n<output>1/5 = around 0.2</output>\n\n<gadget id=\"calculator\">(19/20) - (13/20) - (1/5)</gadget>\n<output>1/10 = around 0.1</output>\n\n<result>1/10 = around 0.1</result>", "result": "1/10", "source": "calc"}
|
||||
{"id": "ape210k__00010503", "question": "Add 8 trees equidistantly between the two trees, so that the distance between the first tree and the fifth tree is 20 meters, how many meters are the distance between the original two trees?", "chain": "<gadget id=\"calculator\">5 - 1</gadget>\n<output>4</output>\n\n<gadget id=\"calculator\">20 / 4</gadget>\n<output>5</output>\n\n<gadget id=\"calculator\">8 + 1</gadget>\n<output>9</output>\n\n<gadget id=\"calculator\">5 * 9</gadget>\n<output>45</output>\n\n<result>45</result>", "result": "45", "source": "calc"}
|
||||
{"id": "ape210k__00468021", "question": "A 2-meter-long iron wire uses up 4 decimeters, and the rest forms a square. What is the side length of the enclosed square?", "chain": "<gadget id=\"calculator\">2 * 10</gadget>\n<output>20</output>\n\n<gadget id=\"calculator\">20 - 4</gadget>\n<output>16</output>\n\n<gadget id=\"calculator\">16 / 4</gadget>\n<output>4</output>\n\n<result>4</result>", "result": "4", "source": "calc"}
|
||||
{"id": "ape210k__00041697", "question": "There is a rectangular vegetable field with a length of 35 meters and a width of 14 meters. What is the area of this vegetable field in square meters?", "chain": "<gadget id=\"calculator\">35 * 14</gadget>\n<output>490</output>\n\n<result>490</result>", "result": "490", "source": "calc"}
|
||||
{"id": "aqua_rat__sr81Rp7oIv2lWfLl", "question": "Rectangular tile each of size 80cm by 40cm must be laid horizontally on a rectangular floor of size 130cm by 230cm,such that the tiles do not overlap and they are placed with edges jutting against each other on all edges. A tile can be placed in any orientation so long as its edges are parallel to the edges of floor. No tile should overshoot any edge of the floor. The maximum number of tiles that can be accommodated on the floor is: Choose the correct option:\nA) 6\tB) 2\tC) 8\tD) 9\tE) 7", "chain": "Area of tile = 80*40= \n<gadget id=\"calculator\">80*40</gadget>\n<output>3_200</output>\n3200 Area of floor= 130*230= \n<gadget id=\"calculator\">130*230</gadget>\n<output>29_900</output>\n29900 No of tiles= 29900/3200 = 9.34 So, the no of tile = 9 ANSWER:9\n<result>D</result>", "result": "D", "source": "calc"}
|
||||
{"id": "ape210k__00607118", "question": "Xiaoxiao scored 92 in Chinese, 98 in mathematics, and 95 in English in the final test. What is her average score in the three subjects?", "chain": "<gadget id=\"calculator\">92 + 98 + 95</gadget>\n<output>285</output>\n\n<gadget id=\"calculator\">285 / 3</gadget>\n<output>95</output>\n\n<result>95</result>", "result": "95", "source": "calc"}
|
||||
{"id": "ape210k__00940095", "question": "The fruit store shipped 425 kilograms of apples, and the pears shipped were 80 kilograms less than four times the apples. How many kilograms of pears did the fruit store ship?", "chain": "<gadget id=\"calculator\">425 * 4</gadget>\n<output>1_700</output>\n\n<gadget id=\"calculator\">1_700 - 80</gadget>\n<output>1_620</output>\n\n<result>1_620</result>", "result": "1_620", "source": "calc"}
|
||||
{"id": "ape210k__00428406", "question": "If a cuboid has a base area of 80 cm2 and a height of 7 cm, what is its volume in cubic cm?", "chain": "<gadget id=\"calculator\">80 * 7</gadget>\n<output>560</output>\n\n<result>560</result>", "result": "560", "source": "calc"}
|
||||
{"id": "math_qa__Oc10lU6MVDif6R2Q", "question": "Matt and Peter can do together a piece of work in 20 days. After they have worked together for 12 days Matt stops and Peter completes the remaining work in 8 days. In how many days Peter complete the work separately.\nChoices:\nA) 21\nB) 24\nC) 20\nD) 25\nE) 30", "chain": "<gadget id=\"calculator\">1 / 20</gadget>\n<output>1/20 = around 0.05</output>\n\n<gadget id=\"calculator\">12 * (1/20)</gadget>\n<output>3/5 = around 0.6</output>\n\n<gadget id=\"calculator\">1 - (3/5)</gadget>\n<output>2/5 = around 0.4</output>\n\n<gadget id=\"calculator\">8 / (2/5)</gadget>\n<output>20</output>\n\n<result>C</result>", "result": "C", "source": "calc"}
|
||||
@@ -0,0 +1,331 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""This sample demonstrates the basic usage pattern of agent-framework-lab-lightning.
|
||||
|
||||
It trains a math agent using a dataset in `data/math/` to solve mathematical problems
|
||||
using an MCP calculator tool.
|
||||
|
||||
One GPU with 40GB of memory is sufficient for this sample.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
from typing import TypedDict, cast
|
||||
|
||||
import sympy # type: ignore[import-untyped,reportMissingImports]
|
||||
from agent_framework import Agent, AgentResponse, MCPStdioTool
|
||||
from agent_framework.lab.lightning import AgentFrameworkTracer
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agentlightning import LLM, Dataset, Trainer, rollout
|
||||
from agentlightning.algorithm.verl import VERL
|
||||
|
||||
|
||||
class MathProblem(TypedDict):
|
||||
"""This TypedDict defines the structure of each training sample.
|
||||
|
||||
Your task structure should contain all the information needed for:
|
||||
|
||||
- The agent to process the task (e.g., 'question')
|
||||
- Evaluation (e.g., 'result' for ground truth)
|
||||
|
||||
This type is optional. Not necessary to make the example work.
|
||||
"""
|
||||
|
||||
# The fields come from the dataset
|
||||
id: str
|
||||
question: str # The math problem for the agent to solve
|
||||
chain: str # Step-by-step solution (not used in training)
|
||||
result: str # Ground truth answer for evaluation
|
||||
source: str
|
||||
|
||||
|
||||
def _load_jsonl(file_path: str) -> Dataset[MathProblem]:
|
||||
"""Load your dataset as a list of task samples.
|
||||
|
||||
Each sample should match your task structure (MathProblem in this case).
|
||||
"""
|
||||
with open(file_path) as f:
|
||||
raw_data = [MathProblem(**json.loads(line)) for line in f]
|
||||
return cast(Dataset[MathProblem], raw_data)
|
||||
|
||||
|
||||
# Evaluation logic
|
||||
# These functions evaluate whether the agent's answer matches the ground truth.
|
||||
# Robust evaluation is crucial for RL training - the reward signal guides learning.
|
||||
|
||||
|
||||
def _normalize_option(option: str) -> str:
|
||||
return re.sub(r"(\s+|\(|\))", "", option)
|
||||
|
||||
|
||||
def _is_option_result(result: str) -> bool:
|
||||
return _normalize_option(result) in list(string.ascii_letters)
|
||||
|
||||
|
||||
def _float_eval(input_str: str) -> float:
|
||||
if " = around " in input_str:
|
||||
input_str = input_str.split(" = around ")[0]
|
||||
expr = sympy.parse_expr(input_str, evaluate=True)
|
||||
return float(expr.evalf())
|
||||
|
||||
|
||||
def _scalar_are_results_same(pred_result: str, true_result: str, rel_tol: float) -> bool:
|
||||
pred_result = str(pred_result) if pred_result is not None else ""
|
||||
true_result = str(true_result) if true_result is not None else ""
|
||||
|
||||
if pred_result.strip() == true_result.strip():
|
||||
return True
|
||||
|
||||
if _is_option_result(true_result):
|
||||
# The task is to select correct option
|
||||
true_result = _normalize_option(true_result)
|
||||
pred_result = _normalize_option(pred_result)
|
||||
return pred_result == true_result
|
||||
|
||||
# The task is to calculate the result as a number
|
||||
try:
|
||||
pred_float = _float_eval(pred_result)
|
||||
true_float = _float_eval(true_result)
|
||||
return math.isclose(pred_float, true_float, rel_tol=rel_tol)
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_result_correct(prediction: str, ground_truth: str) -> float:
|
||||
return float(_scalar_are_results_same(prediction, ground_truth, 1e-2))
|
||||
|
||||
|
||||
def evaluate(result: AgentResponse, ground_truth: str) -> float:
|
||||
"""Main evaluation function that extracts the agent's answer and compares with ground truth.
|
||||
|
||||
This function:
|
||||
1. Extracts the final answer from the agent's response (after ###)
|
||||
2. Compares it with the ground truth using mathematical equivalence
|
||||
3. Returns a reward score (0.0 or 1.0) for RL training
|
||||
|
||||
The reward signal is critical - it directly influences what the model learns.
|
||||
"""
|
||||
# Check if agent provided any response
|
||||
if len(result.messages) == 0:
|
||||
print("No response from agent. Assuming incorrect.")
|
||||
return 0.0
|
||||
final_message = result.messages[-1].text
|
||||
|
||||
# Extract answer after ### marker (as specified in agent instructions)
|
||||
answer = re.search(r"###\s*(.+?)(\s*###|$)", final_message)
|
||||
if answer is None:
|
||||
print("No answer can be extracted from agent's response. Assuming incorrect.")
|
||||
return 0.0
|
||||
answer = answer.group(1)
|
||||
|
||||
# Compare extracted answer with ground truth
|
||||
reward = _is_result_correct(answer, ground_truth)
|
||||
print(f"Reward: {reward}")
|
||||
return reward
|
||||
|
||||
|
||||
# Agent Logic
|
||||
|
||||
# Clear instructions are important for consistent agent behavior
|
||||
# The ### format helps with reliable answer extraction during evaluation
|
||||
AGENT_INSTRUCTION = """
|
||||
Solve the following math problem. Use the calculator tool to help you calculate math expressions.
|
||||
|
||||
Output the answer when you are ready. The answer should be after three sharps (`###`),
|
||||
with no extra punctuations or texts. For example: ### 123
|
||||
""".strip()
|
||||
|
||||
|
||||
# The @rollout decorator is the key integration point with agent-lightning.
|
||||
# It tells the training system that this function defines a trainable agent.
|
||||
@rollout
|
||||
async def math_agent(task: MathProblem, llm: LLM) -> float:
|
||||
"""This is your trainable agent function.
|
||||
|
||||
Key points:
|
||||
|
||||
1. Must be decorated with @rollout
|
||||
2. Takes a task sample and LLM object as parameters
|
||||
3. Returns a float reward score (0.0 to 1.0 typically)
|
||||
4. The LLM object contains the model being trained and its configuration
|
||||
|
||||
During training:
|
||||
- llm.model: The model checkpoint being trained
|
||||
- llm.endpoint: vLLM server endpoint for inference
|
||||
- llm.sampling_parameters: Temperature, etc.
|
||||
"""
|
||||
# Create the Agent Framework components
|
||||
# MCPStdioTool provides calculator functionality via MCP protocol
|
||||
async with (
|
||||
MCPStdioTool(name="calculator", command="uvx", args=["mcp-server-calculator"]) as mcp_server,
|
||||
Agent(
|
||||
client=OpenAIChatClient(
|
||||
model=llm.model, # This is the model being trained
|
||||
api_key=os.getenv("OPENAI_API_KEY") or "dummy", # Can be dummy when connecting to training LLM
|
||||
base_url=llm.endpoint, # vLLM server endpoint provided by agent-lightning
|
||||
),
|
||||
name="MathAgent",
|
||||
instructions=AGENT_INSTRUCTION,
|
||||
temperature=llm.sampling_parameters.get("temperature", 0.0),
|
||||
) as agent,
|
||||
):
|
||||
print(f"Task: {task['question'][:10]}...")
|
||||
# Run the agent on the task
|
||||
result = await agent.run(task["question"], tools=mcp_server)
|
||||
print(f"Agent responses: {result}")
|
||||
|
||||
# Evaluate and return reward - this is what drives RL training
|
||||
return evaluate(result, task["result"])
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entrypoint."""
|
||||
# Configure RL training
|
||||
# This configuration controls all aspects of the RL training process.
|
||||
# Key sections: algorithm, data, rollout, actor, trainer
|
||||
rl_training_config = {
|
||||
"algorithm": {
|
||||
# Advantage estimator type: "gae", "grpo", "reinforce_plus_plus", etc.
|
||||
"adv_estimator": "grpo"
|
||||
},
|
||||
"data": {
|
||||
# Uses this many tasks from the dataset to perform rollouts
|
||||
"train_batch_size": 8,
|
||||
# Used to filter out the over-long prompt-response pairs
|
||||
"max_prompt_length": 4096,
|
||||
"max_response_length": 1024,
|
||||
},
|
||||
"actor_rollout_ref": {
|
||||
# Controls the rollout process
|
||||
"rollout": {
|
||||
# Set to 1 unless you want to use TP in multiple GPUs
|
||||
"tensor_model_parallel_size": 1,
|
||||
# Repeat each task N many times. Required by G(rouped)RPO
|
||||
"n": 4,
|
||||
# Controls the batch size per GPU when computing the log-prob
|
||||
"log_prob_micro_batch_size_per_gpu": 2,
|
||||
# Controls the multi-turn format (this is binded to the LLM used)
|
||||
# See https://docs.vllm.ai/en/stable/features/tool_calling.html
|
||||
"multi_turn": {"format": "hermes"},
|
||||
# Only vllm is supported for now
|
||||
"name": "vllm",
|
||||
# Controls the GPU memory utilization of vLLM
|
||||
# You might want to set this to under 0.8 to prevent OOM
|
||||
"gpu_memory_utilization": 0.7,
|
||||
},
|
||||
"actor": {
|
||||
# Split each sample into sub-batches of this size for PPO
|
||||
"ppo_mini_batch_size": 8,
|
||||
# Local per-GPU micro batch size
|
||||
"ppo_micro_batch_size_per_gpu": 2,
|
||||
# Optimizer configuration
|
||||
"optim": {"lr": 1e-6},
|
||||
# Whether to use KL loss during training
|
||||
"use_kl_loss": False,
|
||||
# PPO clipping ratios for policy updates
|
||||
"clip_ratio_low": 0.2,
|
||||
"clip_ratio_high": 0.3,
|
||||
# FSDP (Fully Sharded Data Parallel) configuration for memory efficiency
|
||||
# Useful when you don't have enough GPU memory
|
||||
"fsdp_config": {
|
||||
# Whether to offload parameters to CPU
|
||||
"param_offload": True,
|
||||
# Whether to offload optimizer state to CPU
|
||||
"optimizer_offload": True,
|
||||
},
|
||||
},
|
||||
# Reference model config
|
||||
"ref": {
|
||||
# Controls the batch size per GPU when computing log-prob for reference model
|
||||
"log_prob_micro_batch_size_per_gpu": 2,
|
||||
"fsdp_config": {"param_offload": True},
|
||||
},
|
||||
# Common configs for the model
|
||||
"model": {
|
||||
# Huggingface model path.
|
||||
# If you want to train a different model, change the path here.
|
||||
"path": "Qwen/Qwen2.5-1.5B-Instruct",
|
||||
# Whether to remove padding tokens in inputs during training
|
||||
"use_remove_padding": True,
|
||||
# Enable gradient checkpointing for memory efficiency
|
||||
"enable_gradient_checkpointing": True,
|
||||
},
|
||||
},
|
||||
# Config for the trainer
|
||||
"trainer": {
|
||||
# Number of GPUs per node
|
||||
"n_gpus_per_node": 1,
|
||||
# Whether to run validation before training begins
|
||||
"val_before_train": True,
|
||||
# Logging backends to use: "console", "wandb", etc.
|
||||
"logger": ["console"],
|
||||
# Number of nodes used in the training
|
||||
"nnodes": 1,
|
||||
# Validation frequency (in training iterations)
|
||||
"test_freq": 4,
|
||||
# Number of epochs in training
|
||||
"total_epochs": 2,
|
||||
},
|
||||
}
|
||||
|
||||
# Load your datasets
|
||||
train_dataset = _load_jsonl("data/math/train.jsonl")
|
||||
val_dataset = _load_jsonl("data/math/test.jsonl")
|
||||
|
||||
# Preview the data to ensure it's loaded correctly
|
||||
print("First 5 rows of train dataset:")
|
||||
for i in range(5):
|
||||
print(train_dataset[i])
|
||||
print("First 5 rows of val dataset:")
|
||||
for i in range(5):
|
||||
print(val_dataset[i])
|
||||
|
||||
# Create trainer with VERL algorithm and start training
|
||||
# n_workers: Number of rollout workers (processes) for parallel data collection
|
||||
trainer = Trainer(algorithm=VERL(rl_training_config), tracer=AgentFrameworkTracer(), n_workers=2)
|
||||
|
||||
# This starts the actual RL training loop:
|
||||
# 1. Collect rollouts using current model
|
||||
# 2. Compute advantages and train the model
|
||||
# 3. Repeat for specified number of epochs
|
||||
trainer.fit(math_agent, train_dataset, val_dataset=val_dataset)
|
||||
|
||||
|
||||
def debug():
|
||||
"""Debug mode allows you to test your agent function before training.
|
||||
|
||||
Always run debug mode first before starting expensive RL training!
|
||||
"""
|
||||
train_dataset = _load_jsonl("data/math/train.jsonl")
|
||||
train_sample = train_dataset[0]
|
||||
|
||||
# Use a known good model for debugging (not the one being trained)
|
||||
model = "gpt-4o-mini"
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if api_key is None:
|
||||
raise ValueError("OPENAI_API_KEY must be set")
|
||||
if base_url is None:
|
||||
raise ValueError("OPENAI_BASE_URL must be set")
|
||||
|
||||
# Test your agent function with a sample task
|
||||
asyncio.run(math_agent(train_sample, LLM(model=model, endpoint=base_url))) # type: ignore
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--debug", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.debug:
|
||||
debug()
|
||||
else:
|
||||
main()
|
||||
@@ -0,0 +1,231 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Advanced example showing multi-agent RL training using Tau2 benchmark.
|
||||
|
||||
This demonstrates:
|
||||
- LitAgent class-based approach (vs @rollout decorator)
|
||||
- Multi-agent scenarios with agent filtering
|
||||
- Resource management for complex setups
|
||||
- Integration with external benchmarks
|
||||
|
||||
Builds on concepts from train_math_agent.py with additional complexity.
|
||||
Requires one GPU of at least 80GB of memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import TypedDict, cast
|
||||
|
||||
from agent_framework.lab.lightning import AgentFrameworkTracer
|
||||
from agent_framework.lab.tau2 import ASSISTANT_AGENT_ID, patch_env_set_state # type: ignore
|
||||
from agent_framework.lab.tau2 import TaskRunner as Tau2TaskRunner # type: ignore
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agentlightning import LLM, Dataset, LitAgent, NamedResources, Rollout, Trainer
|
||||
from agentlightning.algorithm.verl import VERL
|
||||
from tau2.data_model.tasks import Task as Tau2Task # type: ignore[import-untyped]
|
||||
|
||||
|
||||
# Tau2 tasks are complex objects that need special handling during distributed training
|
||||
class SerializedTask(TypedDict):
|
||||
"""Tau2 task object type."""
|
||||
|
||||
id: str
|
||||
data: str # JSON-serialized task data to prevent HuggingFace conversion issues
|
||||
|
||||
|
||||
def _load_dataset() -> tuple[Dataset[SerializedTask], Dataset[SerializedTask]]:
|
||||
"""Load and prepare Tau2 dataset with proper serialization.
|
||||
|
||||
It takes external data dependency (TAU2_DATA_DIR) and uses deterministic train/val split for reproducibility.
|
||||
"""
|
||||
data_dir = os.getenv("TAU2_DATA_DIR")
|
||||
if data_dir is None:
|
||||
raise ValueError("TAU2_DATA_DIR must be set")
|
||||
tasks_path = Path(data_dir) / "tau2/domains/airline/tasks.json"
|
||||
with tasks_path.open("r") as f:
|
||||
dataset = json.load(f)
|
||||
|
||||
# Serialize complex task objects to prevent HuggingFace tokenizer issues
|
||||
dataset = [{"id": task["id"], "data": json.dumps(task)} for task in dataset]
|
||||
|
||||
# Deterministic train/val split (25/25) for reproducible experiments
|
||||
random_state = random.Random(42) # noqa: S311
|
||||
indices = list(range(len(dataset)))
|
||||
random_state.shuffle(indices)
|
||||
train_indices = indices[: int(len(dataset) * 0.5)]
|
||||
val_indices = indices[int(len(dataset) * 0.5) :]
|
||||
print(f"Train indices: {train_indices}")
|
||||
print(f"Val indices: {val_indices}")
|
||||
train_dataset = [dataset[i] for i in train_indices]
|
||||
val_dataset = [dataset[i] for i in val_indices]
|
||||
|
||||
return cast(Dataset[SerializedTask], train_dataset), cast(Dataset[SerializedTask], val_dataset)
|
||||
|
||||
|
||||
# Alternative to @rollout: LitAgent class for advanced scenarios
|
||||
# Use this approach when you need:
|
||||
# - Agent filtering (training only specific agents in multi-agent setup)
|
||||
# - Resource management (multiple LLMs, databases, etc.)
|
||||
# - Complex initialization logic
|
||||
class Tau2Agent(LitAgent):
|
||||
"""Class-based agent with advanced resource management and agent filtering."""
|
||||
|
||||
async def rollout_async(self, task: SerializedTask, resources: NamedResources, rollout: Rollout) -> float:
|
||||
"""The main rollout method. Similar to @rollout but with more control."""
|
||||
llm = resources.get("main_llm")
|
||||
if not isinstance(llm, LLM):
|
||||
raise ValueError("main_llm must be an instance of LLM")
|
||||
|
||||
openai_base_url = os.getenv("OPENAI_BASE_URL")
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY")
|
||||
if openai_base_url is None:
|
||||
raise ValueError("OPENAI_BASE_URL must be set")
|
||||
if openai_api_key is None:
|
||||
raise ValueError("OPENAI_API_KEY must be set")
|
||||
|
||||
# Deserialize the complex task object
|
||||
task_data = json.loads(task["data"])
|
||||
task_obj = Tau2Task(**task_data)
|
||||
|
||||
# Multi-agent setup: assistant (trainable) + user simulator (fixed)
|
||||
runner = Tau2TaskRunner(
|
||||
max_steps=100,
|
||||
assistant_window_size=4000,
|
||||
assistant_sampling_temperature=llm.sampling_parameters.get("temperature", 0.0),
|
||||
)
|
||||
|
||||
# Assistant agent: uses the model being trained
|
||||
assistant_chat_client = OpenAIChatClient(
|
||||
base_url=llm.endpoint, # vLLM endpoint for the model being trained
|
||||
api_key=openai_api_key,
|
||||
model=llm.model, # Model ID being trained
|
||||
)
|
||||
|
||||
# User simulator: uses a fixed, capable model for consistent simulation
|
||||
user_simulator_chat_client = OpenAIChatClient(
|
||||
base_url=openai_base_url, # External API endpoint
|
||||
api_key=openai_api_key,
|
||||
model="gpt-4.1", # Fixed model for user simulator
|
||||
)
|
||||
|
||||
try:
|
||||
# Run the multi-agent conversation
|
||||
conversation = await runner.run(task_obj, assistant_chat_client, user_simulator_chat_client)
|
||||
except Exception:
|
||||
# Handle failures gracefully - assign low reward to discourage problematic behavior
|
||||
# Common issues: tool calling errors, timeout, invalid responses
|
||||
traceback.print_exc()
|
||||
return 0.0
|
||||
|
||||
# Use Tau2's built-in evaluation metrics
|
||||
evaluation = runner.evaluate(task_obj, conversation, runner.termination_reason)
|
||||
|
||||
# Return the evaluation score
|
||||
return evaluation # noqa: RET504
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entrypoint."""
|
||||
# RL config with higher resource requirements and W&B logging
|
||||
rl_training_config = {
|
||||
"algorithm": {"adv_estimator": "grpo"},
|
||||
"data": {
|
||||
"train_batch_size": 8,
|
||||
"max_prompt_length": 8192,
|
||||
"max_response_length": 2048,
|
||||
},
|
||||
"actor_rollout_ref": {
|
||||
"rollout": {
|
||||
"tensor_model_parallel_size": 1,
|
||||
"n": 8, # Higher repetition for more data per task
|
||||
"log_prob_micro_batch_size_per_gpu": 4,
|
||||
"multi_turn": {"format": "hermes"},
|
||||
"name": "vllm",
|
||||
"gpu_memory_utilization": 0.8, # Higher utilization for 80GB GPU
|
||||
},
|
||||
"actor": {
|
||||
"ppo_mini_batch_size": 8,
|
||||
"ppo_micro_batch_size_per_gpu": 4,
|
||||
"optim": {"lr": 1e-6},
|
||||
"use_kl_loss": False,
|
||||
"clip_ratio_low": 0.2,
|
||||
"clip_ratio_high": 0.3,
|
||||
"fsdp_config": {
|
||||
"param_offload": True,
|
||||
"optimizer_offload": True,
|
||||
},
|
||||
},
|
||||
# Reference model config
|
||||
"ref": {
|
||||
"log_prob_micro_batch_size_per_gpu": 8,
|
||||
"fsdp_config": {"param_offload": True},
|
||||
},
|
||||
# Common configs for the model
|
||||
"model": {
|
||||
"path": "Qwen/Qwen2.5-1.5B-Instruct",
|
||||
"use_remove_padding": True,
|
||||
"enable_gradient_checkpointing": True,
|
||||
},
|
||||
},
|
||||
"trainer": {
|
||||
"n_gpus_per_node": 1,
|
||||
"val_before_train": True,
|
||||
"logger": ["console", "wandb"], # Wandb for experiment tracking
|
||||
"project_name": "agent-framework-lab-lightning",
|
||||
"experiment_name": "tau2_agent",
|
||||
"nnodes": 1,
|
||||
"test_freq": 4,
|
||||
"total_epochs": 8,
|
||||
},
|
||||
}
|
||||
|
||||
patch_env_set_state() # Tau2-specific environment setup
|
||||
|
||||
train_dataset, val_dataset = _load_dataset()
|
||||
|
||||
# Key difference with math_agent: trained_agents parameter specifies which agents to train
|
||||
# Only the assistant agent is trained; user simulator remains fixed
|
||||
tau2_agent = Tau2Agent(trained_agents=ASSISTANT_AGENT_ID)
|
||||
|
||||
tracer = AgentFrameworkTracer()
|
||||
trainer = Trainer(algorithm=VERL(rl_training_config), tracer=tracer, n_workers=4)
|
||||
trainer.fit(tau2_agent, train_dataset, val_dataset=val_dataset)
|
||||
|
||||
|
||||
def debug():
|
||||
"""Debug mode for testing multi-agent setup and Tau2 integration."""
|
||||
train_dataset, _ = _load_dataset()
|
||||
tau2_agent = Tau2Agent(trained_agents=ASSISTANT_AGENT_ID)
|
||||
|
||||
openai_base_url = os.getenv("OPENAI_BASE_URL")
|
||||
if openai_base_url is None:
|
||||
raise ValueError("OPENAI_BASE_URL must be set")
|
||||
|
||||
patch_env_set_state() # Required for Tau2 environment
|
||||
|
||||
# Test with resources dict (different from @rollout LLM parameter)
|
||||
asyncio.run(
|
||||
tau2_agent.rollout_async(
|
||||
train_dataset[0],
|
||||
resources={"main_llm": LLM(model="gpt-4.1", endpoint=openai_base_url)},
|
||||
rollout=Rollout(rollout_id="dummy", input="dummy_input", start_time=time.time()),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--debug", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.debug:
|
||||
debug()
|
||||
else:
|
||||
main()
|
||||
@@ -0,0 +1,163 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for lightning module."""
|
||||
|
||||
# ruff: noqa
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import AgentExecutor, AgentResponse, Agent, WorkflowBuilder, Workflow
|
||||
from agent_framework.openai import OpenAIChatCompletionClient
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workflow_two_agents():
|
||||
"""Test a workflow with two OpenAI chat agents where first agent's result passes to second agent."""
|
||||
|
||||
# Mock OpenAI responses
|
||||
first_agent_response = ChatCompletion(
|
||||
id="chatcmpl-123",
|
||||
object="chat.completion",
|
||||
created=1677652288,
|
||||
model="gpt-4o",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(role="assistant", content="Analyzed data shows trend upward"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
second_agent_response = ChatCompletion(
|
||||
id="chatcmpl-456",
|
||||
object="chat.completion",
|
||||
created=1677652289,
|
||||
model="gpt-4o",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
role="assistant",
|
||||
content="Based on the analysis 'Analyzed data shows trend upward', I recommend investing",
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Create mock OpenAI clients
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"OPENAI_API_KEY": "test-key",
|
||||
"OPENAI_MODEL": "gpt-4o",
|
||||
},
|
||||
):
|
||||
first_chat_client = OpenAIChatCompletionClient()
|
||||
second_chat_client = OpenAIChatCompletionClient()
|
||||
|
||||
# Mock the OpenAI API calls
|
||||
with (
|
||||
patch.object(
|
||||
first_chat_client.client.chat.completions,
|
||||
"create",
|
||||
new_callable=AsyncMock,
|
||||
return_value=first_agent_response,
|
||||
),
|
||||
patch.object(
|
||||
second_chat_client.client.chat.completions,
|
||||
"create",
|
||||
new_callable=AsyncMock,
|
||||
return_value=second_agent_response,
|
||||
),
|
||||
):
|
||||
# Create the two agents
|
||||
analyzer_agent = Agent(
|
||||
client=first_chat_client,
|
||||
name="DataAnalyzer",
|
||||
instructions="You are a data analyst. Analyze the given data and provide insights.",
|
||||
)
|
||||
|
||||
advisor_agent = Agent(
|
||||
client=second_chat_client,
|
||||
name="InvestmentAdvisor",
|
||||
instructions="You are an investment advisor. Based on analysis results, provide recommendations.",
|
||||
)
|
||||
|
||||
analyzer_executor = AgentExecutor(id="analyzer", agent=analyzer_agent)
|
||||
advisor_executor = AgentExecutor(id="advisor", agent=advisor_agent)
|
||||
|
||||
# Build workflow: analyzer -> advisor
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=analyzer_executor).add_edge(analyzer_executor, advisor_executor).build()
|
||||
)
|
||||
|
||||
yield workflow
|
||||
|
||||
|
||||
async def test_openai_workflow_two_agents(workflow_two_agents: Workflow):
|
||||
events = await workflow_two_agents.run("Please analyze the quarterly sales data")
|
||||
|
||||
# Get all output events with AgentResponse
|
||||
agent_outputs = [event.data for event in events if event.type == "output" and isinstance(event.data, AgentResponse)]
|
||||
|
||||
# Check that we have outputs from both agents
|
||||
assert len(agent_outputs) == 2
|
||||
assert any("Analyzed data shows trend upward" in str(output) for output in agent_outputs)
|
||||
assert any(
|
||||
"Based on the analysis 'Analyzed data shows trend upward', I recommend investing" in str(output)
|
||||
for output in agent_outputs
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.resource_intensive
|
||||
async def test_observability(workflow_two_agents: Workflow):
|
||||
r"""Expected trace tree:
|
||||
|
||||
[workflow.run]
|
||||
/ \
|
||||
[analyzer] [advisor]
|
||||
/ \ / \
|
||||
[DataAnalyzer] [send] [Investment] [send]
|
||||
| |
|
||||
[chat gpt-4o] [chat gpt-4o]
|
||||
"""
|
||||
pytest.importorskip("agentlightning")
|
||||
from agent_framework_lab_lightning import AgentFrameworkTracer
|
||||
from agentlightning.adapter import TracerTraceToTriplet
|
||||
|
||||
tracer = AgentFrameworkTracer()
|
||||
try:
|
||||
tracer.init()
|
||||
tracer.init_worker(0)
|
||||
|
||||
async with tracer.trace_context():
|
||||
await workflow_two_agents.run("Please analyze the quarterly sales data")
|
||||
|
||||
triplets = TracerTraceToTriplet(agent_match=None, llm_call_match="chat").adapt(tracer.get_last_trace())
|
||||
assert len(triplets) == 2
|
||||
|
||||
triplets = TracerTraceToTriplet(agent_match="analyzer", llm_call_match="chat").adapt(tracer.get_last_trace())
|
||||
assert len(triplets) == 1
|
||||
|
||||
triplets = TracerTraceToTriplet(agent_match="advisor", llm_call_match="chat").adapt(tracer.get_last_trace())
|
||||
assert len(triplets) == 1
|
||||
|
||||
# Parent agent is not matched
|
||||
triplets = TracerTraceToTriplet(agent_match="DataAnalyzer", llm_call_match="chat").adapt(
|
||||
tracer.get_last_trace()
|
||||
)
|
||||
assert len(triplets) == 0
|
||||
|
||||
triplets = TracerTraceToTriplet(agent_match="InvestmentAdvisor|advisor", llm_call_match="chat").adapt(
|
||||
tracer.get_last_trace()
|
||||
)
|
||||
assert len(triplets) == 1
|
||||
|
||||
finally:
|
||||
tracer.teardown_worker(0)
|
||||
tracer.teardown()
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# This makes agent_framework a namespace package
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# This makes agent_framework.lab a namespace package
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||
@@ -0,0 +1,28 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# Import and re-export from the actual implementation
|
||||
from agent_framework_lab_gaia import (
|
||||
GAIA,
|
||||
Evaluation,
|
||||
Evaluator,
|
||||
GAIATelemetryConfig,
|
||||
Prediction,
|
||||
Task,
|
||||
TaskResult,
|
||||
TaskRunner,
|
||||
gaia_scorer,
|
||||
viewer_main,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GAIA",
|
||||
"Evaluation",
|
||||
"Evaluator",
|
||||
"GAIATelemetryConfig",
|
||||
"Prediction",
|
||||
"Task",
|
||||
"TaskResult",
|
||||
"TaskRunner",
|
||||
"gaia_scorer",
|
||||
"viewer_main",
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# Import and re-export from the actual implementation
|
||||
from agent_framework_lab_lightning import AgentFrameworkTracer
|
||||
|
||||
__all__ = ["AgentFrameworkTracer"]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# Import and re-export from the actual implementation
|
||||
from agent_framework_lab_tau2 import (
|
||||
ASSISTANT_AGENT_ID,
|
||||
ORCHESTRATOR_ID,
|
||||
USER_SIMULATOR_ID,
|
||||
TaskRunner,
|
||||
patch_env_set_state,
|
||||
unpatch_env_set_state,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ASSISTANT_AGENT_ID",
|
||||
"ORCHESTRATOR_ID",
|
||||
"USER_SIMULATOR_ID",
|
||||
"TaskRunner",
|
||||
"patch_env_set_state",
|
||||
"unpatch_env_set_state",
|
||||
]
|
||||
@@ -0,0 +1,171 @@
|
||||
[project]
|
||||
name = "agent-framework-lab"
|
||||
description = "Experimental modules for Microsoft Agent Framework"
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260709"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.11.0,<2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# GAIA benchmark module dependencies
|
||||
gaia = [
|
||||
"pydantic>=2.0.0",
|
||||
"opentelemetry-api>=1.39.0",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"tqdm>=4.60.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
"orjson>=3.10.7,<4",
|
||||
"pyarrow>=18.0.0", # For reading parquet files
|
||||
]
|
||||
|
||||
# Lightning RL training module dependencies
|
||||
lightning = [
|
||||
"agentlightning>=0.2.0,<0.4.0",
|
||||
]
|
||||
|
||||
# TAU2 benchmark module dependencies
|
||||
tau2 = [
|
||||
"pydantic>=2.0.0",
|
||||
"tiktoken>=0.11.0",
|
||||
"loguru>=0.7.3",
|
||||
"numpy",
|
||||
]
|
||||
|
||||
# Dependencies for math-related training
|
||||
math = [
|
||||
"sympy>=1.13.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"uv==0.11.28",
|
||||
"ruff==0.15.20",
|
||||
"pytest==9.1.1",
|
||||
"mypy==2.2.0",
|
||||
"pyright==1.1.411",
|
||||
#tasks
|
||||
"poethepoet==0.48.0",
|
||||
"rich>=13.7.1,<15.0.0",
|
||||
"tomli==2.4.1",
|
||||
"tomli-w==1.2.0",
|
||||
"prek==0.4.8",
|
||||
]
|
||||
# tau2 is an executable optional feature fetched from source because it is not available on PyPI.
|
||||
tau2 = [
|
||||
"tau2@ git+https://github.com/sierra-research/tau2-bench@5ba9e3e56db57c5e4114bf7f901291f09b2c5619",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
gaia_viewer = "agent_framework_lab_gaia:viewer_main"
|
||||
lightning = "agent_framework_lab_lightning:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=64", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = [
|
||||
"agent_framework_lab_gaia",
|
||||
"agent_framework_lab_lightning",
|
||||
"agent_framework_lab_tau2",
|
||||
"agent_framework.lab.gaia",
|
||||
"agent_framework.lab.lightning",
|
||||
"agent_framework.lab.tau2",
|
||||
]
|
||||
|
||||
[tool.setuptools.package-dir]
|
||||
"agent_framework_lab_gaia" = "gaia/agent_framework_lab_gaia"
|
||||
"agent_framework_lab_lightning" = "lightning/agent_framework_lab_lightning"
|
||||
"agent_framework_lab_tau2" = "tau2/agent_framework_lab_tau2"
|
||||
"agent_framework.lab.gaia" = "namespace/agent_framework/lab/gaia"
|
||||
"agent_framework.lab.lightning" = "namespace/agent_framework/lab/lightning"
|
||||
"agent_framework.lab.tau2" = "namespace/agent_framework/lab/tau2"
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
agent_framework_lab_gaia = ["py.typed"]
|
||||
agent_framework_lab_lightning = ["py.typed"]
|
||||
agent_framework_lab_tau2 = ["py.typed"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
extend-exclude = ["**/data/**"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = [
|
||||
"T201", # Allow print statements in experimental/lab code for debugging purposes.
|
||||
"ASYNC230", # Allow 'await' outside of async functions in test and experimental code.
|
||||
"INP001", # Ignore missing __init__.py in namespace packages.
|
||||
"RUF029", # Allow use of 'assert' statements; assertions are used for internal checks in experimental code.
|
||||
"ASYNC240", # Allow 'async for' outside of async functions in test and experimental code.
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["gaia/agent_framework_lab_gaia", "lightning/agent_framework_lab_lightning", "tau2/agent_framework_lab_tau2"]
|
||||
exclude = ['gaia/tests', 'lightning/tests', 'tau2/tests', 'namespace', '**/samples']
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_lab_gaia", "agent_framework_lab_lightning", "agent_framework_lab_tau2"]
|
||||
exclude_dirs = ["gaia/tests", "lightning/tests", "tau2/tests"]
|
||||
|
||||
[tool.poe]
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default lab unit test suite."
|
||||
cmd = 'pytest -m "not integration and not resource_intensive" --cov-report=term-missing:skip-covered --junitxml=test-results.xml'
|
||||
|
||||
[tool.poe.tasks.test-gaia]
|
||||
help = "Run the GAIA lab test suite."
|
||||
cmd = "pytest gaia/tests --cov=agent_framework_lab_gaia --cov-report=term-missing:skip-covered"
|
||||
|
||||
[tool.poe.tasks.test-lightning]
|
||||
help = "Run the Lightning lab test suite."
|
||||
cmd = "pytest lightning/tests --cov=agent_framework_lab_lightning --cov-report=term-missing:skip-covered"
|
||||
|
||||
[tool.poe.tasks.test-tau2]
|
||||
help = "Run the Tau2 lab test suite."
|
||||
cmd = "pytest tau2/tests --cov=agent_framework_lab_tau2 --cov-report=term-missing:skip-covered"
|
||||
|
||||
[tool.poe.tasks.build]
|
||||
help = "Skip build for the lab package."
|
||||
cmd = "echo 'Skipping build'"
|
||||
|
||||
[tool.poe.tasks.publish]
|
||||
help = "Skip publish for the lab package."
|
||||
cmd = "echo 'Skipping publish'"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
addopts = "--strict-markers --strict-config"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
markers = [
|
||||
"unit: marks tests as unit tests",
|
||||
"integration: marks tests as integration tests",
|
||||
"resource_intensive: marks tests that are expensive and excluded from default package test runs",
|
||||
]
|
||||
@@ -0,0 +1,198 @@
|
||||
# Agent Framework Lab - τ²-bench
|
||||
|
||||
τ²-bench implements a simulation framework for evaluating customer service agents across various domains.
|
||||
|
||||
> **Note**: This module is part of the consolidated `agent-framework-lab` package. Install the package with the `tau2` extra to use this module.
|
||||
|
||||
The framework orchestrates conversations between two AI agents:
|
||||
|
||||
- **Customer Service Agent**: Follows domain-specific policies and has access to tools (e.g., booking systems, databases)
|
||||
- **User Simulator**: Simulates realistic customer behavior with specific goals and scenarios
|
||||
|
||||
Each evaluation runs a multi-turn conversation where the user simulator presents a customer service scenario, and the agent must resolve it following the domain policy while using available tools appropriately. The results are evaluated using τ²'s comprehensive evaluation system.
|
||||
|
||||
## Supported Domains
|
||||
|
||||
| Domain | Status | Description |
|
||||
| ----------- | ----------------- | ---------------------------------------------------------- |
|
||||
| **airline** | ✅ Supported | Customer service for airline booking, changes, and support |
|
||||
| **retail** | 🚧 In Development | E-commerce customer support scenarios |
|
||||
| **telecom** | 🚧 In Development | Telecommunications service support |
|
||||
|
||||
_Note: Currently only the airline domain is fully supported._
|
||||
|
||||
## Installation
|
||||
|
||||
Install the agent-framework-lab package with TAU2 dependencies:
|
||||
|
||||
```bash
|
||||
pip install "agent-framework-lab[tau2]"
|
||||
```
|
||||
|
||||
**Important:** You must also install the tau2-bench package from source:
|
||||
|
||||
```bash
|
||||
pip install "tau2 @ git+https://github.com/sierra-research/tau2-bench@5ba9e3e56db57c5e4114bf7f901291f09b2c5619"
|
||||
```
|
||||
|
||||
Download data from [Tau2-Bench](https://github.com/sierra-research/tau2-bench):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sierra-research/tau2-bench.git
|
||||
mv tau2-bench/data/ .
|
||||
rm -rf tau2-bench
|
||||
```
|
||||
|
||||
Export the data directory to `TAU2_DATA_DIR` environment variable:
|
||||
|
||||
```bash
|
||||
export TAU2_DATA_DIR="data"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Running a Single Task
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.lab.tau2 import TaskRunner
|
||||
from tau2.domains.airline.environment import get_tasks
|
||||
|
||||
async def run_single_task():
|
||||
# Initialize the task runner
|
||||
runner = TaskRunner(max_steps=50)
|
||||
|
||||
# Set up your LLM clients
|
||||
assistant_client = OpenAIChatClient(
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="your-api-key",
|
||||
model="gpt-4o"
|
||||
)
|
||||
user_client = OpenAIChatClient(
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="your-api-key",
|
||||
model="gpt-4o-mini"
|
||||
)
|
||||
|
||||
# Get a task and run it
|
||||
tasks = get_tasks()
|
||||
task = tasks[0] # Run the first task
|
||||
|
||||
conversation = await runner.run(task, assistant_client, user_client)
|
||||
reward = runner.evaluate(task, conversation, runner.termination_reason)
|
||||
|
||||
print(f"Task completed with reward: {reward}")
|
||||
|
||||
# Run the example
|
||||
asyncio.run(run_single_task())
|
||||
```
|
||||
|
||||
### Running the Full Benchmark
|
||||
|
||||
Use the provided script to run the complete benchmark:
|
||||
|
||||
```bash
|
||||
# Run with default models (gpt-4.1 for both agent and user)
|
||||
python samples/run_benchmark.py
|
||||
|
||||
# Use custom models
|
||||
python samples/run_benchmark.py --assistant gpt-4o --user gpt-4o-mini
|
||||
|
||||
# Debug a specific task
|
||||
python samples/run_benchmark.py --debug-task-id task_001 --assistant gpt-4o
|
||||
|
||||
# Limit conversation length
|
||||
python samples/run_benchmark.py --max-steps 20
|
||||
```
|
||||
|
||||
## Results (on Airline Domain)
|
||||
|
||||
The following results are reproduced from our implementation of τ²-bench with `samples/run_benchmark.py`. It shows the average success rate over the dataset of 50 tasks.
|
||||
|
||||
| Agent Model | User Model | Success Rate |
|
||||
| ------------ | ----------- | ------------ |
|
||||
| gpt-5 | gpt-4.1 | 62.0% |
|
||||
| gpt-5-mini | gpt-4.1 | 52.0% |
|
||||
| gpt-4.1 | gpt-4.1 | 60.0% |
|
||||
| gpt-4.1-mini | gpt-4.1 | 50.0% |
|
||||
| gpt-4.1 | gpt-4o-mini | 42.0% |
|
||||
| gpt-4o | gpt-4.1 | 42.0% |
|
||||
| gpt-4o-mini | gpt-4.1 | 26.0% |
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
Set required environment variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="https://api.openai.com/v1"
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
|
||||
# Optional: for custom endpoints
|
||||
export OPENAI_BASE_URL="https://your-custom-endpoint.com/v1"
|
||||
```
|
||||
|
||||
### Custom Agent Implementation
|
||||
|
||||
```python
|
||||
from agent_framework.lab.tau2 import TaskRunner
|
||||
from agent_framework import Agent
|
||||
|
||||
class CustomTaskRunner(TaskRunner):
|
||||
def assistant_agent(self, assistant_chat_client):
|
||||
# Override to customize the assistant agent
|
||||
return Agent(
|
||||
client=assistant_chat_client,
|
||||
instructions="Your custom system prompt here",
|
||||
# Add custom tools, temperature, etc.
|
||||
)
|
||||
|
||||
def user_simulator(self, user_chat_client, task):
|
||||
# Override to customize the user simulator
|
||||
return Agent(
|
||||
client=user_chat_client,
|
||||
instructions="Custom user simulator prompt",
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Workflow Integration
|
||||
|
||||
```python
|
||||
from agent_framework import WorkflowBuilder, AgentExecutor
|
||||
from agent_framework.lab.tau2 import TaskRunner
|
||||
|
||||
class WorkflowTaskRunner(TaskRunner):
|
||||
def build_conversation_workflow(self, assistant_agent, user_simulator_agent):
|
||||
# Create agent executors
|
||||
assistant_executor = AgentExecutor(assistant_agent, id="assistant_agent")
|
||||
user_executor = AgentExecutor(user_simulator_agent, id="user_simulator")
|
||||
|
||||
# Build a custom workflow with start executor
|
||||
builder = WorkflowBuilder(start_executor=assistant_executor)
|
||||
builder.add_edge(assistant_executor, user_executor)
|
||||
builder.add_edge(user_executor, assistant_executor, condition=self.should_not_stop)
|
||||
|
||||
return builder.build()
|
||||
```
|
||||
|
||||
### Utility Functions
|
||||
|
||||
```python
|
||||
from agent_framework.lab.tau2 import patch_env_set_state, unpatch_env_set_state
|
||||
|
||||
# Enable compatibility patches for τ²-bench integration
|
||||
patch_env_set_state()
|
||||
|
||||
# Disable patches when done
|
||||
unpatch_env_set_state()
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This package is part of the Microsoft Agent Framework Lab. Please see the main repository for contribution guidelines.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tau2 Benchmark for Agent Framework."""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._tau2_utils import patch_env_set_state, unpatch_env_set_state
|
||||
from .runner import ASSISTANT_AGENT_ID, ORCHESTRATOR_ID, USER_SIMULATOR_ID, TaskRunner
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"ASSISTANT_AGENT_ID",
|
||||
"ORCHESTRATOR_ID",
|
||||
"USER_SIMULATOR_ID",
|
||||
"TaskRunner",
|
||||
"patch_env_set_state",
|
||||
"unpatch_env_set_state",
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework._types import Content, Message
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def _get_role_value(role: Any) -> str:
|
||||
"""Get the string value of a role, handling both enum and string."""
|
||||
return role.value if hasattr(role, "value") else str(role)
|
||||
|
||||
|
||||
def flip_messages(messages: list[Message]) -> list[Message]:
|
||||
"""Flip message roles between assistant and user for role-playing scenarios.
|
||||
|
||||
Used in agent simulations where the assistant's messages become user inputs
|
||||
and vice versa. Function calls are filtered out when flipping assistant
|
||||
messages to user messages (since users typically don't make function calls).
|
||||
"""
|
||||
|
||||
def filter_out_function_calls(messages: list[Content]) -> list[Content]:
|
||||
"""Remove function call content from message contents."""
|
||||
return [content for content in messages if content.type != "function_call"]
|
||||
|
||||
flipped_messages: list[Message] = []
|
||||
for msg in messages:
|
||||
role_value = _get_role_value(msg.role)
|
||||
if role_value == "assistant":
|
||||
# Flip assistant to user
|
||||
contents = filter_out_function_calls(msg.contents)
|
||||
if contents:
|
||||
flipped_msg = Message(
|
||||
role="user",
|
||||
# The function calls will cause 400 when role is user
|
||||
contents=contents,
|
||||
author_name=msg.author_name,
|
||||
message_id=msg.message_id,
|
||||
)
|
||||
flipped_messages.append(flipped_msg)
|
||||
elif role_value == "user":
|
||||
# Flip user to assistant
|
||||
flipped_msg = Message(
|
||||
role="assistant", contents=msg.contents, author_name=msg.author_name, message_id=msg.message_id
|
||||
)
|
||||
flipped_messages.append(flipped_msg)
|
||||
elif role_value == "tool":
|
||||
# Skip tool messages
|
||||
pass
|
||||
else:
|
||||
# Keep other roles as-is (system, tool, etc.)
|
||||
flipped_messages.append(msg)
|
||||
return flipped_messages
|
||||
|
||||
|
||||
def log_messages(messages: list[Message]) -> None:
|
||||
"""Log messages with colored output based on role and content type.
|
||||
|
||||
Provides visual debugging by color-coding different message roles and
|
||||
content types. Escapes HTML-like characters to prevent log formatting issues.
|
||||
"""
|
||||
logger_ = logger.opt(colors=True)
|
||||
for msg in messages:
|
||||
role_value = _get_role_value(msg.role)
|
||||
# Handle different content types
|
||||
if hasattr(msg, "contents") and msg.contents:
|
||||
for content in msg.contents:
|
||||
if hasattr(content, "type"):
|
||||
if content.type == "text":
|
||||
escape_text = content.text.replace("<", r"\<") # type: ignore[union-attr]
|
||||
if role_value == "system":
|
||||
logger_.info(f"<cyan>[SYSTEM]</cyan> {escape_text}")
|
||||
elif role_value == "user":
|
||||
logger_.info(f"<green>[USER]</green> {escape_text}")
|
||||
elif role_value == "assistant":
|
||||
logger_.info(f"<blue>[ASSISTANT]</blue> {escape_text}")
|
||||
elif role_value == "tool":
|
||||
logger_.info(f"<yellow>[TOOL]</yellow> {escape_text}")
|
||||
else:
|
||||
logger_.info(f"<magenta>[{role_value.upper()}]</magenta> {escape_text}")
|
||||
elif content.type == "function_call":
|
||||
function_call_text = f"{content.name}({content.arguments})"
|
||||
function_call_text = function_call_text.replace("<", r"\<")
|
||||
logger_.info(f"<yellow>[TOOL_CALL]</yellow> 🔧 {function_call_text}")
|
||||
elif content.type == "function_result":
|
||||
function_result_text = f"ID:{content.call_id} -> {content.result}"
|
||||
function_result_text = function_result_text.replace("<", r"\<")
|
||||
logger_.info(f"<yellow>[TOOL_RESULT]</yellow> 🔨 {function_result_text}")
|
||||
else:
|
||||
content_text = str(content).replace("<", r"\<")
|
||||
logger_.info(f"<magenta>[{role_value.upper()}] ({content.type})</magenta> {content_text}")
|
||||
else:
|
||||
# Fallback for content without type
|
||||
text_content = str(content).replace("<", r"\<")
|
||||
if role_value == "system":
|
||||
logger_.info(f"<cyan>[SYSTEM]</cyan> {text_content}")
|
||||
elif role_value == "user":
|
||||
logger_.info(f"<green>[USER]</green> {text_content}")
|
||||
elif role_value == "assistant":
|
||||
logger_.info(f"<blue>[ASSISTANT]</blue> {text_content}")
|
||||
elif role_value == "tool":
|
||||
logger_.info(f"<yellow>[TOOL]</yellow> {text_content}")
|
||||
else:
|
||||
logger_.info(f"<magenta>[{role_value.upper()}]</magenta> {text_content}")
|
||||
elif hasattr(msg, "text") and msg.text:
|
||||
# Handle simple text messages
|
||||
text_content = msg.text.replace("<", r"\<")
|
||||
if role_value == "system":
|
||||
logger_.info(f"<cyan>[SYSTEM]</cyan> {text_content}")
|
||||
elif role_value == "user":
|
||||
logger_.info(f"<green>[USER]</green> {text_content}")
|
||||
elif role_value == "assistant":
|
||||
logger_.info(f"<blue>[ASSISTANT]</blue> {text_content}")
|
||||
elif role_value == "tool":
|
||||
logger_.info(f"<yellow>[TOOL]</yellow> {text_content}")
|
||||
else:
|
||||
logger_.info(f"<magenta>[{role_value.upper()}]</magenta> {text_content}")
|
||||
else:
|
||||
# Fallback for other message formats
|
||||
text_content = str(msg).replace("<", r"\<")
|
||||
logger_.info(f"<magenta>[{role_value.upper()}]</magenta> {text_content}")
|
||||
@@ -0,0 +1,121 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import tiktoken
|
||||
from agent_framework import InMemoryHistoryProvider, Message
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class SlidingWindowHistoryProvider(InMemoryHistoryProvider):
|
||||
"""A token-aware sliding window implementation of InMemoryHistoryProvider.
|
||||
|
||||
Stores all messages in session state but returns a truncated window from
|
||||
``get_messages`` that fits within ``max_tokens``. Automatically removes
|
||||
oldest messages and leading tool messages to ensure valid conversation flow.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str = InMemoryHistoryProvider.DEFAULT_SOURCE_ID,
|
||||
*,
|
||||
max_tokens: int = 3800,
|
||||
system_message: str | None = None,
|
||||
tool_definitions: Any | None = None,
|
||||
):
|
||||
super().__init__(source_id)
|
||||
self.max_tokens = max_tokens
|
||||
self.system_message = system_message # Included in token count
|
||||
self.tool_definitions = tool_definitions
|
||||
# An estimation based on a commonly used vocab table
|
||||
self.encoding = tiktoken.get_encoding("o200k_base")
|
||||
|
||||
async def get_messages(
|
||||
self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any
|
||||
) -> list[Message]:
|
||||
"""Retrieve messages from session state, truncated to fit within max_tokens."""
|
||||
all_messages = await super().get_messages(session_id, state=state, **kwargs)
|
||||
return self._truncate(list(all_messages))
|
||||
|
||||
def _truncate(self, messages: list[Message]) -> list[Message]:
|
||||
"""Truncate messages to fit within max_tokens and remove leading tool messages."""
|
||||
while len(messages) > 0 and self._get_token_count(messages) > self.max_tokens:
|
||||
logger.warning("Messages exceed max tokens. Truncating oldest message.")
|
||||
messages.pop(0)
|
||||
# Remove leading tool messages
|
||||
while len(messages) > 0:
|
||||
if messages[0].role != "tool":
|
||||
break
|
||||
logger.warning("Removing leading tool message because tool result cannot be the first message.")
|
||||
messages.pop(0)
|
||||
return messages
|
||||
|
||||
def _get_token_count(self, messages: list[Message]) -> int:
|
||||
"""Estimate token count for a list of messages using tiktoken.
|
||||
|
||||
Returns:
|
||||
Estimated token count
|
||||
"""
|
||||
total_tokens = 0
|
||||
|
||||
# Add system message tokens if provided
|
||||
if self.system_message:
|
||||
total_tokens += len(self.encoding.encode(self.system_message))
|
||||
total_tokens += 4 # Extra tokens for system message formatting
|
||||
|
||||
for msg in messages:
|
||||
# Add 4 tokens per message for role, formatting, etc.
|
||||
total_tokens += 4
|
||||
|
||||
# Handle different content types
|
||||
if hasattr(msg, "contents") and msg.contents:
|
||||
for content in msg.contents:
|
||||
if hasattr(content, "type"):
|
||||
if content.type == "text":
|
||||
total_tokens += len(self.encoding.encode(content.text)) # type: ignore[arg-type]
|
||||
elif content.type == "function_call":
|
||||
total_tokens += 4
|
||||
# Serialize function call and count tokens
|
||||
func_call_data = {
|
||||
"name": content.name,
|
||||
"arguments": content.arguments,
|
||||
}
|
||||
total_tokens += self._estimate_any_object_token_count(func_call_data)
|
||||
elif content.type == "function_result":
|
||||
total_tokens += 4
|
||||
# Serialize function result and count tokens
|
||||
func_result_data = {
|
||||
"call_id": content.call_id,
|
||||
"result": content.result,
|
||||
}
|
||||
total_tokens += self._estimate_any_object_token_count(func_result_data)
|
||||
else:
|
||||
# For other content types, serialize the whole content
|
||||
total_tokens += self._estimate_any_object_token_count(content)
|
||||
else:
|
||||
# Content without type, treat as text
|
||||
total_tokens += self._estimate_any_object_token_count(content)
|
||||
elif hasattr(msg, "text") and msg.text:
|
||||
# Simple text message
|
||||
total_tokens += self._estimate_any_object_token_count(msg.text)
|
||||
|
||||
if total_tokens > self.max_tokens / 2:
|
||||
logger.opt(colors=True).warning(
|
||||
f"<yellow>Total tokens {total_tokens} is "
|
||||
f"{total_tokens / self.max_tokens * 100:.0f}% "
|
||||
f"of max tokens {self.max_tokens}</yellow>"
|
||||
)
|
||||
elif total_tokens > self.max_tokens:
|
||||
logger.opt(colors=True).warning(
|
||||
f"<red>Total tokens {total_tokens} is over max tokens {self.max_tokens}. Will truncate messages.</red>"
|
||||
)
|
||||
|
||||
return total_tokens
|
||||
|
||||
def _estimate_any_object_token_count(self, obj: Any) -> int:
|
||||
try:
|
||||
serialized = json.dumps(obj)
|
||||
except Exception:
|
||||
serialized = str(obj)
|
||||
return len(self.encoding.encode(serialized))
|
||||
@@ -0,0 +1,257 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from typing import Any, TypeGuard, cast
|
||||
|
||||
import numpy as np
|
||||
from agent_framework._tools import FunctionTool
|
||||
from agent_framework._types import Message
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from tau2.data_model.message import (
|
||||
AssistantMessage,
|
||||
SystemMessage,
|
||||
ToolCall,
|
||||
ToolMessage,
|
||||
UserMessage,
|
||||
)
|
||||
from tau2.data_model.message import (
|
||||
Message as Tau2Message,
|
||||
)
|
||||
from tau2.data_model.tasks import EnvFunctionCall, InitializationData
|
||||
from tau2.environment.environment import Environment
|
||||
from tau2.environment.tool import Tool
|
||||
|
||||
_original_set_state = Environment.set_state
|
||||
|
||||
|
||||
def _to_str(value: object, default: str = "") -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
return str(value)
|
||||
|
||||
|
||||
def _is_any_list(value: Any) -> TypeGuard[list[Any]]:
|
||||
return isinstance(value, list)
|
||||
|
||||
|
||||
def _is_any_mapping(value: Any) -> TypeGuard[Mapping[Any, Any]]:
|
||||
return isinstance(value, Mapping)
|
||||
|
||||
|
||||
def _is_any_sequence(value: Any) -> TypeGuard[list[Any] | tuple[Any, ...] | set[Any]]:
|
||||
return isinstance(value, (list, tuple, set))
|
||||
|
||||
|
||||
def convert_tau2_tool_to_function_tool(tau2_tool: Tool) -> FunctionTool:
|
||||
"""Convert a tau2 Tool to a FunctionTool for agent framework compatibility.
|
||||
|
||||
Creates a wrapper that preserves the tool's interface while ensuring
|
||||
results are deep-copied to prevent unintended mutations.
|
||||
"""
|
||||
|
||||
def wrapped_func(**kwargs: Any) -> Any:
|
||||
result = tau2_tool(**kwargs)
|
||||
# Deep copy to prevent mutations of returned data
|
||||
return result.model_copy(deep=True) if isinstance(result, BaseModel) else deepcopy(result)
|
||||
|
||||
return FunctionTool(
|
||||
name=tau2_tool.name,
|
||||
description=tau2_tool._get_description(), # pyright: ignore[reportPrivateUsage]
|
||||
func=wrapped_func,
|
||||
input_model=tau2_tool.params,
|
||||
)
|
||||
|
||||
|
||||
def convert_agent_framework_messages_to_tau2_messages(messages: list[Message]) -> list[Tau2Message]:
|
||||
"""Convert agent framework ChatMessages to tau2 Message objects.
|
||||
|
||||
Handles role mapping, text extraction, function calls, and function results.
|
||||
Function results are converted to separate ToolMessage instances.
|
||||
"""
|
||||
tau2_messages: list[Tau2Message] = []
|
||||
|
||||
for msg in messages:
|
||||
role_str = str(msg.role)
|
||||
|
||||
# Extract text content from all text-type contents
|
||||
text_contents = [c for c in msg.contents if hasattr(c, "text") and hasattr(c, "type") and c.type == "text"]
|
||||
content_parts: list[str] = [_to_str(getattr(c, "text", "")) for c in text_contents]
|
||||
content_value = " ".join(content_parts)
|
||||
|
||||
# Extract function calls and convert to ToolCall objects
|
||||
function_calls = [c for c in msg.contents if hasattr(c, "type") and c.type == "function_call"]
|
||||
tool_calls: list[ToolCall] | None = None
|
||||
if function_calls:
|
||||
tool_calls = []
|
||||
for fc in function_calls:
|
||||
arguments = fc.parse_arguments() or {}
|
||||
tool_call = ToolCall(
|
||||
id=_to_str(fc.call_id),
|
||||
name=_to_str(fc.name),
|
||||
arguments=arguments,
|
||||
requestor="assistant" if role_str == "assistant" else "user",
|
||||
)
|
||||
tool_calls.append(tool_call)
|
||||
|
||||
# Extract function results for separate ToolMessage creation
|
||||
function_results = [c for c in msg.contents if hasattr(c, "type") and c.type == "function_result"]
|
||||
|
||||
# Create main message based on role
|
||||
if role_str == "system":
|
||||
tau2_messages.append(SystemMessage(role="system", content=content_value))
|
||||
elif role_str == "user":
|
||||
tau2_messages.append(UserMessage(role="user", content=content_value, tool_calls=tool_calls))
|
||||
elif role_str == "assistant":
|
||||
tau2_messages.append(AssistantMessage(role="assistant", content=content_value, tool_calls=tool_calls))
|
||||
elif role_str == "tool":
|
||||
# Tool messages are handled as function results below
|
||||
pass
|
||||
|
||||
# Convert function results to separate ToolMessage instances
|
||||
for fr in function_results:
|
||||
dumpable_content = _dump_function_result(fr.result)
|
||||
content = dumpable_content if isinstance(dumpable_content, str) else json.dumps(dumpable_content)
|
||||
tool_msg = ToolMessage(
|
||||
id=_to_str(fr.call_id),
|
||||
role="tool",
|
||||
content=content,
|
||||
requestor="assistant", # Most tool calls originate from assistant
|
||||
error=fr.exception is not None,
|
||||
)
|
||||
tau2_messages.append(tool_msg)
|
||||
|
||||
return tau2_messages
|
||||
|
||||
|
||||
def patch_env_set_state() -> None:
|
||||
"""Patch Environment.set_state to allow inconsistent tool call results.
|
||||
|
||||
Modifies the original method to log warnings instead of raising errors
|
||||
when actual tool results differ from expected results, enabling more
|
||||
flexible testing and development workflows.
|
||||
"""
|
||||
|
||||
def set_state(
|
||||
self: Any,
|
||||
initialization_data: InitializationData | None,
|
||||
initialization_actions: list[EnvFunctionCall] | None,
|
||||
message_history: list[Tau2Message],
|
||||
) -> None:
|
||||
if self.solo_mode and any(isinstance(message, UserMessage) for message in message_history):
|
||||
raise ValueError("User messages are not allowed in solo mode")
|
||||
|
||||
def get_actions_from_messages(messages: list[Tau2Message]) -> list[tuple[ToolCall, ToolMessage]]:
|
||||
"""Get the actions from the messages."""
|
||||
messages = deepcopy(messages)[::-1]
|
||||
actions: list[tuple[ToolCall, ToolMessage]] = []
|
||||
while messages:
|
||||
message = messages.pop()
|
||||
if isinstance(message, ToolMessage):
|
||||
raise ValueError("Tool message not expected. Tool messages should always follow a tool call.")
|
||||
if isinstance(message, (AssistantMessage, UserMessage)) and message.is_tool_call():
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls is None:
|
||||
raise ValueError("Tool message expected. Got None.")
|
||||
for tc in tool_calls:
|
||||
if len(messages) == 0:
|
||||
raise ValueError("Tool message expected. Got None.")
|
||||
tm = messages.pop()
|
||||
if not isinstance(tm, ToolMessage):
|
||||
raise ValueError(f"Tool message expected. Got {type(tm)}")
|
||||
if tc.id != tm.id:
|
||||
raise ValueError(f"Tool call id mismatch. Got {tc.id} and {tm.id}")
|
||||
actions.append((tc, tm))
|
||||
|
||||
return actions
|
||||
|
||||
if initialization_data is not None:
|
||||
agent_data = cast(object, getattr(initialization_data, "agent_data", None))
|
||||
if isinstance(agent_data, dict):
|
||||
self.tools.update_db(cast(dict[str, Any], agent_data))
|
||||
|
||||
user_data = cast(object, getattr(initialization_data, "user_data", None))
|
||||
if isinstance(user_data, dict):
|
||||
self.user_tools.update_db(cast(dict[str, Any], user_data))
|
||||
|
||||
if initialization_actions is not None:
|
||||
for action in initialization_actions:
|
||||
self.run_env_function_call(action)
|
||||
|
||||
action_responses = get_actions_from_messages(message_history)
|
||||
for tool_call, expected_response in action_responses:
|
||||
response = self.get_response(tool_call)
|
||||
content = _recursive_json_deserialize(response.content)
|
||||
expected_content = _recursive_json_deserialize(expected_response.content)
|
||||
if content != expected_content:
|
||||
diff = f"Tool call:\n{tool_call}\n\nReturned:\n{response}\n\nExpected:\n{expected_response}"
|
||||
if isinstance(content, str) and content.startswith("Error:"):
|
||||
# If the tool call resulted in an error, the difference can be ignored
|
||||
logger.warning(f"Tool call resulted in an error. Ignoring the difference.\n{diff}")
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Tool call:\n{tool_call}\n\nReturned:\n{response}\n\nExpected:\n{expected_response}"
|
||||
)
|
||||
self.sync_tools()
|
||||
|
||||
Environment.set_state = set_state
|
||||
|
||||
|
||||
def unpatch_env_set_state() -> None:
|
||||
Environment.set_state = _original_set_state
|
||||
|
||||
|
||||
def _dump_function_result(result: Any) -> Any:
|
||||
if isinstance(result, BaseModel):
|
||||
return result.model_dump_json()
|
||||
if _is_any_list(result):
|
||||
return [_dump_function_result(item) for item in result]
|
||||
if isinstance(result, dict):
|
||||
result_dict = cast(dict[str, Any], result)
|
||||
return {k: _dump_function_result(v) for k, v in result_dict.items()}
|
||||
if result is None:
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def _to_native(obj: Any) -> Any:
|
||||
"""Convert data retrieved from Panquet to data usable in AGL server."""
|
||||
# 1) Arrays -> list (then recurse)
|
||||
if isinstance(obj, np.ndarray):
|
||||
return _to_native(obj.tolist())
|
||||
|
||||
# 2) NumPy scalar types -> Python scalars
|
||||
if isinstance(obj, np.generic):
|
||||
return _to_native(obj.item())
|
||||
|
||||
# 3) Dict-like -> dict
|
||||
if _is_any_mapping(obj):
|
||||
return {_to_native(k): _to_native(v) for k, v in obj.items()}
|
||||
|
||||
# 4) Lists/Tuples/Sets -> list
|
||||
if _is_any_sequence(obj):
|
||||
return [_to_native(x) for x in obj]
|
||||
|
||||
# 5) Anything else: leave as-is
|
||||
return obj
|
||||
|
||||
|
||||
def _recursive_json_deserialize(obj: Any) -> Any:
|
||||
"""Recursively deserialize a JSON object."""
|
||||
if isinstance(obj, str):
|
||||
try:
|
||||
deserialized = json.loads(obj)
|
||||
return _recursive_json_deserialize(deserialized)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return obj
|
||||
elif _is_any_list(obj):
|
||||
return [_recursive_json_deserialize(item) for item in obj]
|
||||
elif isinstance(obj, dict):
|
||||
typed_obj = cast(dict[str, Any], obj)
|
||||
return {k: _recursive_json_deserialize(v) for k, v in typed_obj.items()}
|
||||
else:
|
||||
return obj
|
||||
@@ -0,0 +1,440 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentExecutor,
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
AgentResponse,
|
||||
FunctionExecutor,
|
||||
InMemoryHistoryProvider,
|
||||
Message,
|
||||
SupportsChatGetResponse,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
)
|
||||
from loguru import logger
|
||||
from tau2.data_model.simulation import SimulationRun, TerminationReason
|
||||
from tau2.data_model.tasks import Task
|
||||
from tau2.domains.airline.environment import get_environment
|
||||
from tau2.evaluator.evaluator import EvaluationType, RewardInfo, evaluate_simulation
|
||||
from tau2.user.user_simulator import (
|
||||
OUT_OF_SCOPE,
|
||||
STOP,
|
||||
TRANSFER,
|
||||
get_global_user_sim_guidelines,
|
||||
)
|
||||
from tau2.utils.utils import get_now
|
||||
|
||||
from ._message_utils import flip_messages, log_messages
|
||||
from ._sliding_window import SlidingWindowHistoryProvider
|
||||
from ._tau2_utils import convert_agent_framework_messages_to_tau2_messages, convert_tau2_tool_to_function_tool
|
||||
|
||||
__all__ = ["ASSISTANT_AGENT_ID", "ORCHESTRATOR_ID", "USER_SIMULATOR_ID", "TaskRunner"]
|
||||
|
||||
|
||||
def _get_openai_schema(tool: Any) -> dict[str, Any]:
|
||||
schema = getattr(tool, "openai_schema", None)
|
||||
if isinstance(schema, dict):
|
||||
schema_dict = cast(dict[object, Any], schema)
|
||||
if all(isinstance(key, str) for key in schema_dict):
|
||||
return cast(dict[str, Any], schema_dict)
|
||||
raise TypeError(f"Tool {tool} does not expose a dict openai_schema")
|
||||
|
||||
|
||||
# Agent instructions matching tau2's LLMAgent
|
||||
ASSISTANT_AGENT_INSTRUCTION = """
|
||||
You are a customer service agent that helps the user according to the <policy> provided below.
|
||||
In each turn you can either:
|
||||
- Send a message to the user.
|
||||
- Make a tool call.
|
||||
You cannot do both at the same time.
|
||||
Try to be helpful and always follow the policy. Always make sure you generate valid JSON only.
|
||||
""".strip()
|
||||
|
||||
# Default first message from agent (matching tau2)
|
||||
DEFAULT_FIRST_AGENT_MESSAGE = "Hi! How can I help you today?"
|
||||
|
||||
# Constants of Agent executor IDs
|
||||
ASSISTANT_AGENT_ID = "assistant_agent"
|
||||
USER_SIMULATOR_ID = "user_simulator"
|
||||
ORCHESTRATOR_ID = "orchestrator"
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
"""Orchestrates task execution using agent framework workflows for tau2 benchmarks.
|
||||
|
||||
Manages conversation flow between assistant agents and user simulators,
|
||||
handles termination conditions, and evaluates performance using tau2 metrics.
|
||||
|
||||
Only "airline" domain is supported for now.
|
||||
"""
|
||||
|
||||
# State tracking
|
||||
step_count: int
|
||||
full_conversation: list[Message]
|
||||
termination_reason: TerminationReason | None
|
||||
full_reward_info: RewardInfo | None
|
||||
_final_user_message: list[Message] | None
|
||||
_assistant_executor: AgentExecutor | None
|
||||
_user_executor: AgentExecutor | None
|
||||
|
||||
# Configuration
|
||||
max_steps: int
|
||||
assistant_sampling_temperature: float
|
||||
assistant_window_size: int
|
||||
|
||||
def __init__(self, max_steps: int, assistant_sampling_temperature: float = 0.0, assistant_window_size: int = 32768):
|
||||
"""Initialize the TaskRunner.
|
||||
|
||||
Args:
|
||||
max_steps: The maximum number of steps to run.
|
||||
assistant_sampling_temperature: The sampling temperature for the assistant agent.
|
||||
assistant_window_size: The window size for the assistant agent.
|
||||
"""
|
||||
self.assistant_sampling_temperature = assistant_sampling_temperature
|
||||
self.assistant_window_size = assistant_window_size
|
||||
self.max_steps = max_steps
|
||||
self.reinit()
|
||||
|
||||
def reinit(self) -> TaskRunner:
|
||||
"""Reset all state for a new task run."""
|
||||
self.step_count = 0
|
||||
self.full_conversation = []
|
||||
self.termination_reason = None
|
||||
self.full_reward_info = None
|
||||
self._final_user_message = None
|
||||
self._assistant_executor = None
|
||||
self._user_executor = None
|
||||
logger.info("TaskRunner has been re-initialized.")
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return string representation of TaskRunner."""
|
||||
return (
|
||||
f"TaskRunner(max_steps={self.max_steps}, step_count={self.step_count}, "
|
||||
f"full_conversation_length={len(self.full_conversation)}, "
|
||||
f"termination_reason={self.termination_reason}, full_reward_info={self.full_reward_info})"
|
||||
)
|
||||
|
||||
def should_not_stop(self, response: AgentExecutorResponse) -> bool:
|
||||
"""Based on the response, check whether we should or not stop the conversation."""
|
||||
# Determine who sent this based on executor_id
|
||||
is_from_agent = response.executor_id == ASSISTANT_AGENT_ID
|
||||
is_from_user = response.executor_id == USER_SIMULATOR_ID
|
||||
|
||||
self.step_count += 1
|
||||
|
||||
logger.opt(colors=True).info(
|
||||
f"<bold>[Step {self.step_count}] Received the following response from "
|
||||
f"{'<blue>assistant</blue>' if is_from_agent else '<green>user</green>'}</bold>, "
|
||||
f"routing to {'<green>user</green>' if is_from_agent else '<blue>assistant</blue>'}:"
|
||||
)
|
||||
log_messages(response.agent_response.messages)
|
||||
|
||||
if self.step_count >= self.max_steps:
|
||||
logger.info(f"Max steps ({self.max_steps}) reached - terminating conversation")
|
||||
self.termination_reason = TerminationReason.MAX_STEPS
|
||||
# Terminate the workflow
|
||||
return False
|
||||
|
||||
response_text = response.agent_response.text
|
||||
if is_from_agent and self._is_agent_stop(response_text):
|
||||
logger.info("Agent requested stop - terminating conversation")
|
||||
self.termination_reason = TerminationReason.AGENT_STOP
|
||||
return False
|
||||
|
||||
if is_from_user and self._is_user_stop(response_text):
|
||||
logger.info(f"User requested stop with message: '{response_text}' - terminating conversation")
|
||||
self.termination_reason = TerminationReason.USER_STOP
|
||||
# The final user message won't appear in the assistant's message store,
|
||||
# because it will never arrive there.
|
||||
# We need to store it because it's needed for evaluation.
|
||||
self._final_user_message = flip_messages(response.agent_response.messages)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _is_agent_stop(self, _: str) -> bool:
|
||||
"""Check if agent wants to stop the conversation."""
|
||||
# Could check for specific stop tokens if agent uses them
|
||||
return False # Agent doesn't have explicit stop in this setup
|
||||
|
||||
def _is_user_stop(self, text: str) -> bool:
|
||||
"""Check if user wants to stop the conversation."""
|
||||
return STOP in text or TRANSFER in text or OUT_OF_SCOPE in text
|
||||
|
||||
def assistant_agent(self, assistant_chat_client: SupportsChatGetResponse) -> Agent:
|
||||
"""Create an assistant agent.
|
||||
|
||||
Users can override this method to provide a custom assistant agent.
|
||||
|
||||
Args:
|
||||
assistant_chat_client: The chat client for the assistant agent.
|
||||
|
||||
Returns:
|
||||
The assistant agent.
|
||||
"""
|
||||
# Initialize tau2 environment and extract tools/policy
|
||||
# This provides the domain-specific context (airline customer service in this case)
|
||||
env = get_environment()
|
||||
tools = env.get_tools() # Available actions the assistant can take
|
||||
policy = env.get_policy() # Guidelines the assistant must follow
|
||||
|
||||
logger.info(
|
||||
f"Environment has {len(env.get_tools())} tools: {', '.join([tool.name for tool in env.get_tools()])}"
|
||||
)
|
||||
|
||||
# Convert tau2 tools to agent framework FunctionTool format
|
||||
# This bridges the gap between tau2's tool system and agent framework's expectations
|
||||
tools = [convert_tau2_tool_to_function_tool(tool) for tool in tools]
|
||||
|
||||
# Combines general customer service behavior with specific policy guidelines
|
||||
assistant_system_prompt = f"""<instructions>
|
||||
{ASSISTANT_AGENT_INSTRUCTION}
|
||||
</instructions>
|
||||
<policy>
|
||||
{policy}
|
||||
</policy>"""
|
||||
|
||||
# Assistant agent has:
|
||||
# - Access to all domain tools (booking, cancellation, etc.)
|
||||
# - Sliding window memory to handle long conversations within token limits
|
||||
# - Temperature-controlled response generation
|
||||
return Agent(
|
||||
client=assistant_chat_client,
|
||||
instructions=assistant_system_prompt,
|
||||
tools=tools,
|
||||
default_options={"temperature": self.assistant_sampling_temperature},
|
||||
context_providers=[
|
||||
SlidingWindowHistoryProvider(
|
||||
system_message=assistant_system_prompt,
|
||||
tool_definitions=[_get_openai_schema(tool) for tool in tools],
|
||||
max_tokens=self.assistant_window_size,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def user_simulator(self, user_simuator_chat_client: SupportsChatGetResponse, task: Task) -> Agent:
|
||||
"""Create a user simulator agent.
|
||||
|
||||
Users can override this method to provide a custom user simulator agent.
|
||||
|
||||
Args:
|
||||
user_simuator_chat_client: The chat client for the user simulator agent.
|
||||
task: The task to be executed.
|
||||
|
||||
Returns:
|
||||
The user simulator agent.
|
||||
"""
|
||||
# User simulator follows tau2's guidelines for realistic customer behavior
|
||||
# No tools available - users typically don't have direct system access
|
||||
user_sim_guidelines = get_global_user_sim_guidelines(use_tools=False)
|
||||
|
||||
# User simulator prompt combines general guidelines with task-specific scenario
|
||||
user_sim_system_prompt = f"""{user_sim_guidelines}
|
||||
<scenario>
|
||||
{task.user_scenario.instructions}
|
||||
</scenario>"""
|
||||
|
||||
return Agent(
|
||||
client=user_simuator_chat_client,
|
||||
instructions=user_sim_system_prompt,
|
||||
default_options={"temperature": 0.0},
|
||||
# No sliding window for user simulator to maintain full conversation context
|
||||
# TODO(yuge): Consider adding user tools in future for more realistic scenarios
|
||||
)
|
||||
|
||||
async def conversation_orchestrator(
|
||||
self, response: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorRequest]
|
||||
) -> None:
|
||||
"""Orchestrate conversation flow between assistant and user simulator.
|
||||
|
||||
This is the central routing hub that:
|
||||
|
||||
1. Receives responses from either the assistant agent or user simulator
|
||||
2. Flips message roles to create proper conversation flow (assistant->user, user->assistant)
|
||||
3. Routes the flipped messages to the appropriate target agent
|
||||
4. Maintains the conversation loop until termination conditions are met
|
||||
|
||||
Args:
|
||||
response: The response from either assistant or user simulator agent
|
||||
ctx: Workflow context for sending messages to other executors
|
||||
"""
|
||||
# Flip message roles for proper conversation flow
|
||||
# Assistant messages become user messages and vice versa
|
||||
flipped = flip_messages(response.agent_response.messages)
|
||||
|
||||
# Determine source to route to correct target
|
||||
is_from_agent = response.executor_id == ASSISTANT_AGENT_ID
|
||||
|
||||
# Send flipped messages to the opposite agent
|
||||
# Critical: Target ID must be specified to prevent broadcasting to both agents
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=flipped, should_respond=True),
|
||||
target_id=USER_SIMULATOR_ID if is_from_agent else ASSISTANT_AGENT_ID,
|
||||
)
|
||||
|
||||
def build_conversation_workflow(self, assistant_agent: Agent, user_simulator_agent: Agent) -> Workflow:
|
||||
"""Build the conversation workflow.
|
||||
|
||||
Users can override this method to provide a custom conversation workflow.
|
||||
|
||||
Args:
|
||||
assistant_agent: The assistant agent.
|
||||
user_simulator_agent: The user simulator agent.
|
||||
|
||||
Returns:
|
||||
The conversation workflow.
|
||||
"""
|
||||
# STEP 1: Create workflow executors
|
||||
# Each executor wraps an agent or function for workflow orchestration
|
||||
self._assistant_executor = AgentExecutor(assistant_agent, id=ASSISTANT_AGENT_ID)
|
||||
self._user_executor = AgentExecutor(user_simulator_agent, id=USER_SIMULATOR_ID)
|
||||
orchestrator = FunctionExecutor(func=self.conversation_orchestrator, id=ORCHESTRATOR_ID)
|
||||
|
||||
# STEP 2: Build the conversation workflow
|
||||
# Creates a cyclic workflow: Orchestrator -> Assistant -> Orchestrator -> User -> Orchestrator...
|
||||
# The orchestrator acts as a message router that flips roles and routes to appropriate agent
|
||||
return (
|
||||
# Orchestrator manages the conversation flow
|
||||
WorkflowBuilder(max_iterations=10000, start_executor=orchestrator)
|
||||
.add_edge(orchestrator, self._assistant_executor) # Route messages to assistant
|
||||
.add_edge(
|
||||
self._assistant_executor, orchestrator, condition=self.should_not_stop
|
||||
) # Check termination after assistant
|
||||
.add_edge(orchestrator, self._user_executor) # Route messages to user simulator
|
||||
.add_edge(self._user_executor, orchestrator, condition=self.should_not_stop) # Check termination after user
|
||||
.build()
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
task: Task,
|
||||
assistant_chat_client: SupportsChatGetResponse,
|
||||
user_simulator_chat_client: SupportsChatGetResponse,
|
||||
) -> list[Message]:
|
||||
"""Run a tau2 task using workflow-based agent orchestration.
|
||||
|
||||
This method orchestrates a complex multi-agent simulation:
|
||||
|
||||
1. Sets up tau2 environment and converts tools for agent framework compatibility
|
||||
2. Creates two agents: assistant (with tools) and user simulator (without tools)
|
||||
3. Builds a workflow with orchestrated message routing between agents
|
||||
4. Manages conversation flow until termination conditions are met
|
||||
5. Returns complete conversation history for evaluation
|
||||
|
||||
Args:
|
||||
task: Tau2 task containing scenario, policy, and evaluation criteria
|
||||
assistant_chat_client: LLM client for the assistant agent
|
||||
user_simulator_chat_client: LLM client for the user simulator
|
||||
|
||||
Returns:
|
||||
Complete conversation history as Message list for evaluation
|
||||
"""
|
||||
logger.info(f"Starting workflow agent for task {task.id}: {task.description.purpose}") # type: ignore[unused-ignore]
|
||||
logger.info(f"Assistant chat client: {assistant_chat_client}")
|
||||
logger.info(f"User simulator chat client: {user_simulator_chat_client}")
|
||||
|
||||
# STEP 1: Create agents
|
||||
assistant_agent = self.assistant_agent(assistant_chat_client)
|
||||
user_simulator_agent = self.user_simulator(user_simulator_chat_client, task)
|
||||
|
||||
# STEP 2: Create the conversation workflow
|
||||
workflow = self.build_conversation_workflow(assistant_agent, user_simulator_agent)
|
||||
|
||||
# STEP 3: Initialize conversation with standard greeting
|
||||
# Matches tau2's expected conversation start pattern
|
||||
logger.info(f"Starting workflow with hardcoded greeting: '{DEFAULT_FIRST_AGENT_MESSAGE}'")
|
||||
|
||||
first_message = Message(role="assistant", contents=[DEFAULT_FIRST_AGENT_MESSAGE])
|
||||
initial_greeting = AgentExecutorResponse(
|
||||
executor_id=ASSISTANT_AGENT_ID,
|
||||
agent_response=AgentResponse(messages=[first_message]),
|
||||
full_conversation=[Message(role="assistant", contents=[DEFAULT_FIRST_AGENT_MESSAGE])],
|
||||
)
|
||||
|
||||
# STEP 4: Execute the workflow and collect results
|
||||
# The workflow runs until termination conditions are met (max steps, stop signals, etc.)
|
||||
await workflow.run(initial_greeting)
|
||||
|
||||
# STEP 5: Ensemble the conversation history needed for evaluation.
|
||||
# It's coming from three parts:
|
||||
# 1. The initial greeting
|
||||
# 2. The assistant's session state (full history, not just the truncated window)
|
||||
# 3. The final user message (if any)
|
||||
session_state: dict[str, Any] = self._assistant_executor._session.state # type: ignore
|
||||
all_messages: list[Message] = list(
|
||||
session_state.get(InMemoryHistoryProvider.DEFAULT_SOURCE_ID, {}).get("messages", [])
|
||||
)
|
||||
full_conversation = [first_message, *all_messages]
|
||||
if self._final_user_message is not None:
|
||||
full_conversation.extend(self._final_user_message)
|
||||
|
||||
logger.opt(colors=True).info(
|
||||
f"<green>WORKFLOW COMPLETED WITH {len(full_conversation)} MESSAGES. "
|
||||
f"Termination reason: {self.termination_reason}.</green>"
|
||||
)
|
||||
log_messages(full_conversation)
|
||||
|
||||
return full_conversation
|
||||
|
||||
def evaluate(
|
||||
self, task_input: Task, conversation: list[Message], termination_reason: TerminationReason | None
|
||||
) -> float:
|
||||
"""Evaluate agent performance using tau2's comprehensive evaluation system.
|
||||
|
||||
Bridges agent framework conversation results with tau2's evaluation pipeline.
|
||||
Considers task completion, policy adherence, conversation quality, and tool usage.
|
||||
|
||||
Args:
|
||||
task_input: Original tau2 task containing evaluation criteria
|
||||
conversation: Complete conversation history from workflow execution
|
||||
termination_reason: How/why the conversation ended (affects scoring)
|
||||
|
||||
Returns:
|
||||
Numeric reward score (0.0-1.0) representing overall performance
|
||||
|
||||
Side Effects:
|
||||
Stores detailed evaluation results in self.full_reward_info
|
||||
"""
|
||||
# Handle missing termination reason (can happen with unexpected workflow endings)
|
||||
if termination_reason is None:
|
||||
termination_reason = TerminationReason.TOO_MANY_ERRORS
|
||||
|
||||
# Convert agent framework ChatMessages to tau2 Message format for evaluation
|
||||
tau2_messages = convert_agent_framework_messages_to_tau2_messages(conversation)
|
||||
|
||||
# Package conversation and metadata for tau2's evaluation system
|
||||
simulation = SimulationRun(
|
||||
id=str(uuid.uuid4()), # Unique identifier for this evaluation run
|
||||
task_id=task_input.id, # Links evaluation back to original task
|
||||
start_time=get_now(), # Timestamp for evaluation records
|
||||
end_time=get_now(), # Duration is 0 since this is post-hoc evaluation
|
||||
duration=0.0,
|
||||
termination_reason=termination_reason, # Context for how conversation ended
|
||||
messages=tau2_messages, # The actual conversation to evaluate
|
||||
)
|
||||
|
||||
# Run comprehensive multi-dimensional evaluation
|
||||
# EvaluationType.ALL: evaluates task completion, policy adherence, conversation quality, ...
|
||||
# solo_mode=False: indicates multi-agent conversation (assistant + user simulator)
|
||||
self.full_reward_info = evaluate_simulation(
|
||||
simulation=simulation,
|
||||
task=task_input,
|
||||
evaluation_type=EvaluationType.ALL,
|
||||
solo_mode=False,
|
||||
domain="airline",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Evaluation completed - Reward: {self.full_reward_info.reward if self.full_reward_info else None}, "
|
||||
f"Info: {self.full_reward_info}"
|
||||
)
|
||||
return self.full_reward_info.reward if self.full_reward_info else 0.0
|
||||
@@ -0,0 +1,4 @@
|
||||
TAU2_DATA_DIR=/path/to/your/data
|
||||
|
||||
OPENAI_API_KEY=dummy
|
||||
OPENAI_BASE_URL=http://127.0.0.1:12345/
|
||||
@@ -0,0 +1,279 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.lab.tau2 import TaskRunner, patch_env_set_state
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from loguru import logger
|
||||
from tau2.domains.airline.environment import get_tasks
|
||||
|
||||
|
||||
def to_dumpable(result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert benchmark result to JSONL-serializable format.
|
||||
|
||||
Handles both successful runs and error cases, ensuring consistent output
|
||||
format for downstream analysis. Converts Pydantic models to dictionaries
|
||||
and extracts enum values for JSON compatibility.
|
||||
"""
|
||||
if "error" in result:
|
||||
# Error case: minimal structure with zero reward
|
||||
return {
|
||||
"id": result["task"].id,
|
||||
"error": result["error"],
|
||||
"evaluation": {
|
||||
"reward": 0.0, # Standard zero reward for failed runs
|
||||
},
|
||||
"config": result["config"],
|
||||
"task": result["task"].model_dump(),
|
||||
}
|
||||
# Success case: full result structure
|
||||
return {
|
||||
"id": result["task"].id,
|
||||
"evaluation": result["evaluation"].model_dump(), # Detailed evaluation metrics
|
||||
"config": result["config"], # Model configuration used
|
||||
"termination_reason": result["termination_reason"].value, # Enum to string
|
||||
"messages": [m.model_dump() for m in result["messages"]], # Full conversation
|
||||
"task": result["task"].model_dump(), # Task specification
|
||||
}
|
||||
|
||||
|
||||
async def run_benchmark(assistant_model: str, user_model: str, debug_task_id: str | None, max_steps: int):
|
||||
"""Run comprehensive tau2 benchmark evaluation using agent framework.
|
||||
|
||||
This is the main function that:
|
||||
|
||||
1. Sets up output file handling (full benchmark vs debug mode)
|
||||
2. Loads tau2 task dataset and configures LLM clients
|
||||
3. Runs each task through the agent framework workflow
|
||||
4. Evaluates performance using tau2's multi-dimensional metrics
|
||||
5. Aggregates results and calculates overall benchmark scores
|
||||
|
||||
Args:
|
||||
assistant_model: Model ID for the customer service agent (e.g., "gpt-4o")
|
||||
user_model: Model ID for the user simulator (e.g., "gpt-4o")
|
||||
debug_task_id: Optional specific task ID to run (disables batch processing)
|
||||
max_steps: Maximum conversation steps before forced termination
|
||||
|
||||
Output:
|
||||
Creates timestamped JSONL file with detailed results for analysis
|
||||
Prints summary statistics to console with colored logging
|
||||
"""
|
||||
# STEP 1: Configure output handling based on execution mode
|
||||
result_filename = None
|
||||
if debug_task_id is None:
|
||||
# Full benchmark mode: create timestamped results file
|
||||
timestamp = datetime.now().strftime("%m%d%H%M") # Format: MMDDHHMM
|
||||
result_filename = f"results/{assistant_model}_user-{user_model}_{timestamp}.jsonl"
|
||||
os.makedirs("results", exist_ok=True)
|
||||
logger.info(f"Results will be saved to: {result_filename}")
|
||||
else:
|
||||
# Debug mode: single task, no file output, verbose logging
|
||||
logger.info(f"Debug mode: targeting task ID {debug_task_id}")
|
||||
|
||||
# STEP 2: Load tau2 dataset and validate environment
|
||||
tasks = get_tasks() # Loads all tau2 airline customer service tasks
|
||||
logger.info(f"Found {len(tasks)} tasks in the dataset")
|
||||
|
||||
logger_ = logger.opt(colors=True) # Enable colored console output
|
||||
|
||||
# Validate required OpenAI configuration
|
||||
# Both models use the same endpoint but can be different model types
|
||||
openai_base_url = os.getenv("OPENAI_BASE_URL")
|
||||
if openai_base_url is None:
|
||||
raise ValueError("OPENAI_BASE_URL must be set")
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY")
|
||||
if openai_api_key is None:
|
||||
raise ValueError("OPENAI_API_KEY must be set")
|
||||
|
||||
# STEP 3: Initialize LLM clients for both agent roles
|
||||
# Assistant: handles customer service with access to tools and policies
|
||||
assistant_chat_client = OpenAIChatClient(
|
||||
base_url=openai_base_url,
|
||||
api_key=openai_api_key,
|
||||
model=assistant_model,
|
||||
)
|
||||
|
||||
# User simulator: simulates realistic customer behavior and requests
|
||||
user_simulator_chat_client = OpenAIChatClient(
|
||||
base_url=openai_base_url,
|
||||
api_key=openai_api_key,
|
||||
model=user_model,
|
||||
)
|
||||
|
||||
# STEP 4: Filter task set for debug mode
|
||||
if debug_task_id is not None:
|
||||
tasks = [task for task in tasks if task.id == debug_task_id]
|
||||
if not tasks:
|
||||
logger.error(f"Task ID {debug_task_id} not found in dataset")
|
||||
return
|
||||
|
||||
# STEP 5: Initialize evaluation tracking
|
||||
all_rewards: list[float] = [] # Stores reward scores for final statistics
|
||||
task_runner = TaskRunner(max_steps=max_steps) # Reusable workflow orchestrator
|
||||
|
||||
# STEP 6: Execute benchmark across all tasks with proper file handling
|
||||
def write_result(result_fp, result):
|
||||
"""Write result to file if file pointer is provided."""
|
||||
if result_fp is not None:
|
||||
result_fp.write(json.dumps(to_dumpable(result), default=str) + "\n")
|
||||
|
||||
# Use context manager for file handling
|
||||
if result_filename:
|
||||
with open(result_filename, "a") as result_fp:
|
||||
for task in tasks:
|
||||
logger_.info(f"<red>Testing task #{task.id}</red>")
|
||||
logger_.info(f"<cyan>Purpose:</cyan> {task.description.purpose}") # type: ignore
|
||||
|
||||
# Initialize result structure for this task
|
||||
result: dict[str, Any] = {
|
||||
"config": {
|
||||
"assistant": assistant_chat_client.model,
|
||||
"user": user_simulator_chat_client.model,
|
||||
},
|
||||
"task": task,
|
||||
}
|
||||
|
||||
# Log user scenario context for transparency
|
||||
if task.user_scenario and task.user_scenario.instructions:
|
||||
logger_.info(f"<cyan>User scenario:</cyan> {task.user_scenario.instructions.reason_for_call}") # type: ignore
|
||||
|
||||
try:
|
||||
# Execute the workflow: agent + user simulator conversation
|
||||
conversation = await task_runner.run(task, assistant_chat_client, user_simulator_chat_client)
|
||||
|
||||
# Evaluate performance using tau2's comprehensive metrics
|
||||
reward_value = task_runner.evaluate(task, conversation, task_runner.termination_reason)
|
||||
|
||||
# Store detailed results for analysis
|
||||
result["evaluation"] = task_runner.full_reward_info # Full evaluation breakdown
|
||||
result["messages"] = conversation # Complete conversation history
|
||||
result["termination_reason"] = task_runner.termination_reason # How conversation ended
|
||||
|
||||
# Log evaluation results (escape HTML for colored output)
|
||||
reward_str = str(task_runner.full_reward_info).replace("<", r"\<")
|
||||
logger_.info(f"<cyan>Final evaluation:</cyan> {reward_str}")
|
||||
|
||||
except Exception as e:
|
||||
# Robust error handling: capture all failures for analysis
|
||||
logger_.error(f"<red>Error testing task #{task.id}:</red> {e}")
|
||||
result["error"] = traceback.format_exc() # Full stack trace for debugging
|
||||
|
||||
traceback.print_exc() # Console output for immediate debugging
|
||||
reward_value = 0.0 # Zero score for failed runs
|
||||
|
||||
# STEP 7: Persist results incrementally (enables partial analysis)
|
||||
write_result(result_fp, result)
|
||||
|
||||
all_rewards.append(reward_value) # Track for final statistics
|
||||
|
||||
# Reset runner state for next task
|
||||
task_runner.reinit()
|
||||
else:
|
||||
# Debug mode without file output
|
||||
for task in tasks:
|
||||
logger_.info(f"<red>Testing task #{task.id}</red>")
|
||||
logger_.info(f"<cyan>Purpose:</cyan> {task.description.purpose}") # type: ignore
|
||||
|
||||
# Initialize result structure for this task
|
||||
result: dict[str, Any] = {
|
||||
"config": {
|
||||
"assistant": assistant_chat_client.model,
|
||||
"user": user_simulator_chat_client.model,
|
||||
},
|
||||
"task": task,
|
||||
}
|
||||
|
||||
# Log user scenario context for transparency
|
||||
if task.user_scenario and task.user_scenario.instructions:
|
||||
logger_.info(f"<cyan>User scenario:</cyan> {task.user_scenario.instructions.reason_for_call}") # type: ignore
|
||||
|
||||
try:
|
||||
# Execute the workflow: agent + user simulator conversation
|
||||
conversation = await task_runner.run(task, assistant_chat_client, user_simulator_chat_client)
|
||||
|
||||
# Evaluate performance using tau2's comprehensive metrics
|
||||
reward_value = task_runner.evaluate(task, conversation, task_runner.termination_reason)
|
||||
|
||||
# Log evaluation results (escape HTML for colored output)
|
||||
reward_str = str(task_runner.full_reward_info).replace("<", r"\<")
|
||||
logger_.info(f"<cyan>Final evaluation:</cyan> {reward_str}")
|
||||
|
||||
except Exception as e:
|
||||
# Robust error handling: capture all failures for analysis
|
||||
logger_.error(f"<red>Error testing task #{task.id}:</red> {e}")
|
||||
traceback.print_exc() # Console output for immediate debugging
|
||||
reward_value = 0.0 # Zero score for failed runs
|
||||
|
||||
all_rewards.append(reward_value) # Track for final statistics
|
||||
|
||||
# Reset runner state for next task
|
||||
task_runner.reinit()
|
||||
|
||||
# STEP 8: Calculate overall benchmark performance and report final statistics
|
||||
all_accuracy = sum(all_rewards) / len(all_rewards) if all_rewards else 0.0
|
||||
|
||||
# Report final statistics with colored formatting
|
||||
logger_.info("<green>Final Results:</green>")
|
||||
logger_.info(f"<cyan>All tasks accuracy:</cyan> {all_accuracy:.2f} ({int(sum(all_rewards))}/{len(tasks)})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Command-line interface for tau2 benchmark execution.
|
||||
|
||||
Provides flexible execution modes:
|
||||
|
||||
- Full benchmark: Runs all tasks and generates timestamped results file
|
||||
- Debug mode: Single task execution with verbose logging for development
|
||||
- Environment patching: Optional compatibility layer for tau2-bench integration
|
||||
|
||||
Usage Examples:
|
||||
# Full benchmark with default models
|
||||
python run_benchmark.py
|
||||
|
||||
# Custom models
|
||||
python run_benchmark.py --assistant gpt-4o --user gpt-4o-mini
|
||||
|
||||
# Debug specific task
|
||||
python run_benchmark.py --debug-task-id task_123
|
||||
|
||||
# Disable environment patching for testing
|
||||
python run_benchmark.py --disable-env-patch
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run tau2-agent-framework model test")
|
||||
|
||||
# Model configuration arguments
|
||||
parser.add_argument("--assistant", type=str, default="gpt-4.1", help="Assistant model id, e.g., gpt-4.1-mini")
|
||||
parser.add_argument("--user", type=str, default="gpt-4.1", help="User model id")
|
||||
|
||||
# Execution mode arguments
|
||||
parser.add_argument(
|
||||
"--debug-task-id", type=str, default=None, help="Debug a specific task ID (disables result file creation)"
|
||||
)
|
||||
parser.add_argument("--max-steps", type=int, default=100, help="Maximum number of steps to run")
|
||||
|
||||
# Environment configuration arguments
|
||||
parser.add_argument("--disable-env-patch", action="store_true", help="Disable patching tau2-bench environment")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Apply environment patch for tau2-bench compatibility
|
||||
# This modifies tau2's environment to be more flexible with tool call validation
|
||||
if not args.disable_env_patch:
|
||||
patch_env_set_state()
|
||||
|
||||
# Execute benchmark with configured parameters
|
||||
asyncio.run(
|
||||
run_benchmark(
|
||||
assistant_model=args.assistant,
|
||||
user_model=args.user,
|
||||
debug_task_id=args.debug_task_id,
|
||||
max_steps=args.max_steps,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,283 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from litellm import completion as _litellm_completion # noqa: F401
|
||||
except Exception:
|
||||
pytest.skip("LiteLLM import surface required by tau2 is unavailable.", allow_module_level=True)
|
||||
|
||||
from agent_framework._types import Content, Message
|
||||
from agent_framework_lab_tau2._message_utils import flip_messages, log_messages
|
||||
|
||||
|
||||
def test_flip_messages_user_to_assistant():
|
||||
"""Test flipping user message to assistant."""
|
||||
messages = [
|
||||
Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Hello assistant")],
|
||||
author_name="User1",
|
||||
message_id="msg_001",
|
||||
)
|
||||
]
|
||||
|
||||
flipped = flip_messages(messages)
|
||||
|
||||
assert len(flipped) == 1
|
||||
assert flipped[0].role == "assistant"
|
||||
assert flipped[0].text == "Hello assistant"
|
||||
assert flipped[0].author_name == "User1"
|
||||
assert flipped[0].message_id == "msg_001"
|
||||
|
||||
|
||||
def test_flip_messages_assistant_to_user():
|
||||
"""Test flipping assistant message to user."""
|
||||
messages = [
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_text(text="Hello user")],
|
||||
author_name="Assistant1",
|
||||
message_id="msg_002",
|
||||
)
|
||||
]
|
||||
|
||||
flipped = flip_messages(messages)
|
||||
|
||||
assert len(flipped) == 1
|
||||
assert flipped[0].role == "user"
|
||||
assert flipped[0].text == "Hello user"
|
||||
assert flipped[0].author_name == "Assistant1"
|
||||
assert flipped[0].message_id == "msg_002"
|
||||
|
||||
|
||||
def test_flip_messages_assistant_with_function_calls_filtered():
|
||||
"""Test that function calls are filtered out when flipping assistant to user."""
|
||||
function_call = Content.from_function_call(call_id="call_123", name="test_function", arguments={"param": "value"})
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_text(text="I'll call a function"),
|
||||
function_call,
|
||||
Content.from_text(text="After the call"),
|
||||
],
|
||||
message_id="msg_003",
|
||||
)
|
||||
]
|
||||
|
||||
flipped = flip_messages(messages)
|
||||
|
||||
assert len(flipped) == 1
|
||||
assert flipped[0].role == "user"
|
||||
# Function call should be filtered out
|
||||
assert len(flipped[0].contents) == 2
|
||||
assert all(content.type == "text" for content in flipped[0].contents)
|
||||
assert "I'll call a function" in flipped[0].text
|
||||
assert "After the call" in flipped[0].text
|
||||
|
||||
|
||||
def test_flip_messages_assistant_with_only_function_calls_skipped():
|
||||
"""Test that assistant messages with only function calls are skipped."""
|
||||
function_call = Content.from_function_call(call_id="call_456", name="another_function", arguments={"key": "value"})
|
||||
|
||||
messages = [
|
||||
Message(role="assistant", contents=[function_call], message_id="msg_004") # Only function call, no text
|
||||
]
|
||||
|
||||
flipped = flip_messages(messages)
|
||||
|
||||
# Should be empty since the message had no text content after filtering
|
||||
assert len(flipped) == 0
|
||||
|
||||
|
||||
def test_flip_messages_tool_messages_skipped():
|
||||
"""Test that tool messages are skipped."""
|
||||
function_result = Content.from_function_result(call_id="call_789", result={"success": True})
|
||||
|
||||
messages = [Message(role="tool", contents=[function_result])]
|
||||
|
||||
flipped = flip_messages(messages)
|
||||
|
||||
# Tool messages should be skipped
|
||||
assert len(flipped) == 0
|
||||
|
||||
|
||||
def test_flip_messages_system_messages_preserved():
|
||||
"""Test that system messages are preserved as-is."""
|
||||
messages = [Message(role="system", contents=[Content.from_text(text="System instruction")], message_id="sys_001")]
|
||||
|
||||
flipped = flip_messages(messages)
|
||||
|
||||
assert len(flipped) == 1
|
||||
assert flipped[0].role == "system"
|
||||
assert flipped[0].text == "System instruction"
|
||||
assert flipped[0].message_id == "sys_001"
|
||||
|
||||
|
||||
def test_flip_messages_mixed_conversation():
|
||||
"""Test flipping a mixed conversation."""
|
||||
function_call = Content.from_function_call(call_id="call_mixed", name="mixed_function", arguments={})
|
||||
|
||||
function_result = Content.from_function_result(call_id="call_mixed", result="function result")
|
||||
|
||||
messages = [
|
||||
Message(role="system", contents=[Content.from_text(text="System prompt")]),
|
||||
Message(role="user", contents=[Content.from_text(text="User question")]),
|
||||
Message(role="assistant", contents=[Content.from_text(text="Assistant response"), function_call]),
|
||||
Message(role="tool", contents=[function_result]),
|
||||
Message(role="assistant", contents=[Content.from_text(text="Final response")]),
|
||||
]
|
||||
|
||||
flipped = flip_messages(messages)
|
||||
|
||||
# Should have: system (unchanged), assistant (from user), user (from assistant, filtered),
|
||||
# assistant (from final assistant)
|
||||
assert len(flipped) == 4
|
||||
|
||||
# Check each flipped message
|
||||
assert flipped[0].role == "system"
|
||||
assert flipped[0].text == "System prompt"
|
||||
|
||||
assert flipped[1].role == "assistant"
|
||||
assert flipped[1].text == "User question"
|
||||
|
||||
assert flipped[2].role == "user"
|
||||
assert flipped[2].text == "Assistant response" # Function call filtered out
|
||||
|
||||
# Tool message skipped
|
||||
|
||||
assert flipped[3].role == "user"
|
||||
assert flipped[3].text == "Final response"
|
||||
|
||||
|
||||
def test_flip_messages_empty_list():
|
||||
"""Test flipping empty message list."""
|
||||
messages: list[Message] = []
|
||||
flipped = flip_messages(messages)
|
||||
assert len(flipped) == 0
|
||||
|
||||
|
||||
def test_flip_messages_preserves_metadata():
|
||||
"""Test that message metadata is preserved during flipping."""
|
||||
messages = [
|
||||
Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Test message")],
|
||||
author_name="TestUser",
|
||||
message_id="test_123",
|
||||
)
|
||||
]
|
||||
|
||||
flipped = flip_messages(messages)
|
||||
|
||||
assert len(flipped) == 1
|
||||
assert flipped[0].author_name == "TestUser"
|
||||
assert flipped[0].message_id == "test_123"
|
||||
|
||||
|
||||
@patch("agent_framework_lab_tau2._message_utils.logger")
|
||||
def test_log_messages_text_content(mock_logger):
|
||||
"""Test logging messages with text content."""
|
||||
messages = [
|
||||
Message(role="user", contents=[Content.from_text(text="Hello")]),
|
||||
Message(role="assistant", contents=[Content.from_text(text="Hi there!")]),
|
||||
]
|
||||
|
||||
log_messages(messages)
|
||||
|
||||
# Should have called logger.info for each message
|
||||
assert mock_logger.opt.return_value.info.call_count == 2
|
||||
|
||||
|
||||
@patch("agent_framework_lab_tau2._message_utils.logger")
|
||||
def test_log_messages_function_call(mock_logger):
|
||||
"""Test logging messages with function calls."""
|
||||
function_call = Content.from_function_call(call_id="call_log", name="log_function", arguments={"param": "value"})
|
||||
|
||||
messages = [Message(role="assistant", contents=[function_call])]
|
||||
|
||||
log_messages(messages)
|
||||
|
||||
# Should log the function call
|
||||
mock_logger.opt.return_value.info.assert_called()
|
||||
call_args = mock_logger.opt.return_value.info.call_args[0][0]
|
||||
assert "TOOL_CALL" in call_args
|
||||
assert "log_function" in call_args
|
||||
|
||||
|
||||
@patch("agent_framework_lab_tau2._message_utils.logger")
|
||||
def test_log_messages_function_result(mock_logger):
|
||||
"""Test logging messages with function results."""
|
||||
function_result = Content.from_function_result(call_id="call_result", result="success")
|
||||
|
||||
messages = [Message(role="tool", contents=[function_result])]
|
||||
|
||||
log_messages(messages)
|
||||
|
||||
# Should log the function result
|
||||
mock_logger.opt.return_value.info.assert_called()
|
||||
call_args = mock_logger.opt.return_value.info.call_args[0][0]
|
||||
assert "TOOL_RESULT" in call_args
|
||||
|
||||
|
||||
@patch("agent_framework_lab_tau2._message_utils.logger")
|
||||
def test_log_messages_different_roles(mock_logger):
|
||||
"""Test logging messages with different roles get different colors."""
|
||||
messages = [
|
||||
Message(role="system", contents=[Content.from_text(text="System")]),
|
||||
Message(role="user", contents=[Content.from_text(text="User")]),
|
||||
Message(role="assistant", contents=[Content.from_text(text="Assistant")]),
|
||||
Message(role="tool", contents=[Content.from_text(text="Tool")]),
|
||||
]
|
||||
|
||||
log_messages(messages)
|
||||
|
||||
# Should have called logger for each message
|
||||
assert mock_logger.opt.return_value.info.call_count == 4
|
||||
|
||||
# Check that different color tags are used
|
||||
calls = mock_logger.opt.return_value.info.call_args_list
|
||||
system_call = calls[0][0][0]
|
||||
user_call = calls[1][0][0]
|
||||
assistant_call = calls[2][0][0]
|
||||
tool_call = calls[3][0][0]
|
||||
|
||||
assert "cyan" in system_call or "SYSTEM" in system_call
|
||||
assert "green" in user_call or "USER" in user_call
|
||||
assert "blue" in assistant_call or "ASSISTANT" in assistant_call
|
||||
assert "yellow" in tool_call or "TOOL" in tool_call
|
||||
|
||||
|
||||
@patch("agent_framework_lab_tau2._message_utils.logger")
|
||||
def test_log_messages_escapes_html(mock_logger):
|
||||
"""Test that HTML-like characters are properly escaped in log output."""
|
||||
messages = [Message(role="user", contents=[Content.from_text(text="Message with <tag> content")])]
|
||||
|
||||
log_messages(messages)
|
||||
|
||||
mock_logger.opt.return_value.info.assert_called()
|
||||
call_args = mock_logger.opt.return_value.info.call_args[0][0]
|
||||
# Should escape < characters
|
||||
assert "\\<tag>" in call_args or "<tag>" in call_args
|
||||
|
||||
|
||||
@patch("agent_framework_lab_tau2._message_utils.logger")
|
||||
def test_log_messages_mixed_content_types(mock_logger):
|
||||
"""Test logging messages with mixed content types."""
|
||||
function_call = Content.from_function_call(call_id="mixed_call", name="mixed_function", arguments={"key": "value"})
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_text(text="I'll call a function"), function_call, Content.from_text(text="Done!")],
|
||||
)
|
||||
]
|
||||
|
||||
log_messages(messages)
|
||||
|
||||
# Should log multiple times for different content types
|
||||
assert mock_logger.opt.return_value.info.call_count == 3
|
||||
@@ -0,0 +1,213 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for sliding window history provider."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from litellm import completion as _litellm_completion # noqa: F401
|
||||
except Exception:
|
||||
pytest.skip("LiteLLM import surface required by tau2 is unavailable.", allow_module_level=True)
|
||||
|
||||
from agent_framework import InMemoryHistoryProvider
|
||||
from agent_framework._types import Content, Message
|
||||
from agent_framework_lab_tau2._sliding_window import SlidingWindowHistoryProvider
|
||||
|
||||
|
||||
def _make_state(provider: SlidingWindowHistoryProvider, messages: list[Message] | None = None) -> dict:
|
||||
"""Helper to create a session state dict with messages pre-loaded."""
|
||||
state: dict = {}
|
||||
if messages:
|
||||
state["messages"] = list(messages)
|
||||
return state
|
||||
|
||||
|
||||
def test_initialization():
|
||||
"""Test initializing with parameters."""
|
||||
provider = SlidingWindowHistoryProvider(
|
||||
max_tokens=2000,
|
||||
system_message="You are a helpful assistant",
|
||||
tool_definitions=[{"name": "test_tool"}],
|
||||
)
|
||||
|
||||
assert provider.max_tokens == 2000
|
||||
assert provider.system_message == "You are a helpful assistant"
|
||||
assert provider.tool_definitions == [{"name": "test_tool"}]
|
||||
assert provider.source_id == InMemoryHistoryProvider.DEFAULT_SOURCE_ID
|
||||
|
||||
|
||||
async def test_get_messages_empty():
|
||||
"""Test getting messages from empty state."""
|
||||
provider = SlidingWindowHistoryProvider(max_tokens=1000)
|
||||
messages = await provider.get_messages(None, state={})
|
||||
assert messages == []
|
||||
|
||||
|
||||
async def test_get_messages_simple():
|
||||
"""Test getting messages without truncation."""
|
||||
provider = SlidingWindowHistoryProvider(max_tokens=10000)
|
||||
msgs = [
|
||||
Message(role="user", contents=[Content.from_text(text="What's the weather?")]),
|
||||
Message(role="assistant", contents=[Content.from_text(text="I can help with that.")]),
|
||||
]
|
||||
state = _make_state(provider, msgs)
|
||||
|
||||
result = await provider.get_messages(None, state=state)
|
||||
assert len(result) == 2
|
||||
assert result[0].text == "What's the weather?"
|
||||
assert result[1].text == "I can help with that."
|
||||
|
||||
|
||||
async def test_save_and_get_messages():
|
||||
"""Test saving then getting messages with truncation."""
|
||||
provider = SlidingWindowHistoryProvider(max_tokens=50)
|
||||
state: dict = {}
|
||||
|
||||
# Save many messages
|
||||
msgs = [
|
||||
Message(role="user", contents=[Content.from_text(text=f"Message {i} with some content")]) for i in range(10)
|
||||
]
|
||||
await provider.save_messages(None, msgs, state=state)
|
||||
|
||||
# get_messages returns truncated
|
||||
truncated = await provider.get_messages(None, state=state)
|
||||
# Full history is in session state
|
||||
all_msgs = state["messages"]
|
||||
|
||||
assert len(all_msgs) == 10
|
||||
assert len(truncated) < len(all_msgs)
|
||||
|
||||
|
||||
def test_get_token_count_basic():
|
||||
"""Test basic token counting."""
|
||||
provider = SlidingWindowHistoryProvider(max_tokens=1000)
|
||||
messages = [Message(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
|
||||
token_count = provider._get_token_count(messages)
|
||||
assert token_count > 0
|
||||
|
||||
|
||||
def test_get_token_count_with_system_message():
|
||||
"""Test token counting includes system message."""
|
||||
provider = SlidingWindowHistoryProvider(max_tokens=1000, system_message="You are a helpful assistant")
|
||||
|
||||
count_empty = provider._get_token_count([])
|
||||
count_with_msg = provider._get_token_count([Message(role="user", contents=[Content.from_text(text="Hello")])])
|
||||
|
||||
assert count_with_msg > count_empty
|
||||
assert count_empty > 0 # System message contributes tokens
|
||||
|
||||
|
||||
def test_get_token_count_function_call():
|
||||
"""Test token counting with function calls."""
|
||||
function_call = Content.from_function_call(call_id="call_123", name="test_function", arguments={"param": "value"})
|
||||
provider = SlidingWindowHistoryProvider(max_tokens=1000)
|
||||
|
||||
token_count = provider._get_token_count([Message(role="assistant", contents=[function_call])])
|
||||
assert token_count > 0
|
||||
|
||||
|
||||
def test_get_token_count_function_result():
|
||||
"""Test token counting with function results."""
|
||||
function_result = Content.from_function_result(call_id="call_123", result={"success": True, "data": "result"})
|
||||
provider = SlidingWindowHistoryProvider(max_tokens=1000)
|
||||
|
||||
token_count = provider._get_token_count([Message(role="tool", contents=[function_result])])
|
||||
assert token_count > 0
|
||||
|
||||
|
||||
@patch("agent_framework_lab_tau2._sliding_window.logger")
|
||||
def test_truncate_removes_old_messages(mock_logger):
|
||||
"""Test that truncation removes old messages when token limit exceeded."""
|
||||
provider = SlidingWindowHistoryProvider(max_tokens=20)
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="This is a very long message that should exceed the token limit")],
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_text(text="This is another very long message that should also exceed the token limit")
|
||||
],
|
||||
),
|
||||
Message(role="user", contents=[Content.from_text(text="Short msg")]),
|
||||
]
|
||||
|
||||
result = provider._truncate(list(messages))
|
||||
assert len(result) < len(messages)
|
||||
assert mock_logger.warning.called
|
||||
|
||||
|
||||
@patch("agent_framework_lab_tau2._sliding_window.logger")
|
||||
def test_truncate_removes_leading_tool_messages(mock_logger):
|
||||
"""Test that truncation removes leading tool messages."""
|
||||
provider = SlidingWindowHistoryProvider(max_tokens=10000)
|
||||
|
||||
tool_message = Message(role="tool", contents=[Content.from_function_result(call_id="call_123", result="result")])
|
||||
user_message = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
|
||||
result = provider._truncate([tool_message, user_message])
|
||||
assert len(result) == 1
|
||||
assert result[0].role == "user"
|
||||
mock_logger.warning.assert_called()
|
||||
|
||||
|
||||
def test_estimate_any_object_token_count():
|
||||
"""Test token counting for various object types."""
|
||||
provider = SlidingWindowHistoryProvider(max_tokens=1000)
|
||||
|
||||
assert provider._estimate_any_object_token_count({"key": "value"}) > 0
|
||||
assert provider._estimate_any_object_token_count("test string") > 0
|
||||
|
||||
# Non-serializable falls back to str()
|
||||
class Custom:
|
||||
def __str__(self):
|
||||
return "Custom instance"
|
||||
|
||||
assert provider._estimate_any_object_token_count(Custom()) > 0
|
||||
|
||||
|
||||
async def test_real_world_scenario():
|
||||
"""Test a realistic conversation scenario."""
|
||||
provider = SlidingWindowHistoryProvider(max_tokens=30, system_message="You are a helpful assistant")
|
||||
state: dict = {}
|
||||
|
||||
conversation = [
|
||||
Message(role="user", contents=[Content.from_text(text="Hello, how are you?")]),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_text(text="I'm doing well, thank you! How can I help you today?")],
|
||||
),
|
||||
Message(role="user", contents=[Content.from_text(text="Can you tell me about the weather?")]),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_text(
|
||||
text="I'd be happy to help with weather information, "
|
||||
"but I don't have access to current weather data."
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(role="user", contents=[Content.from_text(text="What about telling me a joke instead?")]),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_text(text="Sure! Why don't scientists trust atoms? Because they make up everything!")
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
await provider.save_messages(None, conversation, state=state)
|
||||
|
||||
truncated = await provider.get_messages(None, state=state)
|
||||
all_msgs = state["messages"]
|
||||
|
||||
assert len(all_msgs) == 6
|
||||
assert len(truncated) <= 6
|
||||
|
||||
token_count = provider._get_token_count(truncated)
|
||||
assert token_count <= provider.max_tokens * 1.1
|
||||
@@ -0,0 +1,212 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for tau2 utils module."""
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from litellm import completion as _litellm_completion # noqa: F401
|
||||
except Exception:
|
||||
pytest.skip("LiteLLM import surface required by tau2 is unavailable.", allow_module_level=True)
|
||||
|
||||
from agent_framework import Content, FunctionTool, Message
|
||||
from agent_framework_lab_tau2._tau2_utils import (
|
||||
convert_agent_framework_messages_to_tau2_messages,
|
||||
convert_tau2_tool_to_function_tool,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from tau2.data_model.message import AssistantMessage, SystemMessage, ToolCall, ToolMessage, UserMessage
|
||||
|
||||
|
||||
class _DummyToolInput(BaseModel):
|
||||
param: str
|
||||
|
||||
|
||||
class _DummyToolResult(BaseModel):
|
||||
output: str
|
||||
|
||||
|
||||
class _DummyTau2Tool:
|
||||
def __init__(self, name: str, description: str) -> None:
|
||||
self.name = name
|
||||
self._description = description
|
||||
self.params = _DummyToolInput
|
||||
|
||||
def _get_description(self) -> str:
|
||||
return self._description
|
||||
|
||||
def __call__(self, **kwargs: str) -> _DummyToolResult:
|
||||
return _DummyToolResult(output=kwargs["param"])
|
||||
|
||||
|
||||
def test_convert_tau2_tool_to_function_tool_basic():
|
||||
"""Test basic conversion from tau2 tool to FunctionTool."""
|
||||
tau2_tool = _DummyTau2Tool(name="lookup_booking", description="Lookup booking by id.")
|
||||
|
||||
# Convert the tool
|
||||
tool = convert_tau2_tool_to_function_tool(tau2_tool) # ty: ignore[invalid-argument-type] # pyrefly: ignore[bad-argument-type] # pyright: ignore[reportArgumentType]
|
||||
|
||||
# Verify the conversion
|
||||
assert isinstance(tool, FunctionTool)
|
||||
assert tool.name == tau2_tool.name
|
||||
assert tool.description == tau2_tool._get_description()
|
||||
assert tool.input_model == tau2_tool.params
|
||||
|
||||
assert tool.func is not None
|
||||
result = tool.func(param="ABC123")
|
||||
assert isinstance(result, _DummyToolResult)
|
||||
assert result.output == "ABC123"
|
||||
assert callable(tool.func)
|
||||
|
||||
|
||||
def test_convert_tau2_tool_to_function_tool_multiple_tools():
|
||||
"""Test conversion with multiple tau2 tools."""
|
||||
tools = [
|
||||
_DummyTau2Tool(name="lookup_booking", description="Lookup booking by id."),
|
||||
_DummyTau2Tool(name="cancel_booking", description="Cancel an existing booking."),
|
||||
_DummyTau2Tool(name="check_policy", description="Get policy details."),
|
||||
]
|
||||
|
||||
# Convert multiple tools
|
||||
function_tools = [convert_tau2_tool_to_function_tool(tool) for tool in tools] # ty: ignore[invalid-argument-type] # pyrefly: ignore[bad-argument-type] # pyright: ignore[reportArgumentType]
|
||||
|
||||
# Verify all conversions
|
||||
for tool, tau2_tool in zip(function_tools, tools, strict=False):
|
||||
assert isinstance(tool, FunctionTool)
|
||||
assert tool.name == tau2_tool.name
|
||||
assert tool.description == tau2_tool._get_description()
|
||||
assert tool.input_model == tau2_tool.params
|
||||
assert callable(tool.func)
|
||||
|
||||
|
||||
def test_convert_agent_framework_messages_to_tau2_messages_system():
|
||||
"""Test converting system message."""
|
||||
messages = [Message(role="system", contents=[Content.from_text(text="System instruction")])]
|
||||
|
||||
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
|
||||
|
||||
assert len(tau2_messages) == 1
|
||||
assert isinstance(tau2_messages[0], SystemMessage)
|
||||
assert tau2_messages[0].role == "system"
|
||||
assert tau2_messages[0].content == "System instruction"
|
||||
|
||||
|
||||
def test_convert_agent_framework_messages_to_tau2_messages_user():
|
||||
"""Test converting user message."""
|
||||
messages = [Message(role="user", contents=[Content.from_text(text="Hello assistant")])]
|
||||
|
||||
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
|
||||
|
||||
assert len(tau2_messages) == 1
|
||||
assert isinstance(tau2_messages[0], UserMessage)
|
||||
assert tau2_messages[0].role == "user"
|
||||
assert tau2_messages[0].content == "Hello assistant"
|
||||
assert tau2_messages[0].tool_calls is None
|
||||
|
||||
|
||||
def test_convert_agent_framework_messages_to_tau2_messages_assistant():
|
||||
"""Test converting assistant message."""
|
||||
messages = [Message(role="assistant", contents=[Content.from_text(text="Hello user")])]
|
||||
|
||||
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
|
||||
|
||||
assert len(tau2_messages) == 1
|
||||
assert isinstance(tau2_messages[0], AssistantMessage)
|
||||
assert tau2_messages[0].role == "assistant"
|
||||
assert tau2_messages[0].content == "Hello user"
|
||||
assert tau2_messages[0].tool_calls is None
|
||||
|
||||
|
||||
def test_convert_agent_framework_messages_to_tau2_messages_with_function_call():
|
||||
"""Test converting message with function call."""
|
||||
function_call = Content.from_function_call(call_id="call_123", name="test_function", arguments={"param": "value"})
|
||||
|
||||
messages = [Message(role="assistant", contents=[Content.from_text(text="I'll call a function"), function_call])]
|
||||
|
||||
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
|
||||
|
||||
assert len(tau2_messages) == 1
|
||||
assert isinstance(tau2_messages[0], AssistantMessage)
|
||||
assert tau2_messages[0].content == "I'll call a function"
|
||||
assert tau2_messages[0].tool_calls is not None
|
||||
assert len(tau2_messages[0].tool_calls) == 1
|
||||
|
||||
tool_call = tau2_messages[0].tool_calls[0]
|
||||
assert isinstance(tool_call, ToolCall)
|
||||
assert tool_call.id == "call_123"
|
||||
assert tool_call.name == "test_function"
|
||||
assert tool_call.arguments == {"param": "value"}
|
||||
assert tool_call.requestor == "assistant"
|
||||
|
||||
|
||||
def test_convert_agent_framework_messages_to_tau2_messages_with_function_result():
|
||||
"""Test converting message with function result."""
|
||||
function_result = Content.from_function_result(call_id="call_123", result={"success": True, "data": "result data"})
|
||||
|
||||
messages = [Message(role="tool", contents=[function_result])]
|
||||
|
||||
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
|
||||
|
||||
assert len(tau2_messages) == 1
|
||||
assert isinstance(tau2_messages[0], ToolMessage)
|
||||
assert tau2_messages[0].id == "call_123"
|
||||
assert tau2_messages[0].role == "tool"
|
||||
assert tau2_messages[0].content is not None
|
||||
assert '{"success": true, "data": "result data"}' in tau2_messages[0].content
|
||||
assert tau2_messages[0].requestor == "assistant"
|
||||
assert tau2_messages[0].error is False
|
||||
|
||||
|
||||
def test_convert_agent_framework_messages_to_tau2_messages_with_error():
|
||||
"""Test converting function result with error."""
|
||||
function_result = Content.from_function_result(call_id="call_456", result="Error occurred", exception="Test error")
|
||||
|
||||
messages = [Message(role="tool", contents=[function_result])]
|
||||
|
||||
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
|
||||
|
||||
assert len(tau2_messages) == 1
|
||||
assert isinstance(tau2_messages[0], ToolMessage)
|
||||
assert tau2_messages[0].error is True
|
||||
|
||||
|
||||
def test_convert_agent_framework_messages_to_tau2_messages_multiple_text_contents():
|
||||
"""Test converting message with multiple text contents."""
|
||||
messages = [
|
||||
Message(role="user", contents=[Content.from_text(text="First part"), Content.from_text(text="Second part")])
|
||||
]
|
||||
|
||||
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
|
||||
|
||||
assert len(tau2_messages) == 1
|
||||
assert isinstance(tau2_messages[0], UserMessage)
|
||||
assert tau2_messages[0].content == "First part Second part"
|
||||
|
||||
|
||||
def test_convert_agent_framework_messages_to_tau2_messages_complex_scenario():
|
||||
"""Test converting complex scenario with multiple message types."""
|
||||
function_call = Content.from_function_call(call_id="call_789", name="complex_tool", arguments='{"key": "value"}')
|
||||
|
||||
function_result = Content.from_function_result(call_id="call_789", result={"output": "tool result"})
|
||||
|
||||
messages = [
|
||||
Message(role="system", contents=[Content.from_text(text="System prompt")]),
|
||||
Message(role="user", contents=[Content.from_text(text="User request")]),
|
||||
Message(role="assistant", contents=[Content.from_text(text="I'll help you"), function_call]),
|
||||
Message(role="tool", contents=[function_result]),
|
||||
Message(role="assistant", contents=[Content.from_text(text="Based on the result...")]),
|
||||
]
|
||||
|
||||
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
|
||||
|
||||
assert len(tau2_messages) == 5
|
||||
assert isinstance(tau2_messages[0], SystemMessage)
|
||||
assert isinstance(tau2_messages[1], UserMessage)
|
||||
assert isinstance(tau2_messages[2], AssistantMessage)
|
||||
assert isinstance(tau2_messages[3], ToolMessage)
|
||||
assert isinstance(tau2_messages[4], AssistantMessage)
|
||||
|
||||
# Check the assistant message with tool call
|
||||
assert tau2_messages[2].tool_calls is not None
|
||||
assert len(tau2_messages[2].tool_calls) == 1
|
||||
assert tau2_messages[2].tool_calls[0].name == "complex_tool"
|
||||
Reference in New Issue
Block a user