Skip to content
This repository was archived by the owner on Aug 10, 2026. It is now read-only.

feat(agent): a #agent/thread carries capability directly — register its wants as name-keyed pending grants - #183

Merged
unforced merged 1 commit into
mainfrom
ag-thread-capability
Jul 1, 2026
Merged

feat(agent): a #agent/thread carries capability directly — register its wants as name-keyed pending grants#183
unforced merged 1 commit into
mainfrom
ag-thread-capability

Conversation

@unforced

@unforced unforced commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What

Restore the capability parity the retired #agent/definition had: a #agent/thread now registers its own wants: as pending grants keyed by the agent name, so a thread carries capability directly.

The threads-only model (#174#178) made a thread be the agent, but parseThreadSpec left a thread's wants: informational only (agent-defs.ts "parsed for parity, not registered"; "threads register no grants — only roles do"). That was a regression: the retired #agent/definition did register its own wants keyed by the agent name. Only #agent/role (path-keyed) registered.

Where the registration hooks in

AgentDefRegistry.instantiateThread — after a successful parse + setupAndRegister, it now:

  1. resolveStatusWithGrants({ name, wants, declaredConnections: [] }) — registers each want as a pending grant keyed by parsed.name (= metadata.agent) + derives status (enabled iff all approved, else pending), exactly like the def load path.
  2. sets the live entry with the real status / pending / connections (was hardcoded enabled / []).
  3. reconcileLiveKeys(threadAgent) — the agent-grants: grant rows orphaned when an #agent/definition note is deleted #96 grant-GC, reconciling to the thread's currently-declared wants.

The injection side was already correct: resolveInjectedGrantsUnion(grants, [spec.name, ...roleKeys]) unions over spec.name, so an approved thread grant injects at spawn. This is a registration-only change. resolveStatusWithGrants / reconcileLiveKeys / resolveDefStatus generalize from ParsedAgentDef to a small GrantBearingSpec so a def and a thread share one path.

Safety (mirrors the def discipline exactly)

  • Pending, not auto-granted — the operator still approves each grant. Approval is the security boundary; this is not self-escalation. Roles remain the curated/shared layer, coexisting.
  • Reconcile-GC only from a confident live set — register + reconcile run after a clean parse + setup, and only when the thread exclusively owns the name (the dedup gate returns false for a def-owned/other-source name before any grant traffic). A malformed wants: (parseThreadSpec throws AgentDefParseError) or a setup failure returns before register/reconcile, so a transient error can never present a stale/empty set that nukes approved grants.
  • Name partition, not role partition — keyed by the agent name; a role's PATH-keyed grants (reconcileRole) are untouched. Injection dedups thread-own vs role wants with spec.name first (existing behavior, verified not re-implemented).
  • Disabled threads register nothing (existing gate, unchanged).

Not covered (documented follow-up)

A proper removed-thread grant GC on note deletion needs a thread seen-set + confident removed-thread diff (mirroring pruneRemovedDefs — the poll is the real deletion path since the vault has no deleted trigger). That is a separate, larger change; a deleted thread's approved grants persist until then — a cleanliness gap, never an escalation (grants are always operator-approved). Documented on reloadThread.

Tests

New agent-defs.test.ts block + one grants.test.ts injection test:

  • thread wants: register keyed by the agent name; status pending; connections surfaced with hub grant ids
  • status → enabled once approved
  • wants-free thread stays enabled with no register PUT (byte-identical to before)
  • removing a want → reconcile-GC prunes to the reduced set (keyed by name)
  • safety: malformed wants: → skipped, never register/reconcile (approved grants not nuked)
  • a def-deduped thread registers nothing (def owns the name partition)
  • thread name-partition + role path-partition coexist (neither clobbers)
  • a thread's approved name-keyed want injects via resolveInjectedGrantsUnion([name])

Gates

  • bun test ./src/1450 pass, 0 fail (the daemon gate; the web/ui/src/ browser tests that fail under a loose src/ glob are pre-existing + DOM-dependent, run via test:spa)
  • bun run typecheck — clean
  • bun run lint — exit 0 (2 pre-existing noExplicitAny warnings in src/transports/vault.test.ts, not touched here)

Version: 0.2.4-rc.10.2.4-rc.2. rc tag pushed on merge (not here).

🤖 Generated with Claude Code

https://claude.ai/code/session_01T6HAzWsgiJy4ndsWSCWY5S

…ts wants as name-keyed pending grants

The threads-only model (#174#178) retired #agent/definition; a thread IS
the agent. But parseThreadSpec parsed a thread's `wants:` as INFORMATIONAL
only — never registered as grants — a regression from the def model, whose
retired #agent/definition DID register its own wants keyed by the agent name.

Restore the parity: on instantiateThread, register the thread's parsed
`wants:` as PENDING grants keyed by the AGENT NAME (`metadata.agent` =
spec.name) + derive status from the hub, mirroring the def path's
resolveStatusWithGrants + reconcile-GC (reconcileGrants(name, wants)). The
injection side already unions over spec.name (resolveInjectedGrantsUnion), so
an approved thread grant injects at spawn — this is a REGISTRATION-only change.

Safety (mirrors the def discipline exactly):
- Grants register as PENDING — the operator still approves (approval is the
  security boundary; not self-escalation).
- Register + reconcile only run from a CONFIDENT live set: after a clean parse
  + setup, and only when the thread EXCLUSIVELY owns the name (the dedup gate
  returns false otherwise — a deduped/def-owned name never reconciles). A
  malformed `wants:` / setup failure returns before any grant traffic, so a
  transient error can never present a stale/empty set that nukes approved grants.
- Keyed by the NAME, its own partition — roles (#agent/role, PATH-keyed via
  reconcileRole) are the separate composable/curated layer, untouched.

resolveStatusWithGrants / reconcileLiveKeys / resolveDefStatus generalize from
ParsedAgentDef to a GrantBearingSpec ({ name, wants, declaredConnections }) so
both a def and a thread flow through one code path.

Not covered (follow-up): a proper removed-thread grant GC on note DELETION
needs a thread seen-set + confident removed-thread diff (mirroring
pruneRemovedDefs); a deleted thread's approved grants persist until then (a
cleanliness gap, never an escalation). Documented on reloadThread.

Tests: thread wants register keyed by name (status pending → enabled on
approval); wants-free thread stays enabled with no register PUT; removing a
want prunes via reconcile; malformed wants → skipped, NEVER register/reconcile
(the safety case); a def-deduped thread registers nothing; thread name-partition
+ role path-partition coexist; a thread's approved name-keyed want injects via
resolveInjectedGrantsUnion([name]). rc.1 → rc.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6HAzWsgiJy4ndsWSCWY5S
@unforced
unforced merged commit 868a343 into main Jul 1, 2026
2 checks passed
@unforced
unforced deleted the ag-thread-capability branch July 1, 2026 21:46
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant