Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ dependencies = [
"rich>=13.0",
# Dataset registry bench_version checks (PEP 440 specifier sets).
"packaging>=24",
"litellm[proxy]==1.89.0",
"litellm[proxy]==1.91.0",
"tomli-w>=1.0",
"typer>=0.9",
]
Expand Down
22 changes: 22 additions & 0 deletions src/benchflow/_utils/timestamps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Timestamp formatting helpers for persisted artifacts."""

from __future__ import annotations

from datetime import UTC, datetime


def artifact_timestamp(value: datetime | None) -> str | None:
"""Return a timezone-aware ISO 8601 timestamp for JSON artifacts.

Legacy rollout paths sometimes pass naive ``datetime`` values. Persisting
them with ``str(datetime)`` produced a space-separated timestamp without an
offset, so downstream consumers could not compare artifacts reliably. Treat
naive values as UTC at the artifact boundary and emit a compact ``Z`` suffix.
"""
if value is None:
return None
if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
value = value.replace(tzinfo=UTC)
Comment on lines +18 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Convert naive local times before writing UTC artifacts

When BenchFlow runs on a host whose local timezone is not UTC, native rollouts still create started_at with _init_rollout()'s local naive datetime.now() (src/benchflow/rollout/_setup.py:272). This branch then simply attaches UTC to that wall time, so result.json/config.json claim e.g. 10:00 local is 10:00Z instead of converting to the actual UTC instant, corrupting artifact ordering and cross-run comparisons outside UTC environments.

Useful? React with 👍 / 👎.

else:
value = value.astimezone(UTC)
return value.isoformat().replace("+00:00", "Z")
11 changes: 6 additions & 5 deletions src/benchflow/hosted_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
trajectory_summary_from_events,
)
from benchflow._utils.reward_events import build_rewards_jsonl_events
from benchflow._utils.timestamps import artifact_timestamp
from benchflow.diagnostics import RolloutDiagnostics
from benchflow.rewards.validation import is_valid_reward_number
from benchflow.trajectories.types import redact_acp_trajectory_jsonl
Expand Down Expand Up @@ -560,9 +561,9 @@ def _write_run_artifacts(
"agent": config.agent or None,
"agent_name": "verifiers",
"model": result.normalized_model or result.model or None,
"n_tool_calls": result.total_tool_calls or 0,
"n_prompts": len(prompts),
"agent_result": agent_result,
"n_tool_calls": agent_result["n_tool_calls"],
"n_prompts": agent_result["n_prompts"],
"final_metrics": final_metrics_from_agent_result(agent_result),
"trajectory_summary": trajectory_summary_from_events(
trajectory,
Expand All @@ -583,8 +584,8 @@ def _write_run_artifacts(
**RolloutDiagnostics().to_result_fields(),
"partial_trajectory": False,
"trajectory_source": "hosted_env" if trajectory else None,
"started_at": str(started_at),
"finished_at": str(finished_at),
"started_at": artifact_timestamp(started_at),
"finished_at": artifact_timestamp(finished_at),
"timing": timing,
"scenes": [],
"source": source_provenance,
Expand All @@ -607,7 +608,7 @@ def _write_run_artifacts(
"timeout_sec": None,
"concurrency": config.concurrency,
"agent_idle_timeout_sec": None,
"started_at": str(started_at),
"started_at": artifact_timestamp(started_at),
"agent_env": {},
"scenes": [],
"source": source_provenance,
Expand Down
21 changes: 14 additions & 7 deletions src/benchflow/rollout/_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from benchflow._utils.reward_events import build_rewards_jsonl_events
from benchflow._utils.scoring import classify_error, classify_verifier_error
from benchflow._utils.source_provenance import artifact_source_provenance
from benchflow._utils.timestamps import artifact_timestamp
from benchflow.contracts import (
BaseUser,
DocumentNudgeUser,
Expand Down Expand Up @@ -194,7 +195,7 @@ def _write_config(
"timeout_sec": timeout,
"concurrency": concurrency,
"agent_idle_timeout_sec": agent_idle_timeout,
"started_at": str(started_at),
"started_at": artifact_timestamp(started_at),
"agent_env": recorded_env,
"scenes": _scene_metadata(scenes or []),
"loop": loop_block(loop_strategy),
Expand Down Expand Up @@ -305,7 +306,13 @@ def _build_rollout_result(
runtime_skills_dir=None,
declared_sandbox_skills_dir=None,
)
finished_at = datetime.now()
started_tz = (
started_at.tzinfo
if started_at.tzinfo is not None
and started_at.tzinfo.utcoffset(started_at) is not None
else None
)
finished_at = datetime.now(started_tz) if started_tz is not None else datetime.now()
n_skill_invocations = count_skill_invocations(trajectory)
error_category = (
diagnostics.category_for_channel("error") if error is not None else None
Expand Down Expand Up @@ -387,10 +394,10 @@ def _build_rollout_result(
"agent_name": result.agent_name,
"model": result.model,
**skill_policy.config_metadata(),
"n_tool_calls": result.n_tool_calls,
"n_skill_invocations": result.n_skill_invocations,
"n_prompts": result.n_prompts,
"agent_result": agent_result,
"n_tool_calls": agent_result["n_tool_calls"],
"n_skill_invocations": agent_result["n_skill_invocations"],
"n_prompts": agent_result["n_prompts"],
"final_metrics": final_metrics,
"trajectory_summary": trajectory_summary,
"usage_tracking": usage_tracking,
Expand All @@ -402,8 +409,8 @@ def _build_rollout_result(
**diagnostics.to_result_fields(),
"partial_trajectory": result.partial_trajectory,
"trajectory_source": result.trajectory_source,
"started_at": str(result.started_at),
"finished_at": str(result.finished_at),
"started_at": artifact_timestamp(result.started_at),
"finished_at": artifact_timestamp(result.finished_at),
"timing": timing,
"scenes": _scene_metadata(scenes or []),
"loop": loop or loop_block(None),
Expand Down
7 changes: 6 additions & 1 deletion tests/test_hosted_env_rollout_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def _classify(result) -> str:


def test_hosted_env_writes_contract_result_json(tmp_path, monkeypatch):
"""result.json carries the rollout-contract field set, not a custom shape."""
"""Guards issue #528 and PR #419: hosted result.json keeps the contract."""
result = _run(tmp_path, monkeypatch)
payload = json.loads((result.run_dir / "result.json").read_text())

Expand Down Expand Up @@ -151,6 +151,11 @@ def test_hosted_env_writes_contract_result_json(tmp_path, monkeypatch):
}
assert payload["trajectory_summary"]["steps"] >= 0
assert payload["trajectory_summary"]["tool_call_steps"] >= 0
assert payload["n_tool_calls"] == agent_result["n_tool_calls"]
assert payload["n_prompts"] == agent_result["n_prompts"]
assert payload["started_at"].endswith("Z")
assert payload["finished_at"].endswith("Z")
assert "T" in payload["started_at"]


def test_hosted_env_writes_rewards_jsonl(tmp_path, monkeypatch):
Expand Down
4 changes: 3 additions & 1 deletion tests/test_trace_import_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ def test_tasks_generate_rejects_removed_short_options(
)

assert result.exit_code != 0
assert f"No such option: {alias}" in click.unstyle(result.output)
output = click.unstyle(result.output)
assert "No such option" in output
assert alias in output


def test_tasks_generate_dry_run_uses_generation_filters(
Expand Down
61 changes: 60 additions & 1 deletion tests/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import contextlib
import json
import logging
from datetime import datetime
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand Down Expand Up @@ -136,6 +136,65 @@ def test_token_total_round_trips_into_agent_result(self, tmp_path):
assert data["final_metrics"]["total_completion_tokens"] == 5993
assert "total_tokens" not in data

def test_result_timestamps_are_timezone_aware_iso8601(self, tmp_path):
"""Guards issue #528: result.json timestamps are ISO 8601 UTC strings."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Name the guarded commit in timestamp regression tests

For these new regression tests, the docstrings cite only issue #528; /workspace/benchflow/AGENTS.md requires regression tests to name the PR or commit they guard. Please include this commit hash or the eventual PR number here and in the similar timestamp regression docstring below so future maintainers can trace the intended regression boundary.

Useful? React with 👍 / 👎.

from benchflow.rollout import _build_rollout_result

rollout_dir = tmp_path / "trial"
rollout_dir.mkdir()
_build_rollout_result(
rollout_dir,
task_name="t1",
rollout_name="trial-1",
agent="test",
agent_name="openhands",
model="m",
n_tool_calls=0,
prompts=[],
error=None,
verifier_error=None,
trajectory=[],
partial_trajectory=False,
rewards={"reward": 1.0},
started_at=datetime(2026, 3, 24, 10, 0),
timing={},
)

data = json.loads((rollout_dir / "result.json").read_text())
assert data["started_at"] == "2026-03-24T10:00:00Z"
assert data["finished_at"].endswith("Z")
assert " " not in data["finished_at"]
assert datetime.fromisoformat(data["finished_at"].replace("Z", "+00:00"))

def test_result_timestamps_normalize_offsets_to_utc(self, tmp_path):
"""Guards issue #528: aware result timestamps serialize in UTC."""
from benchflow.rollout import _build_rollout_result

rollout_dir = tmp_path / "trial"
rollout_dir.mkdir()
_build_rollout_result(
rollout_dir,
task_name="t1",
rollout_name="trial-1",
agent="test",
agent_name="openhands",
model="m",
n_tool_calls=0,
prompts=[],
error=None,
verifier_error=None,
trajectory=[],
partial_trajectory=False,
rewards={"reward": 1.0},
started_at=datetime(
2026, 3, 24, 3, 0, tzinfo=timezone(timedelta(hours=-7))
),
timing={},
)

data = json.loads((rollout_dir / "result.json").read_text())
assert data["started_at"] == "2026-03-24T10:00:00Z"


class TestSdkVerify:
@pytest.fixture
Expand Down
Loading
Loading