Skip to content

Commit f4aa037

Browse files
authored
feat(scripts): add latency report and scope-aware quality eval (#156)
* Add latency report, scope-aware quality eval, and sample reports - Rename trace_latency to latency_report for consistency with quality_report - Add --report and --output-json flags to latency_report - Add --session flag to quality_report for single-session evaluation - Add --config flag for scope-aware eval with declined category - Add sample_quality_report.md and sample_latency_report.md - Update README with scope-aware eval docs and latency report section - Add .env and example run artifacts to .gitignore * Improve multi-turn support, A2A null handling, and scope evaluation - get_user_input: return last user message (not first) for multi-turn alignment - get_a2a_response: handle null/empty A2A content as "(no response)" instead of falling through to earlier turns, preventing question/response mismatches - Always include declined category in response_usefulness metrics — lets the LLM judge correctly classify legitimate scope declines even without --config - _load_agent_config: implement auto-discovery of eval/data/agent_context.json (the docstring already described this behavior but the code returned None) - Add Declined Sessions section to markdown report - Add 14 new tests covering get_user_input and get_a2a_response * Address PR #156 review: bug fixes, dead code removal, tests, logging quality_report.py: - B2: Replace stale config cache with dict keyed by config_path - B3: Raise FileNotFoundError instead of sys.exit for missing config - M3: Consistent get_a2a_response return shape (always tuple) - M4: Comment explaining is_a2a=True for "(no response)" sessions - M7: Remove duplicate import json as _json - M9: Document that 'reason' field in --config is documentation-only - L7: Store samples as None (not string) when unset - Fail fast with clear error when --session-ids-file has no valid IDs latency_report.py: - H1: Remove dead parse_period() function and unused import re - H4: Add _load_dotenv(), _configure_logging(), --env CLI flag - M6: Document that stitch_a2a_traces mutates traces in place - M10: Extract A2A noise events to module-level frozenset - L2: Move Counter import and _script_dir to module level - L3: Replace print() with logger.* for operational messages - Second review: --output-json - sends human output to stderr tests: - H2: Add test_latency_report_helpers.py (26 tests for pure helpers) - H3: Add TestBuildScopeContext (6 tests) and TestLoadAgentConfig (4 tests) - M1: Remove unused import pytest from top level - Update test_invalid_json_string for M3 consistent return shape Formatting: pyink + isort applied via autoformat.sh * Address second review: autoformat scripts/, new findings, polish B1: Add scripts/ to autoformat.sh scope; run pyink+isort on both scripts (now consistent 2-space indent throughout) N1: Guard --sdk-tree + --output-json - (SDK trace.render() writes directly to stdout, bypassing the stderr routing) N4: Add test_cache_isolates_paths — proves two different config paths return two different configs (regression guard for B2 fix) N5: Include session ID in A2A JSON parse warning for debuggability M2: Add docstring to get_eval_metrics documenting returned metrics and the always-present declined category M8: Qualify run_eval.py path in --session-ids-file help text N3: Add --env example to README direct-Python usage section L4: Complete latency_report.py module docstring with all flags (--no-waterfall, --sdk-tree, --report, --output-json, --env) 79 tests pass, autoformat clean (pyink + isort). * Address remaining review items: rebase, polish, cleanup L1: Remove GFM trailing whitespace from sample markdown files L6: Add comment about approximate alignment in waterfall markers L8: Clarify --time-period help text in latency_report.py L9: Scope .gitignore *.log to scripts/ and examples/ only L10: Remove duplicate env validation from latency_report.sh (Python script now handles validation with structured logging) L11: Rebased onto origin/main (17 commits ahead, 0 behind) 79 tests pass, autoformat clean. * Regenerate sample reports from live data Ran both scripts against live BigQuery data to verify end-to-end: - quality_report.py --limit 20 --report --samples 3 - latency_report.py --limit 3 --report Sanitized project-specific details for the sample files. Stripped GFM trailing whitespace (L1). * Align --time-period default between quality and latency scripts Change latency_report.py --time-period default from None to "all" to match quality_report.py. Convert "all" back to None before building the TraceFilter so runtime behavior is unchanged. Also regenerate sample_latency_report.md from live data and add .adk/ to .gitignore.
1 parent 917a8fa commit f4aa037

11 files changed

Lines changed: 2224 additions & 285 deletions

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,20 @@ build/
1111
.venv/
1212
venv/
1313
env/
14+
.adk/
1415
uv.lock
16+
.env
1517

1618
# Script outputs
1719
scripts/reports/
1820

21+
# Example run artifacts
22+
examples/*/reports/
23+
examples/*/reports_*/
24+
examples/*/trials_*/
25+
scripts/**/*.log
26+
examples/**/*.log
27+
1928
# Local workspace metadata
2029
.code*/
2130
deploy/streaming_evaluation/.streaming_evaluation_state.json

autoformat.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ set -euo pipefail
2121
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
2222

2323
echo "==> Running isort (import sorting)..."
24-
isort "${REPO_ROOT}/src/" "${REPO_ROOT}/tests/" "${REPO_ROOT}/examples/"
24+
isort "${REPO_ROOT}/src/" "${REPO_ROOT}/tests/" "${REPO_ROOT}/examples/" "${REPO_ROOT}/scripts/"
2525

2626
echo "==> Running pyink (code formatting)..."
27-
pyink --config "${REPO_ROOT}/pyproject.toml" "${REPO_ROOT}/src/" "${REPO_ROOT}/tests/" "${REPO_ROOT}/examples/"
27+
pyink --config "${REPO_ROOT}/pyproject.toml" "${REPO_ROOT}/src/" "${REPO_ROOT}/tests/" "${REPO_ROOT}/examples/" "${REPO_ROOT}/scripts/"
2828

2929
echo "==> Done. All Python files formatted."

scripts/README.md

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
Standalone scripts for the BigQuery Agent Analytics SDK.
44

5+
| Script | Description |
6+
|--------|-------------|
7+
| [quality_report](#quality-report) | LLM-as-a-judge evaluation over agent sessions |
8+
| [latency_report](#latency-report-1) | Timing tree and waterfall for agent traces with A2A stitching |
9+
510
## Quality Report
611

712
Runs LLM-as-a-judge evaluation over agent sessions stored in BigQuery
@@ -56,6 +61,7 @@ EVAL_MODEL_ID=gemini-2.5-flash
5661
./scripts/quality_report.sh --session-ids-file ids.json # evaluate specific sessions
5762
./scripts/quality_report.sh --output-json report.json # write structured JSON output
5863
./scripts/quality_report.sh --threshold 15 # unhelpful rate warning at 15%
64+
./scripts/quality_report.sh --config config.json # scope-aware eval with config
5965
```
6066

6167
Or run the Python script directly:
@@ -98,11 +104,58 @@ These filters can be combined (e.g. `--app-name my_agent --session-ids-file ids.
98104
The evaluation uses two categorical metrics:
99105

100106
- **response_usefulness** - Whether the agent's response provides a genuinely
101-
useful answer. Categories: `meaningful`, `unhelpful`, `partial`.
107+
useful answer. Categories: `meaningful`, `declined`, `unhelpful`, `partial`.
102108

103109
- **task_grounding** - Whether the response is grounded in tool-retrieved data
104110
or fabricated. Categories: `grounded`, `ungrounded`, `no_tool_needed`.
105111

112+
The **`declined`** category is always available — the LLM judge can classify
113+
polite refusals of out-of-scope questions as correct behavior rather than
114+
marking them as `unhelpful`.
115+
116+
### Scope-Aware Evaluation (`--config`)
117+
118+
For more accurate scope evaluation, provide a config file that tells the
119+
LLM judge exactly which topics your agent intentionally does not handle:
120+
121+
```bash
122+
./scripts/quality_report.sh --config agent_context.json --report
123+
```
124+
125+
The script also auto-discovers `eval/data/agent_context.json` relative to
126+
the repo root or script directory, so `--config` is only needed to point
127+
at a non-default location.
128+
129+
Create a JSON config file with `scope_decisions`:
130+
131+
```json
132+
{
133+
"scope_decisions": [
134+
{
135+
"topic": "stock_options",
136+
"decision": "out_of_scope",
137+
"reason": "No tool or data source covers equity compensation"
138+
},
139+
{
140+
"topic": "salary_bands",
141+
"decision": "out_of_scope",
142+
"reason": "Confidential compensation data"
143+
},
144+
{
145+
"topic": "promotions",
146+
"decision": "out_of_scope",
147+
"reason": "No tool covers career progression"
148+
}
149+
]
150+
}
151+
```
152+
153+
Without a config, the LLM judge can still classify obvious declines as
154+
`declined`, but it won't know which specific topics are out of scope. With
155+
the config, the judge is told exactly which topics are out of scope, so it
156+
can correctly classify polite refusals as `declined` (correct behavior)
157+
rather than `unhelpful` (a bug).
158+
106159
### A2A Support
107160

108161
The script automatically detects and resolves responses from remote A2A
@@ -111,4 +164,65 @@ The script automatically detects and resolves responses from remote A2A
111164

112165
### Sample report output
113166

114-
[Sample report output](sample_report.md)
167+
[Sample quality report](sample_quality_report.md)
168+
169+
---
170+
171+
## Latency Report
172+
173+
Fetches agent traces from BigQuery and renders a hierarchical timing tree
174+
with per-span latency and a waterfall timeline. Automatically stitches
175+
A2A (Agent-to-Agent) remote sessions to show full cross-agent latency
176+
breakdown — including LLM call times inside remote agents that would
177+
otherwise appear as a black box.
178+
179+
### Usage
180+
181+
```bash
182+
./scripts/latency_report.sh # latest trace
183+
./scripts/latency_report.sh --limit 5 # last 5 traces with summary
184+
./scripts/latency_report.sh --time-period 1h # traces from the last hour
185+
./scripts/latency_report.sh --session <session_id> # specific session
186+
./scripts/latency_report.sh --app-name my_agent # filter by root agent name
187+
./scripts/latency_report.sh --verbose # show questions and responses
188+
./scripts/latency_report.sh --no-stitch # skip A2A session stitching
189+
./scripts/latency_report.sh --env path/to/.env # use a specific .env file
190+
```
191+
192+
Or run the Python script directly:
193+
194+
```bash
195+
python scripts/latency_report.py --limit 5 --time-period 1h
196+
python scripts/latency_report.py --env path/to/.env --limit 5
197+
```
198+
199+
### Output
200+
201+
The script produces three views for each trace:
202+
203+
1. **Timing tree** — hierarchical span view with latency annotations,
204+
tool names, and A2A boundary markers
205+
2. **Waterfall chart** — ASCII bar chart showing time distribution
206+
3. **SDK trace tree** — the SDK's built-in `trace.render()` output
207+
208+
When multiple traces are fetched (`--limit > 1`), a **summary table**
209+
shows aggregate latency statistics (avg, P50, P95, min, max) and
210+
per-agent breakdown.
211+
212+
### A2A Session Stitching
213+
214+
When a supervisor agent calls a remote agent via A2A, the parent trace
215+
only records `AGENT_STARTING` and `AGENT_COMPLETED` for the remote
216+
agent — the internal LLM and tool spans are logged in a separate
217+
BigQuery session.
218+
219+
The script automatically:
220+
1. Detects `A2A_INTERACTION` events in the parent trace
221+
2. Extracts the remote session ID from `content.metadata.adk_session_id`
222+
3. Fetches the remote agent's spans and inlines them as children
223+
224+
Use `--no-stitch` to disable this behavior.
225+
226+
### Sample report output
227+
228+
[Sample latency report](sample_latency_report.md)

0 commit comments

Comments
 (0)