Skip to content

fix(code): count distinct targets in grouped tool summaries - #5409

Merged
Mason Daugherty (mdrxy) merged 12 commits into
mainfrom
mdrxy/code/file-read-label
Aug 11, 2026
Merged

fix(code): count distinct targets in grouped tool summaries#5409
Mason Daugherty (mdrxy) merged 12 commits into
mainfrom
mdrxy/code/file-read-label

Conversation

@mdrxy

@mdrxy Mason Daugherty (mdrxy) commented Aug 11, 2026

Copy link
Copy Markdown
Member

The summary line for a step counts the tool calls, but it shows the count with a noun. If the agent reads one file two times, the line shows Read 2 files. Only one file is correct.

Tool calls Before After
read a.py two times Read 2 files Read 1 file
read a.py, read b.py Read 2 files Read 2 files
edit a.py three times Edited 3 files Edited 1 file (3 edits)
edit a.py two times, edit b.py Edited 3 files Edited 2 files (3 edits)
fetch one URL two times Fetched 2 URLs Fetched 1 URL (2 calls)

The line now counts the different targets. A target is the file or the URL of a call, from the file_path, path or url argument. The present-tense line uses the same counts, for example Reading 1 file.

Every mutating category (edit, write, delete) and fetch also show the number of operations in parentheses. Each mutation writes to the file and has a diff, so the number of files alone is not sufficient: a group spans a whole step, so delete a.py, write_file a.py, delete a.py is two real deletions of one path. A repeated fetch of one URL is a deliberate new request, so Fetched 1 URL alone would hide the second call. A repeated read keeps one number, because the agent usually reads a large file in parts.

Limits

  • shell, js, task and search count attempts, not objects. Two runs of one command are two operations.
  • web_search shows repeated calls with a different phrase, for example Searched the web 2 times.
  • ls counts every listing. A listing is a snapshot, not a durable object, so an intervening write can make the second listing of one directory show different contents.
  • A call whose target argument is missing or is not a string counts for itself. Failing to identify a target only restores the old count, while a wrong match would hide work.

Paths are compared in the canonical form the filesystem middleware itself produces. _normalize_path_target mirrors validate_path, so two spellings that the middleware resolves to one file count as one file. The normalization is purely lexical and never touches the filesystem.

  • A relative and an absolute spelling match, because the middleware anchors a relative path at the virtual root: a.py and /a.py are one file.
  • A backslash is a separator, because the middleware accepts one: a\b and a/b are one file.
  • Redundant segments collapse: d//a.py, d/./a.py and d/a.py/ are all d/a.py.
  • A .. segment turns the normalization off. The middleware rejects those paths, so there is no canonical form to agree with, and folding one would count a call that could not have run.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
@github-actions github-actions Bot added dcode Related to `deepagents-code` internal User is a member of the `langchain-ai` GitHub organization linting Code style or linting changes size: XS < 50 LOC labels Aug 11, 2026
@mdrxy
Mason Daugherty (mdrxy) marked this pull request as ready for review August 11, 2026 05:50

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Open SWE Review: No issues found

Open SWE reviewed this PR and found no potential bugs to report.

Open in WebView Open SWE trace

Note that the past verb in `_TOOL_SUMMARY_PHRASES["read"]` is unreachable,
record why the noun phrasing is past-only, and document that digit-leading
segments pass through `_join_segments` lowercasing unchanged.
@github-actions github-actions Bot added size: S 50-199 LOC and removed size: XS < 50 LOC labels Aug 11, 2026
The carve-out comment in `_summary_segment` already records why settled
reads skip the table, so restating the consequence beside the table only
adds a second place to keep in sync — and its diff-eligibility claim would
silently rot if `read` were ever added to `_DIFF_HEADER_CATEGORIES`.
The group summary counted calls while phrasing the count as nouns, so one
file read twice rendered as "Reading 2 files" / "Read 2 files", overstating
how much of the tree the step touched. The same defect hit every category
whose noun is an object: "Edited 2 files" for one file edited twice,
"Fetched 2 URLs" for one URL fetched twice.

Collapse repeat calls on a target before counting, keyed on the arg that
names the object (`file_path`/`path`, or `url`). Categories that count
attempts rather than objects — shell, js, task, search — are left alone,
since running one command twice is genuinely two runs. `ls` is excluded
because a bare `ls()` means the backend's working directory, which
`execute` can change mid-step.

Deduping happens per tense bucket, so a file whose second read is still in
flight is still reported as being read. Targets that cannot be identified
(missing or unparsed args) are always counted, so the fallback is the old
behavior rather than an undercount.

This supersedes the "N file reads" phrasing, which only covered reads, only
in the past tense, and broke the parallelism of mixed lines; summaries read
"Edited 1 file, wrote 1 file, read 1 file" again.
@github-actions github-actions Bot added size: M 200-499 LOC and removed size: S 50-199 LOC labels Aug 11, 2026
@mdrxy Mason Daugherty (mdrxy) changed the title style(code): label grouped file reads as operations fix(code): count distinct targets in grouped tool summaries Aug 11, 2026
@github-actions github-actions Bot added fix A bug fix (PATCH) and removed linting Code style or linting changes labels Aug 11, 2026

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Open SWE Review found 1 potential issue.

Open in WebView Open SWE trace

Comment thread libs/code/deepagents_code/tui/widgets/messages.py Outdated
Counting distinct targets fixed the inflated file counts but hid churn on
mutating tools: three edits of one file read as "Edited 1 file", when each
edit is an event that changed the tree and owns its own diff. Reads are
different -- a repeat read is usually pagination, where the count is noise.

Report both numbers for mutations, with the operation count trailing in
parentheses only when it differs from the target count:

    Edited 1 file (3 edits)     3 edits of one file
    Edited 2 files (3 edits)    both numbers survive when they disagree
    Edited 2 files              no repeats, no parenthetical
    Read 1 file                 3 reads of one file, collapsed silently

Applies to edit and write. `delete` is excluded: a second successful delete
of one path cannot happen, since a failed retry is evicted from the group
before it is summarized.

The summary API now takes `(name, args)` pairs rather than bare tool names.
Passing names alone is what made the original overcount possible -- the
phrasing claims nouns, which cannot be counted without the argument naming
each call's target -- so the impoverished signature is gone rather than
worked around. `_tally_categories` returns distinct targets and total calls
together, and the live-line cache key pairs each name with its target so a
second read of a new file still invalidates.
@github-actions github-actions Bot added size: L 500-999 LOC and removed size: M 200-499 LOC labels Aug 11, 2026
`normpath` was applied to every summary target, including the `fetch`
category's URL. Path rules are wrong for a URL, and each rewrite it makes
merges two addresses a server can answer differently:

    /a//b    and /a/b   -> https:/x/a/b
    /a/      and /a     -> https:/x/a
    /a/../b  and /b     -> https:/x/b

Each collision reports "Fetched 1 URL" for two distinct fetches, an
undercount -- the one direction this code is supposed to never move in, since
an unrecognizable target is deliberately counted rather than merged.

Restrict `normpath` to the categories whose target is a genuine filesystem
path and compare anything else exactly. Naming those categories in a
frozenset rather than testing for `fetch` keeps a future non-path category
from silently inheriting path semantics.
…etes

The grouped tool-summary line identified a call's target with
`os.path.normpath`, which resolves to Windows rules on a Windows client.
The paths belong to the backend, so `a\b` — a legal POSIX file name —
merged with `a/b` and the line reported one file where two were read.
Normalize with `posixpath` on every platform instead.

`normpath` also resolves `..` lexically, so `d/../a.py` matched `a.py`
even when `d` is a symbolic link to somewhere else. A `..` segment now
turns the normalization off: a missed match only restores the old count,
while a wrong match hides work.

`delete` gains a repeat noun. A group spans a whole step, so `delete
a.py`, `write_file a.py`, `delete a.py` is two real deletions of one
path, and the line rendered `Deleted 1 file` — the second deletion was
invisible. It now reads `Deleted 1 file (2 deletions)`, like the other
mutating categories.

The comments claiming otherwise are corrected. `ls` was said to be
excluded because a bare `ls()` names the backend's working directory,
which `execute` can change mid-step; `LsSchema.path` is required and
`execute` cannot change that directory. A listing is excluded because it
is a snapshot, not a durable object. The `delete` omission was said to be
impossible rather than merely unusual. Two docstrings describing the
summary as built from tool names alone now say names and arguments.

`ToolCallMessage.summary_call` reads the arguments without copying them.
The group rebuilds its cache key from every member on each spinner tick,
and `args` copies the dict on every access, so the 10 Hz path allocated
per member per tick to decide whether cached text was still valid.

Tests cover the live render path, which could previously be wired to
empty arguments with the suite still green, and freeze the invariants
that the tables and the shared `seen` set depend on.

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Open SWE Review found 1 potential issue.

Open in WebView Open SWE trace

Comment thread libs/code/deepagents_code/tui/widgets/messages.py Outdated
Four rationale comments in the grouped-summary path asserted things that are
not true of the code they sit on:

- `summary_call` claimed the tick path "avoids all per-tick work". The cache
  key is rebuilt per member per tick, so it does not. The no-copy choice is
  still right; only the stated cost model was wrong. Also scopes the
  immutability claim to this widget, since `_args` is the caller's dict and is
  shared with `textual_adapter` and `message_store`.
- `_summary_cache_key` claimed membership is append-only. Eviction removes
  members. The conclusion survives because eviction clears the cache too, so
  the comment now names the property that actually carries the argument.
- `_summary_segment` said "mutating category", but `fetch` appends a repeat
  count and is not mutating. Names the gate instead of paraphrasing it.
- `_REPEAT_COUNT_NOUNS` said only "read" is absent, true only among categories
  that name a target.

Also lists "todos" among the categories deliberately kept out of
`_TOOL_SUMMARY_TARGET_ARGS`, so the set does not read as an oversight, and
points the URL comment at `_PATH_TARGET_CATEGORIES` rather than "the type's
docstring".
Adds cases for behavior that was live but unpinned, so a plausible cleanup
could break it while the suite stayed green:

- The `("file_path", "path")` fallback scans past a `file_path` that is
  present but empty or None. Verified by mutation: rewriting the loop to take
  the first key that appears silently disables the fallback and previously
  passed every test.
- Trailing-slash and `d/./a.py` spellings collapse to one target. These are
  collapses, the direction that must never be wrong, and were the only
  collapsing rules with no test.
- An edit that errors is evicted from the repeat count, so two edits of one
  file where one fails closes as "Edited 1 file". Both edits are settled and
  rendered before the failure so the past-tense line is cached as "(2 edits)"
  first; that ordering is what makes the cache reset in `_evict_unfoldable`
  load-bearing, and the test fails if the reset is dropped.

Pins `_normalize_path_target` against `validate_path` itself rather than
hand-written expectations. The identity only has to be the middleware's
canonical form, so comparing the two directly is what catches drift, and the
traversal case pins the other half: a path the middleware rejects is returned
verbatim so it can never fold into a valid target's tally.

Also corrects a docstring in `TestCaveatedRowsLeaveTheGroup` still describing
the summary as built from tool names alone.
Summary target identities normalize file paths so two spellings of one
file count as one file. The normalizer preserved only `..` traversal, but
the filesystem middleware also rejects a leading `~` and a drive prefix
like `C:/`. Those spellings were canonicalized anyway, so a read of `~`
and a read of the valid virtual path `/~` produced the same identity and
the group reported "Read 1 file" for two different targets.

Delegate to `validate_path` instead of reimplementing it: the identity is
now exactly the string the file tools act on, and any path the middleware
refuses is returned verbatim, so no call that could not have run is
folded into a valid target's count.
@mdrxy
Mason Daugherty (mdrxy) merged commit cf8ea93 into main Aug 11, 2026
57 checks passed
@mdrxy
Mason Daugherty (mdrxy) deleted the mdrxy/code/file-read-label branch August 11, 2026 21:47
Mason Daugherty (mdrxy) pushed a commit that referenced this pull request Aug 12, 2026
> [!CAUTION]
> Merging this PR will automatically publish to **PyPI** and create a
**GitHub release**.

For the full release process, see
[`.github/RELEASING.md`](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md).

---

_Release notes preview: keep this section in sync with the package
`CHANGELOG.md`. Publish reads the merged CHANGELOG via `release.yml`,
not this PR description — keep them aligned anyway so the PR stays an
accurate historical record for reviewers and anyone returning later._

---


##
[0.1.55](deepagents-code==0.1.54...deepagents-code==0.1.55)
(2026-08-12)

### Features

- Added a `/context` usage report for inspecting context consumption
([#5407](#5407)).
- Added a cache and context status row for at-a-glance session state
([#5408](#5408)).
- Added configurable warnings when a session exceeds the configured cost
threshold
([#5405](#5405)).
- Added support for persisting and reconfiguring ACP sessions
([#5366](#5366)).
- Added automatic updates for installed plugins
([#5368](#5368)).
- Added a toggle for diff line numbers
([#5427](#5427)).
- `Ctrl+S` in `/auto model` now stores `[models].auto_classifier`
([#5313](#5313)).

### Fixes

- Restored edit diffs in resumed threads
([#5391](#5391)).
- Added a resume hint after crashes
([#5412](#5412)).
- Clarified the project hooks trust prompt and stopped prompting for
user hooks
([#5426](#5426)).
- Cleared dynamic subagents on the next turn
([#5437](#5437)).
- Improved grouped tool summaries by counting distinct targets
([#5409](#5409)).
- Improved ask-user choice wrapping and selection styling
([#5442](#5442)).
- Serialized `dcode` self-upgrades across processes
([#5252](#5252)).
- Added warnings for stale dependencies in editable installs
([#5386](#5386)).
- Hid incomplete extras from version output
([#5352](#5352)).
- Removed the optional-provider startup tip
([#5421](#5421)).
- Removed the “Message restored to input” toast
([#5253](#5253)).

_End release notes preview._

---

> [!NOTE]
> A **New Contributors** section is appended to the GitHub release notes
automatically at publish time (see [Release
Pipeline](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md#release-pipeline),
step 2).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: langchain-oss-automated-triage[bot] <248757908+langchain-oss-automated-triage[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dcode Related to `deepagents-code` fix A bug fix (PATCH) internal User is a member of the `langchain-ai` GitHub organization size: L 500-999 LOC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant