From 39740e75da38ee38749e9eef3747345aead29ee2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 15:34:31 -0400 Subject: [PATCH] docs(v2): design refreshable context sources --- specs/v2/refreshable-context-sources.md | 401 ++++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 specs/v2/refreshable-context-sources.md diff --git a/specs/v2/refreshable-context-sources.md b/specs/v2/refreshable-context-sources.md new file mode 100644 index 00000000000..fd664996651 --- /dev/null +++ b/specs/v2/refreshable-context-sources.md @@ -0,0 +1,401 @@ +# Refreshable Context Sources + +## Status + +Reviewed proposal for ambient `AGENTS.md`, configured instruction paths or URLs, and later local skill-source invalidation. + +## Decision Summary + +Context-source observation remains pull-based and lazy at a safe provider-turn boundary. + +```text +source signal +-> mark an optional observation cache stale +-> next naturally scheduled safe provider-turn boundary observes current state +-> Context Epoch compares and admits exact changed bytes durably +``` + +The first ambient `AGENTS.md` slice will not depend on filesystem watching. It will directly observe local instruction state whenever `SessionSystemContext.load()` naturally runs before a provider turn. + +Watcher-backed caches are a later efficiency optimization for roots with proven subscription coverage. URLs remain separate observations with an independently chosen refresh policy. + +## Existing Pieces + +| Existing piece | Responsibility | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `Watcher.locationLayer` | Publish advisory `file.watcher.updated` events for local filesystem changes. | +| `EventV2.subscribe(...)` | Expose advisory events as scoped Effect streams. | +| `State.create(...)` | Rebuild replayable plugin and config contribution state from scoped transforms. | +| `SynchronizedRef.modifyEffect(...)` | Serialize effectful state refresh and store the next value only after success. | +| `SystemContext` | Convert coherent source samples into immutable baseline parts, chronological updates, unavailable state, and removal tombstones. | +| `LocationServiceMap` | Own and clean up Location-scoped services, watcher subscriptions, and observation caches together. | + +The missing reusable piece is deliberately small: retain the last successful value, mark it stale, and serialize refresh attempts. + +## Primitive: `Refreshable` + +Place the optional coordination helper at: + +```text +packages/core/src/effect/refreshable.ts +``` + +`Refreshable` does not know about files, URLs, timers, watchers, Sessions, Context Epochs, or stale fallbacks. Domain services decide when to use it and how to recover expected observation failures. + +```ts +export interface Refreshable { + readonly get: Effect.Effect + readonly invalidate: Effect.Effect +} + +export const make = ( + load: Effect.Effect, +): Effect.Effect> +``` + +Internal state: + +```ts +type State = + | { readonly _tag: "Empty" } + | { readonly _tag: "Fresh"; readonly value: A } + | { readonly _tag: "Stale"; readonly value: A } +``` + +Implementation substrate: + +```text +SynchronizedRef.modifyEffect(...) +``` + +Semantics: + +| Operation | Behavior | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `get` on `Empty` | Run `load`, store `Fresh(value)` only after success, and return it. | +| `get` on `Fresh(value)` | Return the cached value without I/O. | +| `get` on `Stale(previous)` | Run `load`, store `Fresh(value)` only after success, and return it. | +| `invalidate` on `Fresh(value)` | Store `Stale(value)`. | +| `invalidate` on `Empty` or `Stale` | No-op. Repeated invalidations coalesce. | +| failed `load` | Preserve the prior `Empty` or `Stale(previous)` state, propagate the failure, and retry on the next `get`. | +| invalidation during `load` | Serialize after the reload and leave the refreshed cache stale for the next `get`. | + +```mermaid +stateDiagram-v2 + [*] --> Empty + Empty --> Fresh: get / load succeeds + Empty --> Empty: get / load fails + Fresh --> Fresh: get / return cached + Fresh --> Stale: invalidate + Stale --> Fresh: get / reload succeeds + Stale --> Stale: invalidate or reload fails +``` + +### Why Custom Instead Of Effect `Cache` + +Effect `Cache`, `ScopedCache`, and `Effect.cachedInvalidateWithTTL(...)` cache failed exits. Context-source observation requires a narrower rule: + +```text +failed refresh +-> retain prior successful value internally +-> remain stale +-> retry at the next natural request +``` + +`SynchronizedRef.modifyEffect(...)` commits the next state only after the refresh effect succeeds. + +### Why No `peek` + +`Refreshable` should not expose stale fallback reads. A domain service that needs a previous discovery graph during failed rescans should own that graph explicitly in its observation model. + +### Why No Refresh Modes Or TTL + +An always-refreshed source does not need a cache: load it directly. TTL expiry, watcher events, and explicit source changes are external invalidation policies that may call `invalidate` later. + +```mermaid +sequenceDiagram + participant Signal as Optional Invalidation Source + participant Cache as Refreshable + participant Consumer + participant Loader + + Signal->>Cache: invalidate + Consumer->>Cache: get + Cache->>Loader: reload only when stale + Loader-->>Cache: successful coherent value + Cache-->>Consumer: refreshed value +``` + +## Observation Units + +Compose refreshables around coherent observations that share one invalidation policy. Do not create one uniformly per rendered Context Component or one aggregate cache for unrelated source kinds. + +```text +local built-in discovery +-> one coherent observation while it shares one refresh policy + +configured local glob +-> separate observation when its scan root or coverage differs + +configured URL +-> independent observation + +local skill directory +-> naturally one observation per registered source + +embedded skill +-> direct value, no refreshable +``` + +## Ambient Instruction Service + +Add a Location-scoped service: + +```ts +export interface InstructionContext.Interface { + readonly loadAmbient: () => Effect.Effect> +} +``` + +`InstructionContext` owns instruction discovery, stable source identity, deterministic ordering, and source loading. `SystemContext` remains unaware of files and URLs. + +Each effective source becomes one independently keyed component: + +```text +core/instructions/file/ +core/instructions/url/ +``` + +Rendered text retains the human-readable source identity: + +```text +Instructions from: /repo/packages/core/AGENTS.md + +``` + +or: + +```text +Instructions from: https://example.com/shared-agents.md + +``` + +The first implementation directly observes global and upward project `AGENTS.md` files on every safe provider-turn boundary. It does not use `Refreshable` yet unless a coherent source observation needs stale-on-failure retention. + +```mermaid +sequenceDiagram + participant Runner as Safe Provider Boundary + participant Instructions as Instruction Context + participant Files + participant Epoch as Context Epoch + + Runner->>Instructions: loadAmbient + Instructions->>Files: discover and read AGENTS.md files + Files-->>Instructions: coherent current observation + Instructions-->>Runner: independently keyed components + Runner->>Epoch: compare and durably admit changes +``` + +## Source Outcomes + +Discovery state and per-source byte observation are separate concerns. + +```text +discovery +-> which stable source identities currently exist? + +observation +-> what bytes or temporary failure does each identity currently produce? +``` + +| Observation | Component outcome | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Local scan succeeds and discovers readable file | Available component with exact contents. | +| Local scan succeeds and a previously discovered file is absent | Remove component so `SystemContext` emits a tombstone. | +| Local scan fails transiently | Preserve the domain-owned prior source graph as unavailable or fail the current turn; never emit mass removals. | +| Known local file read fails transiently | Preserve the component as `SystemContext.unavailable`. | +| Known local file read reports not-found after discovery | Invalidate discovery and report unavailable until a coherent rescan confirms removal. | +| Empty local file | Available exact content, not absence. | +| URL returns `2xx` body | Available component with exact contents. | +| URL times out or returns transient failure | `SystemContext.unavailable`. | +| URL returns `404` or `410` | Decide the explicit removal contract before URL implementation. | + +Instruction removal text must be model-meaningful. If instruction component keys hash source identities, add source-specific removal rendering before unlink support is considered complete: + +```text +Instructions removed: /repo/packages/core/AGENTS.md +Do not continue applying instructions previously loaded from this source. +``` + +## First Ambient Slice + +Implement only: + +```text +global config AGENTS.md ++ upward project AGENTS.md ancestors ++ one keyed component per file ++ direct safe-turn observation +``` + +Preserve V1 ancestor stacking for `AGENTS.md`: nearest ancestor first, then outward through the project boundary. + +Do not include yet: + +```text +CLAUDE.md compatibility fallback +deprecated CONTEXT.md fallback +configured local paths or globs +configured URLs +watcher-backed caching +skills migration +nested read-triggered discovery +``` + +Tests: + +```text +initial baseline +edit +newly added ancestor AGENTS.md +confirmed unlink with meaningful removal text +empty file +transient scan failure +transient file-read failure +deterministic ordering +restart with durable checkpoint +``` + +## Future Watcher Optimization + +Watchers remain advisory optimizations. They never wake idle Sessions and never publish durable Session context events. + +The current watcher subscribes only to `location.directory`, has ignore rules, starts asynchronously, and swallows subscription failures. Effective source roots may live elsewhere. Do not expose one broad `Watcher.Service.local: "watching" | "poll"` flag. + +Add root-specific watching only when needed: + +```ts +export interface Watcher.Interface { + readonly watch: (root: AbsolutePath) => Effect.Effect<"watching" | "poll"> +} +``` + +The exact watcher API remains a follow-up design. A truthful contract must account for: + +- successful subscription startup; +- subscription failure and callback error; +- source roots above or outside `location.directory`; +- ignore and protected-path coverage; +- scope cleanup; +- own-process mutations that should synchronously invalidate caches before the next continuation turn. + +When root coverage is proven: + +```mermaid +sequenceDiagram + participant Watcher + participant Cache as Refreshable + participant Runner + participant Epoch as Context Epoch + + Watcher->>Cache: relevant add / change / unlink + Cache->>Cache: invalidate only + Runner->>Cache: get at next safe boundary + Cache-->>Runner: refreshed coherent observation + Runner->>Epoch: durably admit exact changes +``` + +If coverage is not proven, bypass the cache and observe directly whenever the safe boundary naturally requests current state. This is safe-turn refresh, not a background polling loop. + +## URL Sources + +URLs never share an observation cache with local discovery. + +Start with direct safe-turn loading: + +```text +safe provider-turn boundary +-> fetch URL +-> emit available or unavailable component +``` + +If measurements show excessive requests, add a URL-specific invalidation policy later: + +```text +TTL expires +-> invalidate URL Refreshable +-> next safe provider-turn boundary reloads URL +``` + +A TTL timer must not wake idle Sessions or publish durable Session events. + +## Skills Reuse + +`SkillV2` currently stores replayable source registrations through `State.create(...)` and materialized source results in a raw permanent `Map`. + +Use `Refreshable` later for local directory sources only after skill observation distinguishes confirmed absence from transient failure: + +```text +State.create +-> current replayable Source registrations + +private Map +-> reconcile against active non-embedded source keys +-> create refreshable for added source +-> drop refreshable for removed source +``` + +Do not add a generic `State -> cache invalidation` bridge. The skill service owns both current registrations and its observation-cache lifecycle. + +Remote skill refresh remains a separate design slice because the current puller skips files that already exist and therefore does not define overwrite or removal semantics. + +## Nested Instruction Discovery + +Nested instructions discovered after successful read-tool activity remain a Session-scoped follow-up. + +- The Location-scoped instruction service may resolve and reuse source observations. +- The set of nested source identities active for one Session must be durable and Session-scoped. +- Successful local file reads record newly observed nested source identities through synchronized Session events. +- The next safe provider boundary loads and admits them through Context Epoch history. +- Do not inject V1-style reminder text directly into read-tool output. +- Do not activate nested discovery for directory reads, managed tool-output resources, or external references until those semantics are explicitly designed. + +## Lifecycle + +- Location scope owns instruction services, optional watcher-consumer fibers, and refreshable state. +- `Effect.forkScoped(...)` interrupts watcher-consumer fibers when the cached Location runtime is disposed. +- Stream finalization unsubscribes `EventV2` PubSub subscriptions. +- `Watcher.locationLayer` separately finalizes native Parcel watcher subscriptions. +- Repeated invalidations coalesce into one `Stale` state. +- Idle Sessions are not woken by local edits, URL timers, or plugin changes. +- Context Epoch admission remains serialized by the Session event transaction at the next naturally scheduled provider turn. + +## Proposed Implementation Order + +1. Add and unit-test `Refreshable.make(load)` with `get` and `invalidate`. +2. Add model-meaningful instruction removal rendering support before unlink lands. +3. Add Location-scoped `InstructionContext` for global and upward project `AGENTS.md` only. +4. Compose instruction components into `SessionSystemContext.load()`. +5. Observe local instructions directly at each safe provider boundary. +6. Test add, edit, unlink, empty file, transient scan failure, transient read failure, restart, and deterministic ordering. +7. Add configured local exact paths and globs. +8. Add configured URL observations with explicit `404` and `410` semantics. +9. Add root-specific watcher registration and watcher-backed `Refreshable` invalidation where coverage is proven. +10. Migrate local `SkillV2` directory observations to per-source refreshables after skill failure semantics are corrected. +11. Add durable Session-scoped nested read discovery. + +## Open Questions + +1. Should source-specific removal rendering extend `SystemContext.Component`, or should `InstructionContext` retain removal metadata in a separate component registry? +2. Should the first local scan failure preserve prior discovered sources as unavailable, or fail the current provider turn until a coherent rescan succeeds? +3. Should configured URL sources treat `404` and `410` as confirmed removals? +4. What root-specific watcher API cleanly models ignore policy and callback health? +5. Should own-process file mutations publish an advisory invalidation event synchronously after commit? + +## Compression Line + +```text +State remembers what should be loaded. +Refreshable remembers whether a successful observation needs loading again. +Context Epoch remembers what the model was told. +```