docs(v2): design refreshable context sources

This commit is contained in:
Kit Langton
2026-06-04 15:34:31 -04:00
parent ad79f13073
commit 39740e75da
+401
View File
@@ -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<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:
```ts
type State<A> =
| { 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<ReadonlyArray<SystemContext.Component>>
}
```
`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/<stable-hash-of-normalized-absolute-path>
core/instructions/url/<stable-hash-of-normalized-url>
```
Rendered text retains the human-readable source identity:
```text
Instructions from: /repo/packages/core/AGENTS.md
<exact file contents>
```
or:
```text
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.
```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<string, Info[]>`.
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<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 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.
```