Skip to content

Commit 69f6a83

Browse files
authored
Merge branch 'main' into feat/grpc-receiver-support
2 parents 4349e00 + 86255f6 commit 69f6a83

19 files changed

Lines changed: 1182 additions & 160 deletions

File tree

README.md

Lines changed: 96 additions & 59 deletions
Large diffs are not rendered by default.

examples/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ agentevals accepts OTLP/HTTP on port 4318 (`http/protobuf` and `http/json`) and
2626
| Example | Framework | LLM Provider |
2727
|---------|-----------|-------------|
2828
| [zero-code-examples/langchain/](./zero-code-examples/langchain/) | LangChain | OpenAI |
29+
| [zero-code-examples/ollama/](./zero-code-examples/ollama/) | LangChain | Ollama |
2930
| [zero-code-examples/strands/](./zero-code-examples/strands/) | Strands | OpenAI |
3031
| [zero-code-examples/adk/](./zero-code-examples/adk/) | Google ADK | Gemini |
3132

@@ -99,6 +100,7 @@ Detection checks for `gen_ai.request.model` / `gen_ai.input.messages` (GenAI sem
99100
| Example | Framework | LLM Provider | Instrumentation | Content Delivery |
100101
|---------|-----------|-------------|-----------------|-----------------|
101102
| [zero-code-examples/langchain/](./zero-code-examples/langchain/) | LangChain | OpenAI | GenAI semconv (logs) | Standard OTLP export |
103+
| [zero-code-examples/ollama/](./zero-code-examples/ollama/) | LangChain | Ollama | GenAI semconv (logs) | Standard OTLP export |
102104
| [zero-code-examples/strands/](./zero-code-examples/strands/) | Strands | OpenAI | GenAI semconv (events*) | Standard OTLP export |
103105
| [zero-code-examples/adk/](./zero-code-examples/adk/) | Google ADK | Gemini | ADK built-in | Standard OTLP export |
104106
| [langchain_agent](./langchain_agent/) | LangChain | OpenAI | GenAI semconv (logs) | SDK WebSocket |
@@ -212,6 +214,7 @@ cd ui && npm run dev
212214
```bash
213215
# Zero-code OTLP (recommended):
214216
python examples/zero-code-examples/langchain/run.py
217+
python examples/zero-code-examples/ollama/run.py
215218
python examples/zero-code-examples/strands/run.py
216219
python examples/zero-code-examples/adk/run.py
217220

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
langchain>=1.2.0
2+
langchain-openai>=1.1.10
3+
4+
opentelemetry-sdk>=1.36.0
5+
opentelemetry-exporter-otlp-proto-http>=1.36.0
6+
opentelemetry-instrumentation-openai-v2
7+
python-dotenv>=1.0.0
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Run the LangChain dice agent against local Ollama via ChatOpenAI with OTLP export.
2+
3+
Demonstrates zero-code integration with a local LLM provider using Ollama's
4+
OpenAI-compatible endpoint. The agent emits standard OpenTelemetry spans/logs
5+
and sends them to agentevals via OTLP, without using the agentevals Python SDK
6+
in agent code.
7+
8+
Prerequisites:
9+
1. pip install -r requirements.txt
10+
2. agentevals serve --dev
11+
3. ollama serve
12+
4. ollama pull llama3.2:3b
13+
14+
Usage:
15+
python examples/zero-code-examples/ollama/run.py
16+
17+
Optional environment variables:
18+
LOCAL_OPENAI_BASE_URL (default: http://localhost:11434/v1)
19+
LOCAL_LLM_MODEL (default: llama3.2:3b)
20+
"""
21+
22+
import json
23+
import os
24+
import sys
25+
26+
from dotenv import load_dotenv
27+
from langchain_core.messages import HumanMessage, ToolMessage
28+
from langchain_openai import ChatOpenAI
29+
from opentelemetry import trace
30+
from opentelemetry._logs import set_logger_provider
31+
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
32+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
33+
from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor
34+
from opentelemetry.sdk._logs import LoggerProvider
35+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
36+
from opentelemetry.sdk.resources import Resource
37+
from opentelemetry.sdk.trace import TracerProvider
38+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
39+
40+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "langchain_agent"))
41+
from agent import check_prime, roll_die
42+
43+
load_dotenv(override=True)
44+
45+
46+
def _json_or_value(value):
47+
if not isinstance(value, str):
48+
return value
49+
try:
50+
return json.loads(value)
51+
except json.JSONDecodeError:
52+
return value
53+
54+
55+
def _to_int_if_numeric(value):
56+
value = _json_or_value(value)
57+
if isinstance(value, (int, float)):
58+
return int(value)
59+
if isinstance(value, str):
60+
stripped = value.strip()
61+
if stripped.lstrip("-").isdigit():
62+
return int(stripped)
63+
if "," in stripped:
64+
parts = [p.strip() for p in stripped.split(",") if p.strip()]
65+
if parts and all(p.lstrip("-").isdigit() for p in parts):
66+
return [int(p) for p in parts]
67+
return value
68+
69+
70+
def _normalize_nums(values):
71+
parsed = _json_or_value(values)
72+
if not isinstance(parsed, list):
73+
parsed = [parsed]
74+
75+
normalized = []
76+
for item in parsed:
77+
item = _to_int_if_numeric(item)
78+
if isinstance(item, list):
79+
normalized.extend(item)
80+
else:
81+
normalized.append(item)
82+
return normalized
83+
84+
85+
def _normalize_tool_args(tool_name: str, tool_args):
86+
parsed = _json_or_value(tool_args)
87+
if tool_name == "roll_die":
88+
if isinstance(parsed, dict):
89+
if "sides" in parsed:
90+
parsed["sides"] = _to_int_if_numeric(parsed["sides"])
91+
return parsed
92+
return {"sides": _to_int_if_numeric(parsed)}
93+
94+
if tool_name == "check_prime":
95+
if isinstance(parsed, dict):
96+
parsed["nums"] = _normalize_nums(parsed.get("nums", []))
97+
return parsed
98+
if isinstance(parsed, list):
99+
return {"nums": _normalize_nums(parsed)}
100+
return {"nums": _normalize_nums(parsed)}
101+
102+
return parsed
103+
104+
105+
def main():
106+
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
107+
base_url = os.environ.get("LOCAL_OPENAI_BASE_URL", "http://localhost:11434/v1")
108+
model = os.environ.get("LOCAL_LLM_MODEL", "llama3.2:3b")
109+
110+
print(f"OTLP endpoint: {endpoint}")
111+
print(f"Local model endpoint: {base_url}")
112+
print(f"Local model: {model}")
113+
114+
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"
115+
os.environ.setdefault(
116+
"OTEL_RESOURCE_ATTRIBUTES",
117+
"agentevals.eval_set_id=langchain_local_ollama_openai_eval,agentevals.session_name=langchain-ollama-openai-zero-code",
118+
)
119+
120+
resource = Resource.create()
121+
122+
tracer_provider = TracerProvider(resource=resource)
123+
tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(), schedule_delay_millis=1000))
124+
trace.set_tracer_provider(tracer_provider)
125+
126+
logger_provider = LoggerProvider(resource=resource)
127+
logger_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter(), schedule_delay_millis=1000))
128+
set_logger_provider(logger_provider)
129+
130+
OpenAIInstrumentor().instrument()
131+
132+
llm = ChatOpenAI(model=model, temperature=0.0, base_url=base_url, api_key="ollama")
133+
tools = [roll_die, check_prime]
134+
llm_with_tools = llm.bind_tools(tools)
135+
136+
test_queries = [
137+
"Hi! Can you help me?",
138+
"Roll a 20-sided die for me",
139+
"Is the number you rolled prime?",
140+
]
141+
142+
messages = []
143+
144+
for i, query in enumerate(test_queries, 1):
145+
print(f"\n[{i}/{len(test_queries)}] User: {query}")
146+
messages.append(HumanMessage(content=query))
147+
148+
max_iterations = 5
149+
for _ in range(max_iterations):
150+
response = llm_with_tools.invoke(messages)
151+
messages.append(response)
152+
153+
if not response.tool_calls:
154+
print(f" Agent: {response.content}")
155+
break
156+
157+
for tool_call in response.tool_calls:
158+
tool_name = tool_call["name"]
159+
tool_args = tool_call["args"]
160+
tool_call_id = tool_call.get("id", f"tool-call-{tool_name}")
161+
normalized_args = _normalize_tool_args(tool_name, tool_args)
162+
163+
selected_tool = {t.name: t for t in tools}.get(tool_name)
164+
if selected_tool:
165+
try:
166+
tool_result = selected_tool.invoke(normalized_args)
167+
except Exception as exc:
168+
tool_result = {
169+
"isError": True,
170+
"error": str(exc),
171+
"tool_name": tool_name,
172+
"args": normalized_args,
173+
}
174+
175+
tool_content = json.dumps(tool_result) if isinstance(tool_result, dict) else str(tool_result)
176+
messages.append(ToolMessage(content=tool_content, tool_call_id=tool_call_id))
177+
else:
178+
messages.append(
179+
ToolMessage(
180+
content=json.dumps({"isError": True, "error": f"Unknown tool: {tool_name}"}),
181+
tool_call_id=tool_call_id,
182+
)
183+
)
184+
else:
185+
print(" Agent: [Max iterations reached]")
186+
187+
print()
188+
tracer_provider.force_flush()
189+
logger_provider.force_flush()
190+
print("All traces and logs flushed to OTLP receiver.")
191+
192+
193+
if __name__ == "__main__":
194+
main()

src/agentevals/api/routes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@ async def evaluate_traces(
507507
trace_format=trace_format,
508508
judge_model=config_dict.get("judgeModel"),
509509
threshold=threshold,
510+
trajectory_match_type=config_dict.get("trajectoryMatchType"),
510511
)
511512

512513
logger.info(f"Evaluating {len(trace_paths)} trace file(s) with metrics: {metrics}")
@@ -615,6 +616,7 @@ async def event_generator():
615616
trace_format=trace_format,
616617
judge_model=config_dict.get("judgeModel"),
617618
threshold=threshold,
619+
trajectory_match_type=config_dict.get("trajectoryMatchType"),
618620
)
619621

620622
loader = get_loader(eval_config.trace_format)

src/agentevals/api/streaming_routes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import asyncio
66
import json
77
import logging
8-
from typing import TYPE_CHECKING
8+
from typing import TYPE_CHECKING, Literal
99

1010
from fastapi import APIRouter, Depends, HTTPException
1111
from fastapi.responses import FileResponse
@@ -46,6 +46,7 @@ class EvaluateSessionsRequest(BaseModel):
4646
eval_set_id: str
4747
metrics: list[str] = ["tool_trajectory_avg_score"]
4848
judge_model: str = "gemini-2.5-flash"
49+
trajectory_match_type: Literal["EXACT", "IN_ORDER", "ANY_ORDER"] | None = None
4950

5051

5152
class PrepareEvaluationRequest(BaseModel):
@@ -210,6 +211,7 @@ async def eval_one_session(session_id: str, session) -> SessionEvalResult:
210211
eval_set_file=eval_set_file.name,
211212
metrics=request.metrics,
212213
judge_model=request.judge_model,
214+
trajectory_match_type=request.trajectory_match_type,
213215
)
214216

215217
eval_result = await run_evaluation(config)

src/agentevals/builtin_metrics.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,18 @@ def build_eval_metric(
6060
judge_model: str | None,
6161
threshold: float | None,
6262
rubrics: list[str] | None = None,
63+
match_type: str | None = None,
6364
) -> EvalMetric:
6465
"""Construct an ADK ``EvalMetric`` with the appropriate criterion."""
6566
effective_threshold = threshold if threshold is not None else 0.5
6667

6768
criterion: BaseCriterion | None = None
6869

6970
if metric_name == "tool_trajectory_avg_score":
70-
criterion = ToolTrajectoryCriterion(threshold=effective_threshold)
71+
_match = (
72+
ToolTrajectoryCriterion.MatchType[match_type] if match_type else ToolTrajectoryCriterion.MatchType.EXACT
73+
)
74+
criterion = ToolTrajectoryCriterion(threshold=effective_threshold, match_type=_match)
7175
elif metric_name == "final_response_match_v2":
7276
judge_opts = JudgeModelOptions()
7377
if judge_model:
@@ -105,7 +109,11 @@ def build_eval_metric(
105109
threshold=effective_threshold,
106110
judge_model_options=judge_opts,
107111
)
108-
elif metric_name in ("response_match_score", "response_evaluation_score", "safety_v1"):
112+
elif metric_name in (
113+
"response_match_score",
114+
"response_evaluation_score",
115+
"safety_v1",
116+
):
109117
criterion = BaseCriterion(threshold=effective_threshold)
110118

111119
return EvalMetric(
@@ -179,6 +187,7 @@ async def evaluate_builtin_metric(
179187
expected_invocations: list[Invocation] | None,
180188
judge_model: str | None,
181189
threshold: float | None,
190+
match_type: str | None = None,
182191
) -> dict[str, Any]:
183192
"""Evaluate a single built-in ADK metric.
184193
@@ -197,7 +206,7 @@ async def evaluate_builtin_metric(
197206
)
198207

199208
try:
200-
eval_metric = build_eval_metric(metric_name, judge_model, threshold)
209+
eval_metric = build_eval_metric(metric_name, judge_model, threshold, match_type=match_type)
201210
evaluator: Evaluator = get_evaluator(eval_metric)
202211

203212
if inspect.iscoroutinefunction(evaluator.evaluate_invocations):

src/agentevals/cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ def main(verbose: int) -> None:
109109
default=None,
110110
help="Score threshold for pass/fail.",
111111
)
112+
@click.option(
113+
"--trajectory-match-type",
114+
type=click.Choice(["EXACT", "IN_ORDER", "ANY_ORDER"], case_sensitive=False),
115+
default=None,
116+
help="Match type for tool_trajectory_avg_score: EXACT (default), IN_ORDER, or ANY_ORDER.",
117+
)
112118
@click.option(
113119
"--output",
114120
"-o",
@@ -131,6 +137,7 @@ def run(
131137
trace_format: str,
132138
judge_model: str | None,
133139
threshold: float | None,
140+
trajectory_match_type: str | None,
134141
output: str,
135142
config_file: str | None,
136143
) -> None:
@@ -153,6 +160,7 @@ def run(
153160
trace_format=trace_format,
154161
judge_model=judge_model,
155162
threshold=threshold,
163+
trajectory_match_type=trajectory_match_type,
156164
output_format=output,
157165
)
158166
config = merge_configs(file_config, cli_config)
@@ -165,6 +173,7 @@ def run(
165173
trace_format=trace_format,
166174
judge_model=judge_model,
167175
threshold=threshold,
176+
trajectory_match_type=trajectory_match_type,
168177
output_format=output,
169178
)
170179

src/agentevals/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,19 @@ class EvalRunConfig(BaseModel):
132132
description="Score threshold for pass/fail.",
133133
)
134134

135+
trajectory_match_type: str | None = Field(
136+
default=None,
137+
description="Match type for tool_trajectory_avg_score: 'EXACT', 'IN_ORDER', or 'ANY_ORDER'. Default: EXACT.",
138+
)
139+
140+
@field_validator("trajectory_match_type")
141+
@classmethod
142+
def _validate_trajectory_match_type(cls, v: str | None) -> str | None:
143+
valid = {"EXACT", "IN_ORDER", "ANY_ORDER"}
144+
if v is not None and v.upper() not in valid:
145+
raise ValueError(f"Invalid trajectory_match_type '{v}'. Valid values: {sorted(valid)}")
146+
return v.upper() if v is not None else v
147+
135148
output_format: str = Field(
136149
default="table",
137150
description="Output format: 'table', 'json', or 'summary'.",

src/agentevals/eval_config_loader.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ def load_eval_config(path: str | Path) -> EvalRunConfig:
111111
config.judge_model = data["judge_model"]
112112
if "threshold" in data:
113113
config.threshold = float(data["threshold"])
114+
if "trajectory_match_type" in data:
115+
config.trajectory_match_type = data["trajectory_match_type"]
114116
if "trace_format" in data:
115117
config.trace_format = data["trace_format"]
116118

@@ -136,6 +138,8 @@ def merge_configs(file_config: EvalRunConfig, cli_config: EvalRunConfig) -> Eval
136138
merged.judge_model = cli_config.judge_model
137139
if cli_config.threshold is not None:
138140
merged.threshold = cli_config.threshold
141+
if cli_config.trajectory_match_type is not None:
142+
merged.trajectory_match_type = cli_config.trajectory_match_type
139143
if cli_config.trace_format != "jaeger-json":
140144
merged.trace_format = cli_config.trace_format
141145
if cli_config.output_format != "table":

0 commit comments

Comments
 (0)