Repository navigation
Conversation
run_metadata restored from the JSON cache holds plain dicts, while a resumed run appends live metadata models for the same tools. _aggregate_list only handled dict+dict, so the mixed list raised TypeError at run end. Rehydrate dict entries into the type of the live model in the same list so __add__ applies, in either order.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #92. A session resumed with
session(resume=True, cache_on_interrupt=True)crashed at run end: the per-tool metadata restored from the JSON cache is plain dicts, the new run appends live metadata models for the same tools, and aggregating the mixed list raisedTypeError: unsupported operand type(s) for +: 'dict' and 'ToolUseCountMetadata'— after the agent had already finished its work.Root cause
CacheState.to_dictserializes metadata items withmodel_dump(mode="json")(core/cache.py), andCacheState.from_dictputs the resulting dicts back intorun_metadata_by_turnunchanged — by design: the cache layer is type-agnostic, and the existing tests (test_cache_state_preserves_run_metadata_by_turn,test_v01_cache_state_preserves_aggregate_run_metadata) pin that restored metadata stays as plain dicts. The serialized form carries no type information, and there is no registry mapping a tool name to its metadata class, so restore-time rehydration cannot know which model each dict belongs to (ToolUseCountMetadata,SubAgentMetadata, the browser tools'SearchMetadata/NavigateMetadata/InputTextMetadata, or a user's ownAddablemodel)._merge_run_metadataflattens the per-turn lists and the logger callsaggregate_metadata, whose_aggregate_list(core/models.py) only special-cases dict + dict; a mixed pair fell through toaggregated + mand raised.Changes
_aggregate_listnow tolerates mixed lists: when one side is a dict and the other is a live model, the dict is validated into the live item's type (type(m).model_validate(...)) and the pair is combined with the model's own__add__, in either order. Lists are per-tool, so the live sibling is always the right schema. This:Addablemetadata model, including custom ones andSubAgentMetadata(whose nestedrun_metadatalists aggregate through the same function);If a cached dict genuinely cannot validate into the sibling's type (e.g. the metadata schema changed across versions),
model_validateraises aValidationErrornaming the field, instead of the opaqueTypeError.Tests
I wrote the tests first and checked that they fail without the fix. With
src/stirrup/core/models.pyreverted, all four new tests intests/test_agent.pyfail with the reportedTypeError(including adict + SubAgentMetadatavariant); with the fix, all pass:test_resumed_run_metadata_aggregates_cached_dicts_with_live_models— the reported flow end to end: aCacheStateround trip through JSON, a resumed turn appending a liveToolUseCountMetadata, and the run-end aggregation returning{"my_tool": {"num_uses": 5}}(cached 2 + live 3).test_aggregate_metadata_combines_dicts_and_models_in_either_order— dict-first, model-first, and two dicts followed by a model.test_aggregate_metadata_rehydrates_dicts_into_the_live_model_type— a custom metadata model (stand-in for tool-specific types likeSearchMetadata), proving dicts take the live sibling's type rather than a hard-coded one.test_aggregate_metadata_combines_cached_sub_agent_dicts_with_live_models— a cache-restoredSubAgentMetadatadict aggregated with a live one, including the nested per-tool lists.Local runs (Python 3.13,
uv sync --locked --dev --extra mcp; CI syncs--all-extras):tests/test_agent.py+tests/test_logging.py: 63 passed.pytest --ignore=tests/test_browser_use.py -m "not docker and not e2b and not browser"): 322 passed / 32 failed with the fix, against 34 failed on unmodifiedmainin the same sandbox. The with-fix failure list is a strict subset of the baseline list — the 32 shared failures are the chat-completions/open-responses client tests failing insidehttpxURL parsing in this sandbox (proxy environment), pre-existing and unrelated; the 2 baseline-only failures are flaky environment tests (local cross-environment write, web-fetch IPv6 literal) that passed in the with-fix run.ruff format --check .andruff check .pass;ty checkreports no diagnostics in the changed files (the 22 whole-repo diagnostics are identical on unmodifiedmainand sit in optional-extra modules whose dependencies were not installed in this sync).