18 KiB
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.
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. Its scoped contributor will directly observe local instruction state whenever SystemContextRegistry.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 one immutable baseline, chronological updates, unavailable state, and removal tombstones. |
SystemContextRegistry |
Assemble Location-scoped built-in, instruction, and plugin context producers in stable contribution-key order. |
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:
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.
export interface Refreshable<A, E = never, R = never> {
readonly get: Effect.Effect<A, E, R>
readonly invalidate: Effect.Effect<void>
}
export const make = <A, E, R>(
load: Effect.Effect<A, E, R>,
): Effect.Effect<Refreshable<A, E, R>>
Internal state:
type State<A> =
| { readonly _tag: "Empty" }
| { readonly _tag: "Fresh"; readonly value: A }
| { readonly _tag: "Stale"; readonly value: A }
Implementation substrate:
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. |
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 Existing Effect Caches
Effect Cache, ScopedCache, and Effect.cachedInvalidateWithTTL(...) cache failed exits. Effect Resource preserves its prior value after a failed refresh, but eagerly acquires and does not reload lazily after explicit invalidation. Context-source observation requires the narrower lazy Empty / Fresh / Stale rule:
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.
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 Source or one aggregate cache for unrelated source kinds.
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 Contributor
Add a Location-scoped contributor to SystemContextRegistry:
yield *
registry.contribute({
key: SystemContext.Key.make("core/instructions"),
load: loadAmbientInstructions(),
})
InstructionContext owns instruction discovery, deterministic ordering, and source loading. SystemContextRegistry owns contributor composition and lifecycle. SystemContext remains unaware of files and URLs.
The first slice closes one coherent ordered instruction set into an aggregate source:
core/instructions
-> [{ path, content }, ...]
Rendered text retains the human-readable source identity:
Instructions from: /repo/packages/core/AGENTS.md
<exact file contents>
or:
Instructions from: https://example.com/shared-agents.md
<exact response body>
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.
sequenceDiagram
participant Runner as Safe Provider Boundary
participant Registry as System Context Registry
participant Instructions as Instruction Context
participant Files
participant Epoch as Context Epoch
Runner->>Registry: load
Registry->>Instructions: run contribution
Instructions->>Files: discover and read AGENTS.md files
Files-->>Instructions: coherent current observation
Instructions-->>Registry: instruction SystemContext
Registry-->>Runner: composed SystemContext
Runner->>Epoch: compare and durably admit changes
Source Outcomes
Discovery and file reads form one coherent aggregate observation in the first slice.
successful discovery and reads
-> one ordered aggregate instruction value
temporary discovery or read failure
-> aggregate SystemContext.unavailable
| Observation | Source outcome |
|---|---|
| Local scan succeeds and discovers readable file | Include its exact contents in the available aggregate source. |
| Local scan succeeds and a previously discovered file is absent | Remove it from the aggregate value; remove the aggregate source when no instructions remain. |
| Local scan or file read fails transiently | Preserve the admitted aggregate source as SystemContext.unavailable; never emit mass removals. |
| Empty local file | Include the empty exact content in the available aggregate source. |
URL returns 2xx body |
Available source 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. |
Aggregate instruction removal text must be model-meaningful:
Previously loaded instructions no longer apply.
First Ambient Slice
Implement only:
global config AGENTS.md
+ upward project AGENTS.md ancestors
+ one aggregate core/instructions source
+ direct safe-turn observation
Preserve V1 ancestor stacking for AGENTS.md: nearest ancestor first, then outward through the project boundary.
Do not include yet:
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:
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 structured snapshots
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:
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:
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.
When coverage is proven, cache each known candidate instruction path independently rather than invalidating one aggregate instruction cache:
candidate instruction path
-> one Refreshable<File | Absent>
-> watcher event invalidates only the matching path
-> next safe provider boundary reloads only stale candidates
-> available candidates become ordered per-file Context Sources
Ambient candidates include the global AGENTS.md path and one AGENTS.md candidate in every applicable ancestor directory, including candidates that are currently absent so later additions are observable.
URL Sources
URLs never share an observation cache with local discovery.
Start with direct safe-turn loading:
safe provider-turn boundary
-> fetch URL
-> emit available or unavailable source
If measurements show excessive requests, add a URL-specific invalidation policy later:
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<string, Info[]>.
Use Refreshable later for local directory sources only after skill observation distinguishes confirmed absence from transient failure:
State.create
-> current replayable Source registrations
private Map<Source.key, Refreshable>
-> 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 the System Context Registry, scoped context contributions, optional watcher-consumer fibers, and refreshable state.
Effect.forkScoped(...)interrupts watcher-consumer fibers when the cached Location runtime is disposed.- Stream finalization unsubscribes
EventV2PubSub subscriptions. Watcher.locationLayerseparately finalizes native Parcel watcher subscriptions.- Repeated invalidations coalesce into one
Stalestate. - 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.
Implementation Status And Follow-Up Order
Implemented in the direct-observation slice:
- Add the Location-scoped
SystemContextRegistrybacked by stable-keyed scoped contributions. - Register built-in and ambient instruction producers with
SystemContextRegistry. - Observe local instructions directly at each safe provider boundary.
- Preserve admitted instructions after transient scan/read failure and block initial provider turns while context is unavailable.
- Test ordering, edit, unlink, empty file, transient scan failure, discovered-then-missing races, durable restart behavior, and deterministic context admission.
Follow-up order:
- Add and unit-test
Refreshable.make(load)withgetandinvalidate. - Add truthful root-specific watcher registration.
- Move ambient instructions from one directly observed aggregate to one watcher-invalidated Refreshable and Context Source per candidate file.
- Add configured local exact paths and globs.
- Add configured URL observations with explicit
404and410semantics. - Migrate local
SkillV2directory observations to per-source refreshables after skill failure semantics are corrected. - Add durable Session-scoped nested read discovery.
Open Questions
- Should configured URL sources treat
404and410as confirmed removals? - What root-specific watcher API cleanly models ignore policy and callback health?
- Should own-process file mutations publish an advisory invalidation event synchronously after commit?
Compression Line
SystemContextRegistry remembers which context producers participate.
Refreshable remembers whether a successful observation needs loading again.
Context Epoch remembers what the model was told.