Skip to content

feat(coding-agents): add per_source observation scoping - #3872

Open
shauneccles wants to merge 3 commits into
vectorize-io:mainfrom
shauneccles:feat/coding-agents-per-source-scopes
Open

feat(coding-agents): add per_source observation scoping#3872
shauneccles wants to merge 3 commits into
vectorize-io:mainfrom
shauneccles:feat/coding-agents-per-source-scopes

Conversation

@shauneccles

Copy link
Copy Markdown

Closes #3871.

The problem

On a repo worked by a coding agent, this plugin writes documents from genuinely different origins into one bank: commit diffs (source:git), the commit-message seed (source:git + source:git-log), session transcripts (source:chat), uploads (source:upload).

A commit diff records what the code actually does. A transcript records what someone intended, argued for, or discarded. Under the shared default these consolidate into one undifferentiated belief set, so:

  • an idea floated in chat and never implemented is indistinguishable from a belief derived from the diffs;
  • "what does the codebase actually do" cannot be answered from commit-derived knowledge alone;
  • when the two conflict, consolidation reconciles them into a single belief rather than keeping both claims with their origin intact.

Why this cannot be done with configuration

This is the crux, and it is why the change is code rather than a docs note.

observationScopes already accepts an explicit string[][], so the obvious answer is "just configure [[], ["source:git"], ["source:chat"]]". That does not work. The server treats an explicit list as unconditional — it is never filtered against the memory's own tags. In hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py, both resolvers end the same way:

def _resolve_obs_tags_list(memory): ...
    return parsed  # explicit list[list[str]]

def _resolve_write_scopes(memory): ...
    return [frozenset(s) for s in parsed]  # explicit list[list[str]]

parsed comes back verbatim. So that configuration writes every document into all three scopes: the source:git scope fills with observations built from chat transcripts and vice versa. The scopes exist but mean nothing, which is worse than not having them.

Every scalar mode the server offers derives its scopes from the memory's own tags, but none derives from part of the tag set. Only a per-document decision, made where the tags are known, separates these.

The change

"per_source" becomes an accepted observationScopes value. It is resolved client-side, per document, in a new exported resolveRetainScopes(tags, configured) in src/core/hindsight.ts, and never reaches the server. It expands to the global scope plus one per distinct source: tag the document carries, sorted:

document tags resolved scopes
session transcript source:chat [[], ["source:chat"]]
commit diff source:git [[], ["source:git"]]
commit-message seed source:git, source:git-log [[], ["source:git"], ["source:git-log"]]
no source tag knowledge:convention [[]]

A document may legitimately carry more than one source tag — the commit-message seed keeps source:git alongside source:git-log so the cold-repo check (listDocumentIds("source:git")) still sees it — so every distinct one gets a scope. Taking all of them, sorted, is the only rule that needs no arbitrary tie-break and does not silently depend on the order git.ts happens to emit its tags in.

this.observationScopes still flows to the single call site that sets the field; only the value it resolves to is now per document.

On "double the consolidation cost"

That is the explicit rationale of #3575, so it deserves a direct answer rather than a footnote.

Yes: per_source costs one extra consolidation pass per document (two for the commit-message seed). The difference from the case #3575 was right to refuse is what the extra pass buys. Splitting on harness:<id> is provenance — which agent typed it — and the extra pass buys two copies of the same belief, each blind to the other. Splitting on source: is semantic: the origin changes what kind of claim the document is making, and once a commit-derived belief and a chat-derived belief merge into one observation, no read-side filter can separate them again, because the merged observation has a single origin-free identity.

So the cost is real, it is the price of the axis, and it is why this is opt-in.

Why not per_tag

per_tag splits on the right axis but also on every other one — _resolve_obs_tags_list maps it to [[t] for t in tags], unconditionally. It would reinstate the per-agent harness: fork that #3564 and #3575 removed, and any volatile tag (a session id added through retainTags) would become its own scope, which is the fragmentation bug itself. Reading only source: is what keeps the split safe: a small, closed, meaningful set of values the plugin itself controls.

What is deliberately unchanged

One test was widened — deliberately

The source-text guard in hindsight.test.ts ("keeps that call site inside retain(), with the scoping on the item it posts") went from

expect(body).toContain("observation_scopes: this.observationScopes");

to

// The scoping may be derived per document (see `per_source`), but it must still be set on the
// item here and still come from the configured value — not from a server default.
expect(body).toMatch(/observation_scopes: .*this\.observationScopes/);

Flagging it explicitly so it does not read as weakening a test. The guard's intent is intact: the field must still be set inside retain(), on the item it posts, and still derive from this.observationScopes rather than being left to the server default. The regex only allows the value to be wrapped in a resolver. A call site that dropped the scoping, moved it out of retain(), or hardcoded a value still fails.

Lineage

Test plan

  • npx tsc --noEmit — clean
  • npx vitest run — 55 files passed, 665 passed / 23 skipped
  • npm run build — succeeds
  • npm run skill:build and node hindsight-docs/scripts/sync-coding-agents-doc.mjs re-run, generated output committed
  • prettier 3 with the repo's root .prettierrc.json over every changed file (matching the prettier-int-coding-agents task in scripts/hooks/lint.sh) — clean. Three files under this package (src/core/retain-cursor.ts, src/dsh.ts, src/install-ui.ts) are flagged by that task on main as well; they are untouched here and left alone.
  • src/docs-freshness.test.ts — 12 tests pass

Six new tests cover per_source end to end at the wire level (the global-plus-source expansion, git vs chat separation, the untagged fallback, the two-source-tag seed, order independence, and a volatile provenance tag never becoming a scope), plus one covering config acceptance.

I could not run ./scripts/hooks/lint.sh in full — it starts with uv sync for the whole monorepo and my checkout is sparse — so the Python and control-plane tasks are unverified, though nothing here touches them.

On a repo worked by a coding agent, commit diffs and session transcripts
make different kinds of claim. A diff records what the code does; a
transcript records what someone intended, argued for, or discarded. Under
the `shared` default both consolidate into one undifferentiated belief
set, so an idea floated in chat and never implemented is indistinguishable
from a belief derived from the commits, and "what does the codebase
actually do" cannot be answered from commit-derived knowledge alone.

This cannot be fixed by configuration. The server treats an explicit
scope list as unconditional: `_resolve_obs_tags_list` and
`_resolve_write_scopes` in the consolidator both return the parsed list
verbatim, without filtering it against the memory's own tags. A configured
`[[], ["source:git"], ["source:chat"]]` therefore writes EVERY document
into all three scopes, and the `source:git` scope fills with observations
built from chat transcripts. Only a per-document decision separates them.

`per_source` is resolved client-side, per document, in the new exported
`resolveRetainScopes`, and never reaches the server. It expands to the
global scope plus one per distinct `source:` tag the document carries,
sorted — a document with two source tags (the commit-message seed keeps
`source:git` alongside `source:git-log` so the cold-repo check still sees
it) gets a scope for each, which needs no arbitrary tie-break and does not
depend on the order the caller assembled its tags in.

Reading only `source:` is what keeps this safe. `per_tag` splits on the
right axis but also on every other one: it would reinstate the per-agent
`harness:` fork that vectorize-io#3564 and vectorize-io#3575 removed, and any volatile tag such as
a session id from `retainTags` would become its own scope — the
fragmentation bug itself.

The empty scope is always emitted first and unchanged, so the merged view
matches `shared` exactly and the untagged observations that knowledge
pages read (`tags_match: "all"`, per vectorize-io#3664) are unaffected.

The cost is honest: one extra consolidation pass per document. That is the
price of the axis, and it is why this is opt-in —
`DEFAULT_OBSERVATION_SCOPES` remains `shared` and no existing value
changes meaning.

Closes vectorize-io#3871

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdYchB9yKUWw8oa16RWQu9
@strix-security

strix-security Bot commented Aug 29, 2026

Copy link
Copy Markdown

Strix Security Review

Warning

This pull request has 2 commits after the last Strix review (a9a729f). Strix has not reviewed these changes.
Automatic review on push is off for this repository. To review the latest changes, tag @strix-security in a comment, or turn on re-review on push.

No security issues found.

Updated for a9a729f.


Reviewed by Strix
Re-run review · Configure security review settings

@koriyoshi2041 koriyoshi2041 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.

The per-document resolution keeps the existing shared scope while separating only the controlled source axis, and it avoids sending the plugin-only mode to the server. I also checked the multi-source and no-source cases at a9a729f: 85 focused tests pass, along with typecheck and the package build.

Running per_source on real repositories showed two of the five source tags are
provenance labels rather than axes, and each produced a scope worth less than it
cost.

source:git-log is a bookkeeping alias. git.ts tags the commit-message seed with
it AND source:git, so emitting a scope for each gave two near-identical belief
sets — 307 observations against 302 on one repo — and doubled the consolidation
for that pair. It is the same claim as source:git: what the commits say. Mapped
onto it rather than dropped, because that seed is where a cold repo's entire
commit history arrives.

source:survey-baseline marks the "researching…" status document, whose retain
strategy is meant to extract nothing. It still yielded a scope holding exactly
one observation — a belief set that exists only to be noise. Excluded.

Emitting a scope per DISTINCT source tag, sorted, is still right: taking the
first made the result depend on the order git.ts assembled its tags, which a
test pins. It is the vocabulary that carries two non-semantic entries, not the
rule.
This reverts 0786de8. The evidence behind it was an artefact.

The claim was that source:git and source:git-log produced near-identical belief
sets (307 observations against 302). They did — but only because at the time of
measurement the bank held exactly ONE git document, the commit-message seed,
which carries both tags. The per-commit diff backfill under gitIngest: "full"
had not run yet, so one document was feeding both scopes.

Once it does, the two diverge and are genuinely different questions:
source:git-log is fed only by the seed — what the commit MESSAGES say — while
source:git also collects every per-commit diff. Intent against implementation.

The reasoning was wrong at a deeper level too. Overlapping content across scopes
is not duplication to be engineered away: consolidation already distills and
deduplicates WITHIN each scope, and a fact that legitimately answers two
questions belonging to both is the design working. Excluding tags to avoid
overlap misreads what a scope is for.

source:survey-baseline goes back for the same reason. Its scope holding a single
observation suggests the marker document's retain strategy is extracting facts
it is meant to suppress — which is worth fixing where it happens, not papering
over in the scope resolver.
@shauneccles

Copy link
Copy Markdown
Author

Sorry for the noisy last two commits - I missed the root cause of another issue (#3874) and got a bit confused. It's been a big day 😅

@koriyoshi2041 koriyoshi2041 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.

The two follow-up commits leave the implementation unchanged and make the multi-tag intent clearer: the git-log seed and per-commit diffs answer different questions, so retaining both controlled source scopes is correct. Re-reviewed at 1a98249.

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.

coding-agents: under shared, commit knowledge and conversation knowledge consolidate into one belief set with no way to ask them apart

2 participants