Skip to content

fix(registry-sync): apply agent.profile_updated events to the local agent index - #7093

Open
sujanchalla0510 wants to merge 2 commits into
adcontextprotocol:mainfrom
sujanchalla0510:fix/registry-sync-agent-profile-updated
Open

fix(registry-sync): apply agent.profile_updated events to the local agent index#7093
sujanchalla0510 wants to merge 2 commits into
adcontextprotocol:mainfrom
sujanchalla0510:fix/registry-sync-agent-profile-updated

Conversation

@sujanchalla0510

@sujanchalla0510 sujanchalla0510 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Problem

RegistrySync (server/src/registry-sync/index.ts) is the reference in-memory
client that bootstraps from the registry API and then polls the change feed
to "stay current" (its own docstring). RegistrySync.applyEvent() switches
on event_type and handles agent.discovered / agent.removed, but has no
case for agent.profile_updated — one of the event_type enum values the
registry-event.json schema has declared since PR #5054 (merged 2026-05-26)
specifically for "an agent inventory profile changed" (e.g. an agent adds a
market, channel, or format_kind after it was first discovered).

Because the switch has no default branch and the poller advances its
cursor regardless of whether an event was handled, agent.profile_updated
events are silently dropped: the feed cursor moves past them, but the local
AgentIndex entry for that agent is never touched. A consumer of
RegistrySync (this repo's own registry-sync-collection-index.test.ts and
server/tests/unit/registry-sync/ treat it as a reusable module, and its
class docstring documents registry.agents.search({ channels: ['ctv'], markets: ['US'] }) as the intended usage) will keep matching stale
channels/markets/format_kinds/etc. for any already-known agent
indefinitely, until the next full bootstrap (a cursor_expired recovery or
process restart).

Confirmed on current upstream/main via git show HEAD before editing.
git log -S "agent.profile_updated" -- server/src/registry-sync/index.ts static/schemas/source/core/registry-event.json shows the schema gained this
event type in #5054 (2026-05-26), which never touched
server/src/registry-sync/index.ts; RegistrySync itself predates that PR
(added in #1807) and was never updated to add the new case. No existing
open or closed issue covers this (searched RegistrySync,
agent.profile_updated, registry-sync applyEvent, agent profile stale).

Fix

Added an agent.profile_updated case to the switch in applyEvent().
Deliberately not a copy of agent.discovered's full-snapshot upsert:
per the schema's changed_fields field description, an agent.profile_updated
payload may legitimately carry only the fields that changed. Reusing
agent.discovered's pattern ((payload.channels as string[]) ?? [], etc.)
would silently zero out every field the producer omitted on a partial
update — a subtler regression than doing nothing. Instead the new case
merges the payload onto the existing AgentIndex entry, mirroring the exact
pattern the file already uses for property.updated immediately below it,
and — matching that same sibling case — is a no-op when the agent isn't
already in the local index (nothing to merge onto).

Test

Added two tests to server/tests/unit/registry-sync/registry-sync.test.ts,
following the file's existing (sync as any).applyEvents([event]) convention:

  1. handles agent.profile_updated event by merging onto the existing profile
    — seeds a full agent profile, applies a partial agent.profile_updated
    event that only sends markets, and asserts the changed field updates
    while untouched fields (channels, property_count, has_tmp) survive.
  2. ignores agent.profile_updated for an agent not already in the index
    — matches the no-op convention already used for property.updated.

Proof the test is real: reverted the source change (kept the tests) and
re-ran npx vitest run --config server/vitest.config.ts server/tests/unit/registry-sync/registry-sync.test.ts — 20 passed / 1
failed, with the merge test failing (expected [ 'US' ] to deeply equal [ 'US', 'CA' ], i.e. the partial update was silently dropped). Restored the
fix and re-ran the same command — 21/21 passed. Ran npm run typecheck
(tsc --project server/tsconfig.json --noEmit) — clean. Ran
node scripts/check-changeset-protocol-scope.cjs upstream/main — this is a
server-code-only change, not protocol-surface-scoped, so no changeset is
required per .agents/playbook.md. Final diff is two files, +64 lines (17
in index.ts, 47 in the test file), no lines removed.

🤖 Generated with a Claude Code session as part of an ongoing series of
narrowly-scoped, independently-verified fixes to this repository.


Update: addressing @bokelley's review

Both requested changes are addressed, with the second one intentionally
scoped down rather than forced in — see below.

1. Whitelist the payload before merge (fixed)

agentProfilePayload in registry-event.json has additionalProperties: true, and the agent.profile_updated branch specifically layers an
advisory changed_fields array on top. The original fix's
{ ...existing, ...(payload as Partial<AgentProfile>) } spread let both of
those — and any other non-AgentProfile key a future producer attaches —
leak straight into the stored profile and back out through
agents.get()/list()/search().

Replaced the spread with an explicit agentProfilePatchFromPayload()
helper that whitelists only real AgentProfile fields (name, channels,
property_types, markets, categories, tags, delivery_types,
format_kinds, property_count, publisher_count, has_tmp,
category_taxonomy) before merging — mirroring collectionFromPayload's
existing explicit-construction pattern in the same file (the sibling that
already does this safely), rather than property.updated's spread
immediately below (which has the identical latent leak but is out of scope
for this PR).

Strengthened the existing merge test to assert changed_fields doesn't
leak into the stored profile, and added a new test
(does not leak advisory or unrecognized payload fields into the stored profile) that sends changed_fields plus an unrelated advisory field and
an unrecognized type key (the schema's field name for this concept —
distinct from AgentProfile's agent_type, so it's correctly excluded
either way) and asserts the stored profile's key set is exactly the
AgentProfile fields, nothing else.

Verified: reverted the whitelist change (kept the tests) — both new
assertions fail against the old spread (changed_fields present on the
stored object). Restored the fix — 22/22 pass. npm run typecheck clean.

2. Producer coverage (scoped out, follow-up filed)

I looked at wiring produceEventsFromDiff (server/src/crawler.ts) up to
actually emit agent.profile_updated, and it's not a narrow addition like
point 1 was — genuinely new infrastructure is needed, not a copy of the
agent.discovered/agent.removed emission pattern:

  • The pre-crawl snapshot it diffs against
    (CrawlerService.snapshotAgentState()) only captures domain
    associations
    (Map<string, { domains: Set<string> }>), built
    specifically for the authorization-granted/revoked diff — it carries no
    profile field values to diff against.
  • AgentInventoryProfilesDatabase has no bulk "read the previous profile
    for these agent URLs" method today (getProfile() is single-agent;
    search() is paginated/scored) — one would need to be added, in the same
    single-round-trip spirit as federatedIndex.getAllAgentDomainPairs()
    (used in snapshotAgentState() specifically to avoid O(N) per-agent
    queries).
  • The actual field-level diff needs to be order-independent (several
    AgentProfile fields are arrays built fresh from Sets each crawl in
    buildInventoryProfiles(), e.g. channels: [...channels]), so naive
    !==/JSON.stringify comparison would false-positive on reordering.
    There's already a directly analogous helper to model this on —
    adagentsChangedFields() in server/src/db/publisher-db.ts (~line 101),
    which normalizes both sides through a sorted-key stableManifestString
    before comparing, feeding publisher.adagents_changed's own
    changed_fields — but no equivalent exists yet for agent profiles.

That's a new DB read path + a new diff helper + restructuring the
buildInventoryProfilesproduceEventsFromDiff handoff in the crawl
loop, each of which deserves its own review rather than being folded into
this consumer-side fix. Per your suggested alternative, I'm narrowing this
PR's scope explicitly: this PR fixes the consumer-side dead code in
RegistrySync.applyEvent(); the producer does not yet emit
agent.profile_updated, so the tests here exercise applyEvents()
directly rather than a real end-to-end crawl-to-consumer path.

Filed #7095 to track completing the production side,
with the specific suggested scope (bulk previous-profile read, an
agentProfileChangedFields helper mirroring adagentsChangedFields, the
produceEventsFromDiff wiring, and producer-level test coverage in
server/tests/unit/crawler-format-kind-events.test.ts or a sibling file).

…gent index

RegistrySync.applyEvent() handled agent.discovered and agent.removed but
silently no-op'd agent.profile_updated, one of the event_type values the
registry-event.json schema has declared since PR adcontextprotocol#5054 (2026-05-26) for
"an agent inventory profile changed". The feed poller advances its cursor
past these events regardless, so a locally-synced agent's channels,
markets, format_kinds, etc. never converge with the registry after the
initial bootstrap — a permanent staleness gap the class's own docstring
("polls the change feed to stay current") explicitly promises against.

Add a case that merges the event payload onto the existing AgentIndex
entry, matching the merge pattern already used for property.updated
immediately below it. A plain copy of agent.discovered's full-snapshot
upsert would be wrong here: per the schema's changed_fields description,
agent.profile_updated payloads may carry only the fields that changed,
so defaulting absent fields to [] / 0 / false (as agent.discovered does
for a brand-new entity) would silently wipe out unrelated, unchanged
profile data on every partial update.

Adds two unit tests to registry-sync.test.ts: one confirming a partial
update merges onto the existing profile without clobbering untouched
fields, one confirming the event is a no-op for an agent not already in
the index (matching property.updated's existing convention).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFth3sHBrJbHuFeA8Cegdc

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

Two changes are needed before merge:

  1. Do not spread the full event payload into AgentProfile. The payload includes advisory changed_fields and permits additional properties, so get/list/search can return runtime fields that are not part of AgentProfile. Destructure the metadata or explicitly construct a whitelisted profile patch before upsert.

  2. The current crawler produceEventsFromDiff emits agent.discovered, agent.removed, and authorization events, but never agent.profile_updated. The new unit tests call applyEvents directly, so the stated end-to-end stale-index path remains untested and, with this server, unproduced. Please either add the producer/diff coverage here or narrow this PR explicitly and link a concrete follow-up that completes the production path.

@bokelley bokelley added the ladon/force-review Force Ladon to review the next eligible PR event label Aug 30, 2026
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Aug 30, 2026

@aao-secretariat aao-secretariat 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.

Ladon verdict: Approve

Approve — clean, well-tested server-only fix.

This PR adds the missing agent.profile_updated case to RegistrySync.applyEvent(), closing a silent event-drop bug where the poller advanced its cursor past these events while leaving stale profile fields in the local AgentIndex. It correctly uses a partial-merge (mirroring property.updated) rather than agent.discovered's full-snapshot upsert, avoiding field zeroing. Server-only change with accompanying unit tests; no protocol surface touched, so no changeset required.

Checked against the decision table:

  • No critical/high/medium findings (row 1, 8 do not fire).
  • gated_paths: false — row 2 does not apply despite review_decision: CHANGES_REQUESTED.
  • high_risk: false, no deletions/modifications to sensitive paths (rows 3–5 do not fire).
  • No prior decision, no no-auto-approve team match (rows 6–7 do not fire).
  • No protocol/schema/docs/changeset surface touched — none of the repo-specific spec gates apply.

Falls through to row 9 → approve.

@bokelley bokelley removed the ladon/force-review Force Ladon to review the next eligible PR event label Aug 30, 2026
@bokelley

Copy link
Copy Markdown
Contributor

Issue #7095 proposes the producer-side counterpart to this PR — wiring produceEventsFromDiff in crawler.ts to emit agent.profile_updated when an already-known agent's profile changes between crawls (new getProfilesByUrls bulk read on AgentInventoryProfilesDatabase, an agentProfileChangedFields diff helper, and producer-side tests extending crawler-format-kind-events.test.ts). Same surface as this PR; consider folding before merge or confirm as a tracked follow-up.


Generated by Claude Code

Addresses review feedback on adcontextprotocol#7093 point 1: applyEvent()'s
agent.profile_updated case spread the raw event payload onto the
existing AgentProfile. Per registry-event.json's agentProfilePayload
def, additionalProperties is true and this event type additionally
carries an advisory changed_fields array — neither is part of
AgentProfile, so the bare spread let both leak into the stored
profile and back out through agents.get()/list()/search().

Add agentProfilePatchFromPayload(), which explicitly whitelists only
real AgentProfile fields (name, channels, property_types, markets,
categories, tags, delivery_types, format_kinds, property_count,
publisher_count, has_tmp, category_taxonomy) from the payload before
merging, mirroring collectionFromPayload's existing explicit-
construction pattern in the same file rather than property.updated's
spread (which has the same latent issue but is out of scope here).

Strengthened the existing merge test to assert changed_fields does
not leak into the stored profile, and added a dedicated test that
sends changed_fields plus an unrelated advisory field and an
unrecognized "type" key (the schema's field name for this concept,
distinct from AgentProfile's agent_type) and asserts the stored
profile's key set is exactly the AgentProfile fields — nothing else.

Verified by reverting the source change (keeping the tests): both new
assertions fail against the old spread ('changed_fields' present on
the stored profile). Restored the fix — 22/22 pass. npm run typecheck
clean.

Point 2 of the review (producer coverage for agent.profile_updated)
is addressed separately: see PR description update and
adcontextprotocol#7095 for the follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFth3sHBrJbHuFeA8Cegdc
@sujanchalla0510

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — both points are addressed.

1. Whitelist the payload before merge. You're right that the spread let changed_fields (and anything else additionalProperties: true allows on agentProfilePayload) leak into the stored AgentProfile. Replaced it with an explicit agentProfilePatchFromPayload() helper that whitelists only real AgentProfile fields before merging — following collectionFromPayload's existing explicit-construction pattern in the same file, rather than property.updated's spread (which has the same latent issue but is out of scope here). Added a test that sends changed_fields plus an unrelated advisory field and an unrecognized type key and asserts the stored profile's key set is exactly the AgentProfile fields. Reverted the whitelist (keeping the tests) to confirm both new assertions fail against the old spread, then restored it — 22/22 pass.

2. Producer coverage. I looked into wiring produceEventsFromDiff up to actually emit agent.profile_updated, and it's a genuinely larger change than this PR, not a copy of the agent.discovered/agent.removed pattern: the pre-crawl snapshot it diffs against (snapshotAgentState()) only tracks domain associations, not profile field values; there's no bulk "read the previous profile for these agents" method on AgentInventoryProfilesDatabase yet; and a correct field-level diff needs to be order-independent since several profile fields are arrays freshly built from Sets each crawl (there's a directly analogous helper to model it on — adagentsChangedFields() in publisher-db.ts — but nothing equivalent exists for agent profiles yet). That's a new DB read path plus a new diff helper plus restructuring the crawl loop's diff handoff — each deserving its own review.

Taking your suggested alternative: narrowed the PR description to state plainly that this PR fixes the consumer-side dead code and the producer doesn't yet emit this event type, and filed #7095 with the specific scope needed to complete the production path (bulk previous-profile read, an agentProfileChangedFields helper, the produceEventsFromDiff wiring, and producer-level test coverage).

Pushed as a new commit on the same branch; PR description updated with the full detail on both points.

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.

2 participants