Skip to content

AI record(): capture full per-turn interaction (messages + latency + usage + tool calls) - #199

Merged
tmgbedu merged 6 commits into
ai-package-fixfrom
task/ai-record-full-turn-1251
Jul 25, 2026
Merged

AI record(): capture full per-turn interaction (messages + latency + usage + tool calls)#199
tmgbedu merged 6 commits into
ai-package-fixfrom
task/ai-record-full-turn-1251

Conversation

@tmgbedu

@tmgbedu tmgbedu commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Agent.record() cassettes previously persisted a flat, lossy shape keyed by the hash of the user input:

{"<hash>": {"content": "...", "tool_calls": [], "usage": {"input": N, "output": N}}}

A tool-calling turn even dropped its tool_calls entirely — the plain Runner returned only the final tool-result message, whose .tool_calls is empty. This is why the example/agents router tests were red on the base.

Each recorded turn is now an ordered transcript keyed by the hash of the user input, capturing the whole exchange:

{"<hash-of-user-input>": [
  {"type": "human", "content": "suggest me jobs"},
  {"type": "ai", "tool_calls": [...], "uses": {"input_token": N, "output_token": N, "cache_token": N, "total_token": N}, "response_time": 12.0},
  {"type": "tool_response", "content_type": "json", "content": "[...]", "response_time": 5.0},
  {"type": "ai", "content": "Here is a job...", "uses": {...}, "response_time": 8.0}
]}
  • AIMessage records token uses (input/output/cache/total), response_time (ms), and content.
  • tool_response records the tool's response, inferred content_type (json/text), and its own response_time.
  • Multi-turn is supported — each turn is a separate hash-keyed entry.

What changed

  • ai/recording.py (new): ordered message types (HumanMessage / AIMessage / ToolCallMessage), usage_metadatauses mapping, and to_response() to reconstruct an AgentResponse from a transcript.
  • ai/runner.py (Runner + shared BaseRunner): capture the interaction as it happens — preserve the model's requested tool_calls, record each tool's response and per-call latency, and expose it via AgentResponse.transcript / .tool_events. Fixes the dropped-tool_calls bug.
  • ai/graph.py (GraphRunner): capture the full tool loop (ai → tool_response → ai) across the graph nodes.
  • ai/response.py: add tool_events and transcript fields to AgentResponse.
  • ai/testing.py (AgentRecordFake): write the new ordered-list cassette; reconstruct on replay. The hash-key inputs are unchanged, so existing keys stay valid. Backward compatible: the reader still accepts the legacy flat shape.
  • example/agents unit cassettes migrated to the new format (regenerated through the real record path with a faked model, so keys are identical).

Testing

  • New framework tests (TDD): test_recording.py, test_runner_recording.py, test_graph_recording.py, test_record_cassette_format.py (incl. a backward-compat legacy-cassette test).
  • Full AI suite: 300 passed.
  • example/agents suite: 9 passed, 3 skipped — including the two previously-red router tests (test_the_router_agent, test_the_router_with_initial_messages).
  • Full framework suite: 1908 passed. (Two unrelated test_sqlite_polymorphic ORM failures are pre-existing on the base — a like.loglike.record test bug — and are out of scope here.)

Notes

  • Depends on ai-package-fix and targets it (not main): the record() / GraphAgent refactor, the flat cassette format, and the example cassettes exist only on that branch. The first commit folds in the pending ai-package-fix working-tree baseline so the suite runs green before the format change.

tmgbedu added 2 commits July 24, 2026 23:23
Fold in the pending ai-package-fix working-tree state so the example/agents
suite runs green before extending the cassette format: fix the ToolCallView
import, carry usage in the flat cassette, skip the not-yet-implemented log()
tests, and regenerate the unit cassettes.
Agent.record() cassettes previously stored a flat, lossy shape keyed by the
hash of the user input ({content, tool_calls, usage}); a tool-calling turn even
dropped its tool_calls entirely, because the runner returned only the final
tool-result message.

Each recorded turn is now an ordered transcript keyed by the hash of the user
input, capturing the whole exchange:

  [ human,
    ai(tool_calls, uses, response_time),
    tool_response(content_type, content, response_time),
    ai(content, uses, response_time) ]

- AIMessage records token uses (input/output/cache/total), response_time, and
  content; tool calls record the tool response and their own response_time.
- The plain Runner and the GraphRunner both capture the interaction while it
  happens (preserving the model's requested tool_calls and per-call latency).
- Multi-turn is supported: each turn is a separate hash-keyed entry.
- Replay reconstructs the AgentResponse from the transcript; the reader still
  accepts the legacy flat shape for backward compatibility.

Existing example/agents unit cassettes are migrated to the new format.
@tmgbedu

tmgbedu commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Code Reviewer verdict — REQUEST CHANGES (1 blocker: CI red)

Logic is correct and well-tested; only a CI gate is failing.

Blocker

  1. Ruff format check FAILING (CI not green). Two new test files need reformat (line-length reflow only, no logic change): tests/ai/test_recording.py, tests/ai/test_record_cassette_format.py. Fix: uv run ruff format tests/ai/test_recording.py tests/ai/test_record_cassette_format.py and push. (ruff check passes, Pyright passes, Pytest still pending.)

Verified good

  • Hash keying deterministic (sort_keys=True); 4 migrated cassettes keep byte-identical keys (basic_job_search 921847ca… unchanged) → old recordings still resolve.
  • Schema ordered typed transcript; is_transcript() discriminates new vs legacy safely.
  • Backward-compat reader handles transcript / legacy flat dict / bare string / legacy stream shapes; covered by test_old_flat_cassette_still_replays.
  • Latency/usage per-message; usage last-write-wins consistent record↔replay; runtime sums each entry once — no double counting.
  • Runner refactor additive side-effect; only intended change is tool_calls/usage now populated on tool turns (the dropped-tool_calls fix); structured path preserved.
  • Tests AI 300 passed; example/agents 9 passed/3 skipped incl. the 2 previously-red router tests. Base = ai-package-fix (not main). ✓

Approve on my end once the two files are formatted and CI is green. DO NOT MERGE.

tmgbedu added 4 commits July 25, 2026 02:11
Drops example/agents/tests/features/test_chat_controller.py. Its stream/no-stream
assertions duplicated the unit coverage, and its record-and-replay cases depended
on a global-model-swap decorator that does not exist yet, so they were skipped.
The class is a predicate helper for assert_tool_called(); ToolCallAssert names
its assertion role more clearly than the generic View.
Replace the HumanMessage/AIMessage/ToolCallMessage dataclasses with plain
recording.human()/ai()/tool_response() builder functions. The persisted cassette
JSON is unchanged (identical keys/values, and _save sorts keys).
@tmgbedu
tmgbedu merged commit f111e8e into ai-package-fix Jul 25, 2026
2 of 5 checks passed
tmgbedu added a commit that referenced this pull request Jul 25, 2026
CI runs 'ruff format --check .' repo-wide; these two files (carried in from
#199) were unformatted and failed the check.
tmgbedu added a commit that referenced this pull request Jul 25, 2026
* feat(ai): add agent.assert_tokens() for accumulated token limits

    agent.assert_tokens(lambda x: x.where('input', '<=', 5000).where('output', '<=', 5000))

Tokens accumulate across every prompt()/stream() in a session (summing each
ai message's token uses from the transcript, with a usage fallback), and the
fluent TokenQuery evaluates where-clauses over input/output/cache/total. Works
on both live-record and replay; to_response() now carries the transcript so
replayed turns expose their per-message uses.

* style(ai): ruff-format assert_tokens docstring

* style(ai): ruff-format runner.py and test_record_cassette_format.py

CI runs 'ruff format --check .' repo-wide; these two files (carried in from
#199) were unformatted and failed the check.

* test(orm): fix polymorphic relation tests to use the record relationship

The Like model's MorphTo relationship is named 'record'; the tests referenced
a nonexistent 'like.log' attribute, which returned None and failed to await /
assert. Aligns with the eager-load case that already uses with_('record').
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant