Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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 thread
bingran-you marked this conversation as resolved.
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
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."""
Comment thread
bingran-you marked this conversation as resolved.
Outdated
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