Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions packages/context/src/context/layers/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ interface FileReferenceOptions {
baseDir?: string;
/** Slot position (defaults to Slot.RAG) */
slot?: number;
/** Model to use for priority scoring */
/**
* Model for LLM-backed relevance scoring of referenced files. OPT-IN: when
* omitted, a fast path-heuristic scores references (no model call), keeping
* the user's turn free of an extra LLM round-trip per new #file reference.
*/
scoringModel?: string;
/** Maximum file size in bytes (defaults to 1MB) */
maxFileSize?: number;
Expand Down Expand Up @@ -414,7 +418,8 @@ interface ScoreFileRelevanceParams {
async function scoreFileRelevance(params: ScoreFileRelevanceParams): Promise<number> {
const { filePath, fileContent, userQuery, ctx, model } = params;

if (!ctx.callModel) {
// No scoring model configured (the default) or no model access: heuristic.
if (!model || !ctx.callModel) {
const queryLower = userQuery.toLowerCase();
const pathLower = filePath.toLowerCase();
if (pathLower.includes(queryLower) || queryLower.includes(pathBasename(pathLower))) {
Expand Down Expand Up @@ -480,7 +485,7 @@ function createFileReferenceRuntime(opts?: FileReferenceOptions): FileReferenceR
return {
baseDir,
slot: opts?.slot ?? Slot.RAG,
scoringModel: opts?.scoringModel ?? 'anthropic/claude-haiku-4-5-20251001',
scoringModel: opts?.scoringModel?.trim() ?? '',
readOpts: {
maxFileSize,
followSymlinks,
Expand Down Expand Up @@ -983,7 +988,8 @@ async function renderFileReferenceDelta({
*
* Behavior:
* - Transforms references to markdown anchor links `[#path/to/file](#path-to-file)`
* - Scores file relevance using LLM when first referenced
* - Scores file relevance when first referenced — path-match heuristic by
* default; LLM scoring only when `scoringModel` is explicitly configured
* - Injects file contents into context via recall(), ordered by priority
* - Detects file changes on each new message, triggers immediate re-render
* - Shows warning for deleted files
Expand All @@ -1003,10 +1009,10 @@ export function filesystem(opts?: FileReferenceOptions): ContextLayer<FileRefere
// anchoring plus a compact supersede is worth the most on.
placement: 'anchor',
timeouts: {
// onItemAppend reads files AND runs an LLM scoring call per new
// reference (parallelized) — the 5s pipeline default silently drops the
// transform + state update on timeout, killing the layer's feature.
onItemAppend: 30_000,
// onItemAppend reads files; when an LLM scoringModel is opted in it also
// scores each new reference — keep headroom so a timeout doesn't
// silently drop the transform + state update.
onItemAppend: runtime.scoringModel ? 30_000 : 10_000,
},
hooks: {
init: () => initFileReferenceState(runtime.baseDir),
Expand Down
66 changes: 63 additions & 3 deletions packages/core/test/context/filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1235,9 +1235,66 @@ describe('filesystem', () => {
});

describe('append-pipeline timeout headroom + parallel scoring (M8)', () => {
it('factory pins onItemAppend timeout at 30s (fs + LLM work cannot fit the 5s default)', () => {
const layer = filesystem();
expect(layer.timeouts?.onItemAppend).toBe(30_000);
it('sizes the onItemAppend timeout to the work actually configured', () => {
// Default is heuristic-only scoring: fs reads fit a tighter budget than
// the 5s pipeline default allows, but need nothing like 30s.
expect(filesystem().timeouts?.onItemAppend).toBe(10_000);
// Opting into LLM scoring adds a model round-trip per new reference.
expect(
filesystem({
scoringModel: 'anthropic/claude-haiku-4-5-20251001',
}).timeouts?.onItemAppend,
).toBe(30_000);
});

it('never calls the model when no scoringModel is configured (heuristic default)', async () => {
await createTestFile('h1.ts', 'heuristic target');

const layer = filesystem({
baseDir: tempDir,
});
const store = createLayerStateStore();
let modelCalls = 0;
const ctx = makeCtx({
executionId: 'exec-heuristic',
callModel: async () => {
modelCalls++;
throw new Error('implicit model call');
},
});
await initLayers({
layers: [
layer,
],
ctx,
storage: makeStorage(),
store,
});

await runAppendPipeline({
layers: [
layer,
],
items: [
makeUserMessage('Look at #h1.ts'),
],
ctx,
log: makeItemLog(),
store,
});

expect(modelCalls).toBe(0);
const state = store.get<{
files: Map<
string,
{
priority: number;
}
>;
}>('exec-heuristic', 'filesystem');
// Heuristic path: the query contains the basename, so the path-match
// branch scores 80 — no model was involved either way.
expect(state?.files.get('h1.ts')?.priority).toBe(80);
});

it('scores multiple new references in parallel (wall time ≪ sequential), all tracked', async () => {
Expand All @@ -1248,6 +1305,9 @@ describe('filesystem', () => {

const layer = filesystem({
baseDir: tempDir,
// Required: LLM scoring is opt-in, so without a model the scoring path
// never runs and the wall-clock parallelism assertion below is vacuous.
scoringModel: 'test/scorer',
});
const store = createLayerStateStore();
const ctx = makeCtx({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The filesystem layer watches user messages for `#path/to/file.ts`-style referenc
- **Budget**: `'auto'`
- **Re-render timing**: `immediate`
- **[Placement](/docs/framework/context-layers#prompt-cache-anchoring)**: `'anchor'` — a large payload that changes one file at a time. The layer implements `renderDelta`, so a change publishes only the files that moved plus a note of any no longer referenced, instead of republishing the whole set
- **Hook timeouts**: `onItemAppend` `30000` ms — it reads files *and* runs an LLM scoring call per new reference, so the 5s pipeline default would silently drop the transform
- **Hook timeouts**: `onItemAppend` `10000` ms for file reads by default; `30000` ms when `scoringModel` is configured, since each new reference then also runs an LLM scoring call — the 5s pipeline default would silently drop the transform

## Usage

Expand All @@ -33,7 +33,7 @@ A user message like `Look at #src/index.ts and #package.json` tracks both files;
interface FilesystemOptions {
baseDir?: string; // base for resolving relative paths (default: cwd)
slot?: number; // default Slot.RAG (350)
scoringModel?: string; // model for priority scoring
scoringModel?: string; // opt-in: LLM relevance scoring (default: heuristic, no model call)
maxFileSize?: number; // default 1 MB
followSymlinks?: boolean; // default false (security)
allowedExtensions?: string[]; // default: common code/text extensions
Expand All @@ -44,7 +44,7 @@ interface FilesystemOptions {
|---|---|---|---|
| `baseDir` | `string` | `process.cwd()` | All references resolve relative to this directory |
| `slot` | `number` | `350` | Position of injected file contents |
| `scoringModel` | `string` | a small fast model | Used for the relevance-scoring LLM call |
| `scoringModel` | `string` | none (heuristic) | Opt-in model for the relevance-scoring LLM call |
| `maxFileSize` | `number` | `1048576` | Files larger than this are rejected with `FILE_TOO_LARGE` |
| `followSymlinks` | `boolean` | `false` | When `false`, any symlinked path component is rejected |
| `allowedExtensions` | `string[]` | code/text set | Extension (or exact filename, e.g. `Dockerfile`) allowlist |
Expand All @@ -54,7 +54,7 @@ interface FilesystemOptions {
### onItemAppend

1. Scans incoming user messages for the `#path` pattern (requires a file-like shape — `#hashtag`, `#123`, `#region` are not matched).
2. New references are read and **priority-scored 0-100** via an LLM call against the current user query (path-match heuristic when no model is available; parse failures default to 50). Absolute paths are rejected — only relative paths under `baseDir` are allowed.
2. New references are read and **priority-scored 0-100**. By default a fast path-match heuristic scores references — no model call, so the user's turn carries no extra LLM round-trip per `#file` reference. Configure `scoringModel` to opt into LLM scoring against the current user query (parse failures default to 50). Absolute paths are rejected — only relative paths under `baseDir` are allowed.
3. Already-tracked files are re-read for **change detection** (content hash); changes, deletions, and reappearances update the tracked state and request an immediate re-render.
4. Reference text in the message is transformed into markdown anchor links.

Expand Down
35 changes: 35 additions & 0 deletions specs/12-builtin-context-layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,41 @@ const layers = [

---

## `filesystem()`

Tracks `#path/to/file` references in user messages and injects priority-scored file contents into the context.

```typescript
interface FileReferenceOptions {
baseDir?: string; // base for resolving relative paths, default cwd
slot?: number; // default Slot.RAG
scoringModel?: string; // OPT-IN: LLM relevance scoring; default heuristic, no model call
maxFileSize?: number; // default 1MB
followSymlinks?: boolean; // default false (security)
allowedExtensions?: string[]; // default: common code/text extensions
}

function filesystem(opts?: FileReferenceOptions): ContextLayer<FileReferenceState>
```

| Property | Value |
|----------|-------|
| **id** | `'filesystem'` |
| **slot** | `Slot.RAG` (350) |
| **scope** | `'thread'` |
| **budget** | `'auto'` |
| **timeouts** | `{ onItemAppend: 10_000 }` for file reads; `30_000` when `scoringModel` is configured (each new reference then runs an LLM scoring call) |
| **hooks** | `init`, `onItemAppend`, `recall`, `renderDelta` |
| **placement** | `'anchor'` |

**Behavior:**
- `onItemAppend`: Scans user messages for `#path` references, transforms them to markdown anchor links, reads new references in parallel, and re-reads tracked files for change detection (content hash). Absolute paths are rejected; reads are gated by containment, symlink rejection, an extension allowlist, and a size cap.
- **Scoring is heuristic by default** — a path/query match heuristic scores each new reference 0-100 with **no model call**. LLM-backed scoring against the current user query happens only when `scoringModel` is explicitly configured; this is a deliberate breaking change from the previous implicit small-model default, which silently issued an LLM call per new reference inside the append pipeline.
- `recall`: Renders a `# Referenced Files` developer message ordered by priority, budget-trimmed (head + tail with a truncation marker).
- `renderDelta`: Publishes only changed/dropped file blocks.

---

## Custom Layer Examples (Informative)

### RAG Knowledge Base
Expand Down
Loading