Files lists one document once per turn that touches it, so a project's file view grows without bound
A file artifact's identity is derived from the turn that registered it, and
nothing collapses rows across turns. The bytes are deduplicated correctly; the
listing is not. In a project where an agent hands the same document back a few
times, the Files view fills with entries that are byte-identical, same-named, and
indistinguishable at a glance.
(Filenames below are anonymised — this came out of a finance project on my own
deployment. Structure and counts are verbatim.)
What I saw
Four documents, ten rows, one project scope:
| time |
actor |
direction |
rows |
| 17:08:13 |
sales@… |
in |
2 |
| 17:22:14 |
finance@… |
in |
4 |
| 17:22:51 |
agent reply carried them |
out |
4 |
The two documents both people contributed have three rows each; the two only
one person contributed have two. Every row for a given document has the same
sha256, the same blob_key, a different id, and a different path.
The byte layer is fine — keyFor in src/files/durable-byte-store.ts:42 is
files/${sha256}, and the store stats before writing (:83). On disk there are
exactly four blobs for four documents, and the two written at 17:08 still carry
that mtime after the 17:22 re-registration. Nothing is stored twice. It is only
the artifact rows, and the view built from them, that multiply.
Why it grows
src/core/orchestrator.ts:1609 seeds the artifact id with the run:
const fileRegistration: ArtifactRegistration = {
store: deps.files,
ownerScopeId: toScopeId("personal", actor.id),
createdBy: actor.id,
createdInScope: scopeId,
seed: input.runId ?? `${session.id}:${Date.now()}`,
// src/files/file-artifact-store.ts:75
export function fileArtifactId(seed: string, direction: FileDirection, batchIndex: number): string {
return createHash("sha256").update(`${seed}:${direction}:${batchIndex}`).digest("hex").slice(0, 32);
}
and the insert dedupes on that id alone:
-- src/files/postgres-file-artifact-store.ts:89
ON CONFLICT (id) DO NOTHING
So the idempotency key is the turn. Retrying within a run is safe, which is
presumably the intent; but two turns that carry the same bytes are two rows by
construction. An agent that attaches the same spreadsheet in ten replies leaves
ten rows. This is not a fixed +2 — it is one row per document per turn that
touches it, and it only accumulates.
This is also a property of the registration path, not of artifact ids in
general. Every conversation attachment — inbound via materializeInbound,
outbound via collectOutbound/collectNamedOutbound
(src/core/attachments.ts) — flows through the same per-turn seed. The one
counter-example in the repo is the write tool's share path
(src/tools/primitives.ts:612), which registers with a stable id
(fileArtifactId(\${writableScopeId}:${path}`, "out", 0)) and the writable scope as owner — so re-sharing an unchanged file across turns collapses to one row via the same ON CONFLICT`. Cross-turn deduplication by id choice is
already an established pattern here, just not on the conversation path where
the multiplication actually happens.
The listing has no collapsing step to compensate:
-- src/files/postgres-file-artifact-store.ts:141
SELECT * FROM file_artifacts
WHERE ... created_in_scope = $n ...
ORDER BY created_at DESC, id DESC
sha256 is stored on every row and is already the blob key, so the information
needed to group is present and unused. The indexes that exist are
(owner_scope_id, path) (non-unique, and path embeds the id, so it varies
with the same turn seed that made the row distinct in the first place) and a
partial (created_in_scope, created_at DESC, id DESC) WHERE enabled = TRUE
(:39-40) — the latter already serves the scope listing's ordering; what's
missing is the grouping key.
A second effect, same root
src/core/orchestrator.ts:1606:
ownerScopeId: toScopeId("personal", actor.id),
A document surfaced inside a project is owned by whoever's turn produced it. In
my ten rows the same document is owned by two different people. So a project's
files aren't the project's; they're a union of personal artifacts that happen to
share a created_in_scope. That is defensible for ACL purposes, but it means the
duplicate rows also disagree about ownership, and there's no row that represents
"this document, in this project."
Why this matters more than it looks
The scope where I hit it was finance. Three identically-named copies of a
reimbursement schedule, with no visible marker of which arrived from whom and
which the agent emitted, is not a cosmetic problem — picking the wrong one is a
wrong filing. And because the count is per-turn, the view degrades with use: the
projects where the agent is most active are the ones whose file lists become
least usable.
Suggestion
Collapse the listing on (created_in_scope, sha256): one row per document,
earliest createdAt, a count that expands to the history, and the set of
contributors. FileListItem (src/api/app-types.ts:522) already carries
direction, ownerScopeId and createdAt, so the surfaces need no new
endpoint — but the contributors line does require one addition:
FileListItem has no createdBy today, so either it gains that field or the
list route returns the grouped rows with authors included.
If a per-turn row is wanted as the underlying ledger — and it is a reasonable
thing to keep — then the fix is to stop treating that ledger as the file list.
Either way, adding (created_in_scope, sha256) alongside the existing
(created_in_scope, created_at …) partial index would make the grouped read
cheap without disturbing the current ordering.
I can test a branch against a live deployment; the reproduction above is two
people dropping the same file into a project and asking the agent to hand it
back.
Files lists one document once per turn that touches it, so a project's file view grows without bound
A file artifact's identity is derived from the turn that registered it, and
nothing collapses rows across turns. The bytes are deduplicated correctly; the
listing is not. In a project where an agent hands the same document back a few
times, the Files view fills with entries that are byte-identical, same-named, and
indistinguishable at a glance.
(Filenames below are anonymised — this came out of a finance project on my own
deployment. Structure and counts are verbatim.)
What I saw
Four documents, ten rows, one project scope:
sales@…infinance@…inoutThe two documents both people contributed have three rows each; the two only
one person contributed have two. Every row for a given document has the same
sha256, the sameblob_key, a differentid, and a differentpath.The byte layer is fine —
keyForinsrc/files/durable-byte-store.ts:42isfiles/${sha256}, and the storestats before writing (:83). On disk there areexactly four blobs for four documents, and the two written at 17:08 still carry
that mtime after the 17:22 re-registration. Nothing is stored twice. It is only
the artifact rows, and the view built from them, that multiply.
Why it grows
src/core/orchestrator.ts:1609seeds the artifact id with the run:and the insert dedupes on that id alone:
So the idempotency key is the turn. Retrying within a run is safe, which is
presumably the intent; but two turns that carry the same bytes are two rows by
construction. An agent that attaches the same spreadsheet in ten replies leaves
ten rows. This is not a fixed +2 — it is one row per document per turn that
touches it, and it only accumulates.
This is also a property of the registration path, not of artifact ids in
general. Every conversation attachment — inbound via
materializeInbound,outbound via
collectOutbound/collectNamedOutbound(
src/core/attachments.ts) — flows through the same per-turn seed. The onecounter-example in the repo is the write tool's share path
(
src/tools/primitives.ts:612), which registers with a stable id(
fileArtifactId(\${writableScopeId}:${path}`, "out", 0)) and the writable scope as owner — so re-sharing an unchanged file across turns collapses to one row via the sameON CONFLICT`. Cross-turn deduplication by id choice isalready an established pattern here, just not on the conversation path where
the multiplication actually happens.
The listing has no collapsing step to compensate:
sha256is stored on every row and is already the blob key, so the informationneeded to group is present and unused. The indexes that exist are
(owner_scope_id, path)(non-unique, andpathembeds the id, so it varieswith the same turn seed that made the row distinct in the first place) and a
partial
(created_in_scope, created_at DESC, id DESC) WHERE enabled = TRUE(:39-40) — the latter already serves the scope listing's ordering; what's
missing is the grouping key.
A second effect, same root
src/core/orchestrator.ts:1606:A document surfaced inside a project is owned by whoever's turn produced it. In
my ten rows the same document is owned by two different people. So a project's
files aren't the project's; they're a union of personal artifacts that happen to
share a
created_in_scope. That is defensible for ACL purposes, but it means theduplicate rows also disagree about ownership, and there's no row that represents
"this document, in this project."
Why this matters more than it looks
The scope where I hit it was finance. Three identically-named copies of a
reimbursement schedule, with no visible marker of which arrived from whom and
which the agent emitted, is not a cosmetic problem — picking the wrong one is a
wrong filing. And because the count is per-turn, the view degrades with use: the
projects where the agent is most active are the ones whose file lists become
least usable.
Suggestion
Collapse the listing on
(created_in_scope, sha256): one row per document,earliest
createdAt, a count that expands to the history, and the set ofcontributors.
FileListItem(src/api/app-types.ts:522) already carriesdirection,ownerScopeIdandcreatedAt, so the surfaces need no newendpoint — but the contributors line does require one addition:
FileListItemhas nocreatedBytoday, so either it gains that field or thelist route returns the grouped rows with authors included.
If a per-turn row is wanted as the underlying ledger — and it is a reasonable
thing to keep — then the fix is to stop treating that ledger as the file list.
Either way, adding
(created_in_scope, sha256)alongside the existing(created_in_scope, created_at …)partial index would make the grouped readcheap without disturbing the current ordering.
I can test a branch against a live deployment; the reproduction above is two
people dropping the same file into a project and asking the agent to hand it
back.