Skip to content
Merged
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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Local logs now shows the recording calls agentacct refused, with counts and a
fixed set of reason codes. A refused call previously left no trace anywhere —
no store entry, no counter, no log line — so an agent that failed to record
was invisible to you and to the maintainer, while the dashboard told you to
record more work. The figure is derived at read time from data already on
disk, so it covers refusals that predate this release, and it stores only
counts and reason codes, never the offending value or path.

### Changed
- The "usage without work context" prompt no longer blames you for all of it;
it now points at the refused-recording list for the part agentacct caused.

### Fixed
- Secret redaction no longer destroys the record it was protecting. Any value
containing something that looked like a credential was replaced *in its
Expand All @@ -27,6 +40,27 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
family was calibrated against a real ledger rather than guessed: zero false
positives across 27k distinct strings. The shapes still missed are named in
the module, with the method to re-derive them.
- `agentacct_record_section` now accepts `title` as an alias for
`section_title`. The recording contract agentacct ships to every client told
agents to send `title`, which the schema then rejected outright, losing the
whole record; the instruction is corrected and the alias keeps
already-onboarded machines working without re-running `onboard`.
- Limit errors now say what was received, not only what is allowed. An agent
that overshoots a length limit could previously only shrink blindly and
retry.
- Metadata size is measured in real UTF-8 bytes on every write surface. It was
measured against an ASCII-escaped encoding, so each CJK character counted as
6 bytes and each emoji as 12 — a Chinese-writing agent was refused at roughly
a third of the advertised budget, by an error naming a parameter it had never
sent. Size errors now name the field that actually overflowed.
- `files` entries: the project-relative rule is published in the schema (it was
enforced but documented nowhere), and an absolute path that provably lies
under the call's own `project_dir` is normalized instead of failing the whole
call. This was the single largest cause of refused recordings. Paths that
escape the project are still rejected.
- A tool call mangled in transit — parameters absorbed into a narrative field
as literal text — is now flagged with a warning and a marker on the stored
record instead of being kept silently. It never rejects and never repairs.

## [0.5.2] - 2026-07-31

Expand Down
121 changes: 116 additions & 5 deletions src/agentacct/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import secrets
import threading
import time
from dataclasses import dataclass, replace
# ``field`` is aliased: several helpers below use ``field`` as a loop variable.
from dataclasses import dataclass, field as dataclass_field, replace
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping
Expand Down Expand Up @@ -118,7 +119,7 @@
select_session_projection_envelopes,
)
from .source_discovery import UsageSourceDiscovery, discover_usage_sources
from .storage import validate_run_id
from .storage import METADATA_MAX_BYTES, json_utf8_size, validate_run_id
from .store_resolution import is_recognized_global_store
from .supervisor import OwnedSupervisor, SupervisorAlreadyRunning, SupervisorError
from .task_continuations import ClientSessionRef, ContinuationTaskError, ContinuationTaskStore
Expand Down Expand Up @@ -149,6 +150,7 @@
usage_bucket_date,
week_start,
)
from .log_evidence import summarize_refused_recording_attempts
from .usage_truth import (
CODEX_REPLAY_QUARANTINE_STATE,
is_diagnostic_event,
Expand Down Expand Up @@ -295,9 +297,11 @@ def validate_optional_run_id(cls, value: str | None) -> str | None:
@field_validator("metadata")
@classmethod
def validate_metadata_size(cls, value: dict[str, Any]) -> dict[str, Any]:
encoded = json.dumps(value, sort_keys=True)
if len(encoded.encode("utf-8")) > 8192:
raise ValueError("metadata must be <= 8192 bytes when JSON encoded")
# Real UTF-8 bytes, measured by the same helper the CLI and MCP lanes
# use: the escaped encoding billed CJK text 2x, so the same payload was
# accepted on one surface and rejected on another.
if json_utf8_size(value) > METADATA_MAX_BYTES:
raise ValueError(f"metadata must be <= {METADATA_MAX_BYTES} bytes when JSON encoded")
return value


Expand Down Expand Up @@ -855,6 +859,11 @@ class DashboardUsageView:
excluded_saved_records: list[DashboardUsageRecord]
local_by_client: dict[str, list[DashboardUsageRecord]]
saved_by_client: dict[str, list[DashboardUsageRecord]]
# Recording calls agentacct REFUSED, derived from the same live client-log
# scan that produced local_records. Nothing about a refusal is stored, so
# this is re-derived on every scan — which is also why refusals that
# predate the feature are counted without a backfill.
refused_recording: dict[str, Any] = dataclass_field(default_factory=dict)


def _safe_float(value: Any) -> float | None:
Expand Down Expand Up @@ -1126,6 +1135,10 @@ def _build_usage_view(local_usage_preview: list[ClientUsageEvent], events: list[
excluded_saved_records=excluded_saved_records,
local_by_client=_usage_records_by_client(local_records),
saved_by_client=_usage_records_by_client(saved_records),
# Derived from the raw scan candidates (not the records), because the
# summary dedups per base session itself: one Claude Code transcript
# becomes several per-model records carrying the SAME refusal counts.
refused_recording=summarize_refused_recording_attempts(local_usage_preview),
)


Expand Down Expand Up @@ -1810,6 +1823,99 @@ def _display_count_label(value: Any) -> str:
return text


# Plain-language names for the frozen refusal reason vocabulary. Anything the
# vocabulary gains without a label here still renders (as its code), so a new
# reason can never be silently dropped from the table.
_REFUSED_RECORDING_REASON_LABELS = {
"narrative_over_limit": "Text longer than the field allows",
"files_not_project_relative": "File path was not project-relative",
"unknown_argument": "Argument agentacct does not accept",
"missing_argument": "Required argument was missing",
"invalid_argument": "Argument had the wrong type or value",
"value_over_limit": "Value above the allowed maximum",
"value_under_limit": "Value below the allowed minimum",
"metadata_over_size": "Metadata above the size limit",
"incomplete_argument_group": "Arguments that must be sent together were not",
"no_runs": "No run existed to attach the record to",
"unknown_run_id": "Named run does not exist in this store",
"other": "Refused for a reason this build does not name yet",
}


def _refused_recording_reason_label(reason_code: Any) -> str:
code = str(reason_code or "other")
return _REFUSED_RECORDING_REASON_LABELS.get(code, code)


def _refused_recording_field_label(field_name: Any) -> str:
"""No field means the refusal was about the call, not one argument.

Deliberately NOT the dashboard's generic "Not reported": nothing is
missing here, and an allowlisted-only field is also how an agent-invented
argument name is kept out of this page.
"""

return str(field_name) if field_name else "Not argument-specific"


def _refused_recording_html(summary: Mapping[str, Any], esc: Any) -> str:
"""Refusals agentacct itself returned, derived live from the client logs.

Renders ONLY the bounded {tool, field, reason} triple and its count. No
message text, no rejected value, no length, no path — the rejection path
runs before the redactor, so anything else here would leak user content.
"""

total = int(summary.get("refused_attempt_total") or 0)
sessions = int(summary.get("sessions_with_refusals") or 0)
unclassified = int(summary.get("unclassified_outputs_skipped") or 0)
rows = [row for row in (summary.get("rows") or []) if isinstance(row, Mapping)]
if not total:
body = (
'<p class="section-note">No refused recording calls were found in the client logs '
"scanned above. Recording calls that agentacct rejects are stored nowhere, so this "
"count comes from re-reading the client's own logs on every scan.</p>"
)
else:
table_rows = "".join(
"<tr>"
f"<td>{esc(_display_count_label(row.get('tool')))}</td>"
f"<td>{esc(_refused_recording_field_label(row.get('field')))}</td>"
f"<td>{esc(_refused_recording_reason_label(row.get('reason_code')))}</td>"
f"<td>{esc(_fmt_int(row.get('count')))}</td>"
"</tr>"
for row in rows
)
body = (
f'<p class="section-note"><strong>{esc(_fmt_int(total))} recording call(s) across '
f"{esc(_fmt_int(sessions))} client session(s) were refused by agentacct.</strong> "
"These are attempts an agent made and this product rejected — not work you failed to "
"record. Until they are fixed, that work has no context in the ledger, so usage rows "
"from those sessions can look unexplained. Only the argument name and the refusal "
"reason are shown: the rejected values never leave the client log. This counts the "
"sessions in the local scan on this page, so it is a floor rather than an all-time "
"total.</p>"
f'<div class="table-wrap"><table><thead><tr><th>Tool</th><th>Argument</th>'
f"<th>Why agentacct refused it</th><th>Attempts</th></tr></thead>"
f"<tbody>{table_rows}</tbody></table></div>"
)
remainder_note = (
f'<p class="section-note">A further {esc(_fmt_int(unclassified))} scanned output(s) '
"donated no recorded event but are NOT counted above: they were refused by the client "
"before agentacct saw them, failed in the client's transport before reaching it (a "
"timed-out or dropped call), or carried a shape this build does not recognise. They stay "
"out of the refusal total rather than inflating it.</p>"
if unclassified
else ""
)
return f"""
<div class="subsection-title">Recording calls agentacct refused</div>
<div class="section-header" id="refused-recording-attempts"><h2>Recording calls agentacct refused</h2></div>
{body}
{remainder_note}
"""


def _display_provider_model(provider: Any, model: Any) -> str:
provider_label = _display_count_label(provider)
model_label = _display_count_label(model)
Expand Down Expand Up @@ -11457,6 +11563,10 @@ def source_sessions_cell(source: UsageSourceDiscovery) -> str:
("/runs", "agentacct-launched command JSON"),
]
)
# Refusals are derived from the same live scan this page already consumes,
# so they cover every rejection still on disk — including ones recorded
# long before this surface existed.
refused_recording_html = _refused_recording_html(usage_view.refused_recording, esc)

return f""" <section class="section" id="debug">
<div class="section-header"><h2>Raw Data / Debug</h2></div>
Expand All @@ -11471,6 +11581,7 @@ def source_sessions_cell(source: UsageSourceDiscovery) -> str:
<div class="subsection-title">Debug JSON endpoints</div>
<p class="section-note">Every GET rebuilds the work ledger from the store — no caching, by design (zero staleness); the measured envelope is fine to roughly 5,000 saved rows, revisit if stores grow beyond that.</p>
<div class="table-wrap"><table><thead><tr><th>Endpoint</th><th>Purpose</th></tr></thead><tbody>{debug_endpoint_rows}</tbody></table></div>
{refused_recording_html}
<div class="subsection-title">Work Timeline</div>
<div class="section-header"><h2>Work Timeline</h2><div class="sort-controls">View: {timeline_view_control}</div></div>
<p class="section-note">Chronological mix of usage, MCP work, evidence, diagnostics, and proxy events. The default Grouped view collapses usage import rows into one entry per agent and day (counts and exact sums only — grouping never allocates usage to work); Everything shows the raw row-level mix.</p>
Expand Down
11 changes: 7 additions & 4 deletions src/agentacct/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
from .reports import build_run_report_payload
from .runner import RunOptions, start_guarded_run
from .service import SentinelService, SessionObservationConflict
from .storage import RunStore, validate_run_id
from .storage import METADATA_MAX_BYTES, RunStore, json_utf8_size, validate_run_id
from .supervisor import OwnedSupervisor, SupervisorError
from .source_discovery import discover_usage_sources
from . import store_merge
Expand Down Expand Up @@ -686,11 +686,14 @@ def _parse_metadata_json(value: str | None) -> dict[str, object]:
if not isinstance(parsed, dict):
raise typer.BadParameter("--metadata-json must decode to a JSON object")
try:
encoded = json.dumps(parsed, sort_keys=True, allow_nan=False)
# Real UTF-8 bytes, measured by the same helper the HTTP and MCP lanes
# use: the escaped encoding billed CJK text 2x, so the same payload was
# accepted on one surface and rejected on another.
size = json_utf8_size(parsed, allow_nan=False)
except ValueError as exc:
raise typer.BadParameter(f"--metadata-json must be strict JSON: {exc}") from exc
if len(encoded.encode("utf-8")) > 8192:
raise typer.BadParameter("--metadata-json must be <= 8192 bytes when JSON encoded")
if size > METADATA_MAX_BYTES:
raise typer.BadParameter(f"--metadata-json must be <= {METADATA_MAX_BYTES} bytes when JSON encoded")
return parsed


Expand Down
Loading
Loading