From 2eac5e50c5293ff4a8e5537e9bd97f7fd5cb0fbd Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 1 Oct 2026 10:11:22 +0300 Subject: [PATCH] LOU-W4, LOU-W7: sessions for createAgent; AGENTS.md auto-loading W4: agent.session() returns an AgentSession that owns the transcript and passes it to the executor on each send (no checkpoint/resume involved). MemorySessionStore (default) and FileSessionStore (atomic write, id validation). Sends are serialized; failed or aborted sends leave the transcript unchanged; stored transcripts are trimmed to a provider-valid prefix. W7: loadProjectInstructions() walks up to .git/stopAt and returns the nearest AGENTS.md/CLAUDE.md (size-capped); createAgent({ projectInstructions }) is an opt-in that appends it under a heading. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 14 ++ docs/configuration.md | 30 +++++ docs/sessions.md | 84 ++++++++++++ llms-full.txt | 131 +++++++++++++++++++ llms.txt | 1 + src/createAgent.ts | 72 +++++++++-- src/index.ts | 6 + src/projectInstructions.test.ts | 121 ++++++++++++++++++ src/projectInstructions.ts | 80 ++++++++++++ src/session/AgentSession.ts | 144 +++++++++++++++++++++ src/session/index.ts | 4 + src/session/session.test.ts | 219 ++++++++++++++++++++++++++++++++ src/session/sessionStore.ts | 131 +++++++++++++++++++ 13 files changed, 1026 insertions(+), 11 deletions(-) create mode 100644 docs/sessions.md create mode 100644 src/projectInstructions.test.ts create mode 100644 src/projectInstructions.ts create mode 100644 src/session/AgentSession.ts create mode 100644 src/session/index.ts create mode 100644 src/session/session.test.ts create mode 100644 src/session/sessionStore.ts diff --git a/README.md b/README.md index a1b25f4b6..37d22d1ce 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ dashboard — any provider, any deploy target, no lock-in. - **Human-in-the-loop** — flag a tool `needsApproval` and pause execution until a human approves or rejects it, then `resumeAfterApproval()` from any process - **Durable execution** — pass a `sessionId` + `checkpointStore` and a crash mid-conversation resumes instead of restarting - **Cancellation** — pass an `AbortSignal` (`agent.send(input, { signal })`) to stop a run; it resolves with `finishReason: 'aborted'` and the transcript so far, and the signal reaches the provider, tools and delegated agents +- **Sessions** — `agent.session()` keeps a multi-turn conversation (in memory, or persisted with `FileSessionStore`) +- **Project instructions** — `createAgent({ projectInstructions: true })` appends the nearest `AGENTS.md` / `CLAUDE.md` to the instructions (opt-in) - **Parallel tool calls** — when the model asks for several tools in one turn they run concurrently (cap it with `toolConcurrency`, or `1` for sequential), and results still reach the transcript in the model's call order - **Skills** — `defineSkill()` / `loadSkills('./skills')`: only each skill's name and description sit in the system prompt; the model loads the full markdown on demand through an auto-registered `load_skill` tool - **Multi-agent delegation** — wrap a child agent as a tool with `createDelegateTool()`, with a `maxDepth` guard against delegation loops @@ -205,6 +207,17 @@ console.log(result.text); console.log(result.usage.totalTokens, result.finishReason, result.steps); ``` +### Multi-turn sessions + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const session = createAgent({ provider }).session(); // or .session({ id, store: new FileSessionStore(dir) }) +await session.send('My name is Ali.'); +const { text } = await session.send('What is my name?'); // remembers +console.log(session.id, session.messages.length); +``` + ## Documentation - [Installation](docs/installation.md) - requirements, peer/provider packages, installing from a local build @@ -214,6 +227,7 @@ console.log(result.usage.totalTokens, result.finishReason, result.steps); - [API Overview](docs/api-overview.md) - the main exports; `npm run docs:build` generates the full TypeDoc reference - [Tracing and observability](docs/observability.md) - OpenTelemetry GenAI spans, attribute table, content opt-in - [Testing](docs/testing.md) - unit-test agents deterministically with the scripted `mockModel` +- [Sessions](docs/sessions.md) - multi-turn conversations: `agent.session()`, `MemorySessionStore`, `FileSessionStore` - [Skills](docs/skills.md) - on-demand instructions: `defineSkill()`, `loadSkills()`, how they save context - [Agent Forge](docs/agent-forge.md) - the visual dashboard (`loushy studio`): quickstart, first-agent walkthrough, hook authoring - Full guides site: [linuxdevil.github.io/agent-sdk-docs](https://linuxdevil.github.io/agent-sdk-docs/) diff --git a/docs/configuration.md b/docs/configuration.md index 161cd9fb9..51e212d7e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -145,6 +145,7 @@ what the examples and the Quick Start use by default. | `tools` | `Record`, keyed by the name the agent uses. | | `name` | Agent name (default `'agent'`). | | `maxSteps` | Passed through to `AgentExecutor.execute()`. | +| `projectInstructions` | `true` or `{ cwd?, files? }`: append the nearest `AGENTS.md` / `CLAUDE.md` to the instructions (off by default; see [Project instructions](#project-instructions)). | With neither `model` nor `provider`, `createAgent()` resolves from the environment: `LOUSHY_MODEL` (a `'provider/model'` string) if set, otherwise @@ -159,6 +160,35 @@ variable (`createAgent: OPENAI_API_KEY is not set. ...`), an unknown prefix lists the supported ones and suggests the closest, and a missing optional peer dependency prints the exact `npm install` command. +## Project instructions + +Many repositories keep guidance for coding agents in an `AGENTS.md` (or +`CLAUDE.md`) file. `createAgent` can append it to the agent's instructions: + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const agent = createAgent({ + instructions: 'You review pull requests.', + provider, + projectInstructions: true, // or { cwd: './packages/api', files: ['AGENTS.md'] } +}); +``` + +The file is added after your own instructions under the heading +`## Project instructions (from AGENTS.md)`. It is read once, when the agent is +created. The lookup walks up from `cwd` (default `process.cwd()`) and uses the +nearest directory that has one of `files` (default `['AGENTS.md', 'CLAUDE.md']`, +first match wins), stopping at the nearest directory that contains `.git` or at +the filesystem root. Content over 32,000 characters is cut with a truncation +marker. If no file is found nothing is added. + +This is **opt-in** on purpose: reading files from disk by default would surprise +people who embed the SDK in a server, where the working directory is not the +project the agent is about. To find the file yourself (for example to show it), +use `loadProjectInstructions({ cwd, files, stopAt, maxChars })`, which returns +`{ path, content }` or `undefined`. + ## `AgentExecutor.execute()` options `AgentExecutor` is static: call `AgentExecutor.execute(options)`. Only diff --git a/docs/sessions.md b/docs/sessions.md new file mode 100644 index 000000000..ca1454fa9 --- /dev/null +++ b/docs/sessions.md @@ -0,0 +1,84 @@ +# Sessions + +`agent.send(text)` is single-turn: every call starts from an empty history. A +**session** is a multi-turn conversation: it keeps the transcript and passes it +to the model on every `send()`. + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const chat = createAgent({ instructions: 'Be brief.', provider }); + +const session = chat.session(); // in memory, new generated id +await session.send('My name is Ali.'); +const { text } = await session.send('What is my name?'); // sees the first exchange + +console.log(session.id, session.messages.length); +``` + +## The session object + +| Member | Description | +| ------ | ----------- | +| `id` | The session id: generated (a UUID) unless you pass one. | +| `send(input, { signal })` | Sends a user message with the whole conversation so far and resolves with the usual `ExecutionResult`. | +| `messages` | A read-only snapshot of the transcript (user, assistant and tool messages; no system prompt). Editing the snapshot does not change the session. | +| `load()` | Reads the saved transcript from the store. `send()` does this for you; call it to show history before the first `send()` of a resumed session. | +| `clear()` | Forgets the conversation, including in the store. | + +Concurrent `send()` calls on one session are queued and run one after another in +call order, so the transcript never interleaves. + +A `send()` that throws (a provider error, or a tool that throws a +`PropagatingToolError`) or is aborted with `signal` leaves the transcript +exactly as it was before that call. An aborted `send()` resolves with +`finishReason: 'aborted'`, as `agent.send()` does. The stored transcript never +contains an assistant tool-call turn without the matching tool results. + +Sessions are a thin layer: the session owns the transcript and hands it to the +executor on each turn. They do not use the checkpoint/resume mechanism +(`sessionId` + `checkpointStore` on `AgentExecutor.execute()`), which is for +resuming one interrupted run, not for continuing a conversation. + +## Stores + +A `SessionStore` keeps transcripts between calls: + +```ts +import type { Message } from '@loushy/build-ai-agent'; + +interface SessionStore { + load(id: string): Promise; + save(id: string, messages: readonly Message[]): Promise; + delete(id: string): Promise; +} +``` + +- `MemorySessionStore` (the default, one new store per session) lives as long as + the process. Share one instance between sessions to look them up by id. +- `FileSessionStore(dir)` writes one JSON file per session (`/.json`), + atomically (temp file, then rename), creating `dir` on first save. + +```ts +import { createAgent, FileSessionStore } from '@loushy/build-ai-agent'; + +const agent = createAgent({ provider }); +const store = new FileSessionStore('./.loushy/sessions'); + +const first = agent.session({ id: 'user-42', store }); +await first.send('My name is Ali.'); + +// Later, even in another process or another createAgent() instance: +const again = agent.session({ id: 'user-42', store }); +await again.send('What is my name?'); // "Ali" +``` + +Session ids must match `^[A-Za-z0-9_-]{1,128}$` (they become file names, so +`../x` and `a/b` are refused with an error that says so). Implement +`SessionStore` yourself to keep transcripts in a database or Redis. + +## Project instructions + +Not part of sessions, but often wanted together: see "Project instructions" in +[Configuration](configuration.md) to give an agent your repository's +`AGENTS.md`. diff --git a/llms-full.txt b/llms-full.txt index b02923f09..fb5b6b713 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -20,6 +20,8 @@ dashboard — any provider, any deploy target, no lock-in. - **Human-in-the-loop** — flag a tool `needsApproval` and pause execution until a human approves or rejects it, then `resumeAfterApproval()` from any process - **Durable execution** — pass a `sessionId` + `checkpointStore` and a crash mid-conversation resumes instead of restarting - **Cancellation** — pass an `AbortSignal` (`agent.send(input, { signal })`) to stop a run; it resolves with `finishReason: 'aborted'` and the transcript so far, and the signal reaches the provider, tools and delegated agents +- **Sessions** — `agent.session()` keeps a multi-turn conversation (in memory, or persisted with `FileSessionStore`) +- **Project instructions** — `createAgent({ projectInstructions: true })` appends the nearest `AGENTS.md` / `CLAUDE.md` to the instructions (opt-in) - **Parallel tool calls** — when the model asks for several tools in one turn they run concurrently (cap it with `toolConcurrency`, or `1` for sequential), and results still reach the transcript in the model's call order - **Skills** — `defineSkill()` / `loadSkills('./skills')`: only each skill's name and description sit in the system prompt; the model loads the full markdown on demand through an auto-registered `load_skill` tool - **Multi-agent delegation** — wrap a child agent as a tool with `createDelegateTool()`, with a `maxDepth` guard against delegation loops @@ -203,6 +205,17 @@ console.log(result.text); console.log(result.usage.totalTokens, result.finishReason, result.steps); ``` +### Multi-turn sessions + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const session = createAgent({ provider }).session(); // or .session({ id, store: new FileSessionStore(dir) }) +await session.send('My name is Ali.'); +const { text } = await session.send('What is my name?'); // remembers +console.log(session.id, session.messages.length); +``` + ## Documentation - [Installation](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/installation.md) - requirements, peer/provider packages, installing from a local build @@ -212,6 +225,7 @@ console.log(result.usage.totalTokens, result.finishReason, result.steps); - [API Overview](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/api-overview.md) - the main exports; `npm run docs:build` generates the full TypeDoc reference - [Tracing and observability](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/observability.md) - OpenTelemetry GenAI spans, attribute table, content opt-in - [Testing](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/testing.md) - unit-test agents deterministically with the scripted `mockModel` +- [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md) - multi-turn conversations: `agent.session()`, `MemorySessionStore`, `FileSessionStore` - [Skills](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/skills.md) - on-demand instructions: `defineSkill()`, `loadSkills()`, how they save context - [Agent Forge](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-forge.md) - the visual dashboard (`loushy studio`): quickstart, first-agent walkthrough, hook authoring - Full guides site: [linuxdevil.github.io/agent-sdk-docs](https://linuxdevil.github.io/agent-sdk-docs/) @@ -1945,6 +1959,7 @@ what the examples and the Quick Start use by default. | `tools` | `Record`, keyed by the name the agent uses. | | `name` | Agent name (default `'agent'`). | | `maxSteps` | Passed through to `AgentExecutor.execute()`. | +| `projectInstructions` | `true` or `{ cwd?, files? }`: append the nearest `AGENTS.md` / `CLAUDE.md` to the instructions (off by default; see [Project instructions](#project-instructions)). | With neither `model` nor `provider`, `createAgent()` resolves from the environment: `LOUSHY_MODEL` (a `'provider/model'` string) if set, otherwise @@ -1959,6 +1974,35 @@ variable (`createAgent: OPENAI_API_KEY is not set. ...`), an unknown prefix lists the supported ones and suggests the closest, and a missing optional peer dependency prints the exact `npm install` command. +## Project instructions + +Many repositories keep guidance for coding agents in an `AGENTS.md` (or +`CLAUDE.md`) file. `createAgent` can append it to the agent's instructions: + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const agent = createAgent({ + instructions: 'You review pull requests.', + provider, + projectInstructions: true, // or { cwd: './packages/api', files: ['AGENTS.md'] } +}); +``` + +The file is added after your own instructions under the heading +`## Project instructions (from AGENTS.md)`. It is read once, when the agent is +created. The lookup walks up from `cwd` (default `process.cwd()`) and uses the +nearest directory that has one of `files` (default `['AGENTS.md', 'CLAUDE.md']`, +first match wins), stopping at the nearest directory that contains `.git` or at +the filesystem root. Content over 32,000 characters is cut with a truncation +marker. If no file is found nothing is added. + +This is **opt-in** on purpose: reading files from disk by default would surprise +people who embed the SDK in a server, where the working directory is not the +project the agent is about. To find the file yourself (for example to show it), +use `loadProjectInstructions({ cwd, files, stopAt, maxChars })`, which returns +`{ path, content }` or `undefined`. + ## `AgentExecutor.execute()` options `AgentExecutor` is static: call `AgentExecutor.execute(options)`. Only @@ -2357,6 +2401,93 @@ them as LLM, agent and tool calls with token usage; others show them as ordinary spans with attributes. Which conventions a given backend supports, and which version of them, varies; check its documentation. +# Sessions + +Source: docs/sessions.md + +`agent.send(text)` is single-turn: every call starts from an empty history. A +**session** is a multi-turn conversation: it keeps the transcript and passes it +to the model on every `send()`. + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const chat = createAgent({ instructions: 'Be brief.', provider }); + +const session = chat.session(); // in memory, new generated id +await session.send('My name is Ali.'); +const { text } = await session.send('What is my name?'); // sees the first exchange + +console.log(session.id, session.messages.length); +``` + +## The session object + +| Member | Description | +| ------ | ----------- | +| `id` | The session id: generated (a UUID) unless you pass one. | +| `send(input, { signal })` | Sends a user message with the whole conversation so far and resolves with the usual `ExecutionResult`. | +| `messages` | A read-only snapshot of the transcript (user, assistant and tool messages; no system prompt). Editing the snapshot does not change the session. | +| `load()` | Reads the saved transcript from the store. `send()` does this for you; call it to show history before the first `send()` of a resumed session. | +| `clear()` | Forgets the conversation, including in the store. | + +Concurrent `send()` calls on one session are queued and run one after another in +call order, so the transcript never interleaves. + +A `send()` that throws (a provider error, or a tool that throws a +`PropagatingToolError`) or is aborted with `signal` leaves the transcript +exactly as it was before that call. An aborted `send()` resolves with +`finishReason: 'aborted'`, as `agent.send()` does. The stored transcript never +contains an assistant tool-call turn without the matching tool results. + +Sessions are a thin layer: the session owns the transcript and hands it to the +executor on each turn. They do not use the checkpoint/resume mechanism +(`sessionId` + `checkpointStore` on `AgentExecutor.execute()`), which is for +resuming one interrupted run, not for continuing a conversation. + +## Stores + +A `SessionStore` keeps transcripts between calls: + +```ts +import type { Message } from '@loushy/build-ai-agent'; + +interface SessionStore { + load(id: string): Promise; + save(id: string, messages: readonly Message[]): Promise; + delete(id: string): Promise; +} +``` + +- `MemorySessionStore` (the default, one new store per session) lives as long as + the process. Share one instance between sessions to look them up by id. +- `FileSessionStore(dir)` writes one JSON file per session (`/.json`), + atomically (temp file, then rename), creating `dir` on first save. + +```ts +import { createAgent, FileSessionStore } from '@loushy/build-ai-agent'; + +const agent = createAgent({ provider }); +const store = new FileSessionStore('./.loushy/sessions'); + +const first = agent.session({ id: 'user-42', store }); +await first.send('My name is Ali.'); + +// Later, even in another process or another createAgent() instance: +const again = agent.session({ id: 'user-42', store }); +await again.send('What is my name?'); // "Ali" +``` + +Session ids must match `^[A-Za-z0-9_-]{1,128}$` (they become file names, so +`../x` and `a/b` are refused with an error that says so). Implement +`SessionStore` yourself to keep transcripts in a database or Redis. + +## Project instructions + +Not part of sessions, but often wanted together: see "Project instructions" in +[Configuration](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md) to give an agent your repository's +`AGENTS.md`. + # Skills Source: docs/skills.md diff --git a/llms.txt b/llms.txt index b34ea3137..4e3eedd6b 100644 --- a/llms.txt +++ b/llms.txt @@ -10,6 +10,7 @@ - [API Overview](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/api-overview.md): The package root (`@loushy/build-ai-agent`) exports everything below. - [Configuration](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md): A declarative agent is a YAML (`.yaml`/`.yml`) or JSON (`.json`) file, validated with zod by `loadSpec()` (`src/spec/schema.ts`). - [Tracing and observability](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/observability.md): `AgentExecutor.execute()` and `FlowExecutor.execute()` emit spans through a `TraceExporter`. +- [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md): `agent.send(text)` is single-turn: every call starts from an empty history. - [Skills](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/skills.md): A skill is a named chunk of instructions (a markdown document) that the model loads only when it needs it. - [Testing agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/testing.md): `@loushy/build-ai-agent/testing` ships `mockModel`: a scripted, deterministic `LLMProvider` for unit tests. diff --git a/src/createAgent.ts b/src/createAgent.ts index c3cdcb67c..ba4dacc99 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -25,6 +25,10 @@ import { modelFromEnv, resolveProviderSpec } from './providers/providerSpec'; import type { DefinedTool } from './tools/defineTool'; import { ToolConcurrency, assertToolConcurrency } from './execution/toolBatch'; import type { Skill } from './skills/defineSkill'; +import type { Message } from './providers/llm'; +import { AgentSession, type SessionOptions } from './session/AgentSession'; +import { loadProjectInstructions } from './projectInstructions'; +import { basename } from 'node:path'; /** * Options for createAgent() that do not depend on how the instructions and @@ -66,6 +70,20 @@ export interface CreateAgentBase { * ``` */ toolConcurrency?: ToolConcurrency; + /** + * Opt in to appending the nearest `AGENTS.md` / `CLAUDE.md` (found by + * walking up from `cwd`, see `loadProjectInstructions()`) to the agent's + * instructions, under a `## Project instructions (from AGENTS.md)` heading + * (LOU-W7). Off by default: the SDK never reads files from disk unless asked. + * Nothing is added when no file is found. Read once, when the agent is created. + * + * @example + * ```ts + * createAgent({ prompt: '...', provider, projectInstructions: true }); + * createAgent({ prompt: '...', provider, projectInstructions: { cwd: './packages/api', files: ['AGENTS.md'] } }); + * ``` + */ + projectInstructions?: boolean | { cwd?: string; files?: readonly string[] }; } /** @@ -147,6 +165,19 @@ export interface SendOptions { export interface SimpleAgent { /** Send a single user message and get back the full execution result text. */ send: (message: string, options?: SendOptions) => Promise; + /** + * Start a multi-turn conversation (LOU-W4): every `send()` sees the earlier + * exchanges. In memory by default; pass `{ id, store }` (e.g. a + * `FileSessionStore`) to persist it and continue it later. + * + * @example + * ```ts + * const session = agent.session(); + * await session.send('My name is Ali.'); + * const { text } = await session.send('What is my name?'); + * ``` + */ + session: (options?: SessionOptions) => AgentSession; } /** @@ -165,7 +196,7 @@ export interface SimpleAgent { */ export function createAgent(config: CreateAgentConfig = {}): SimpleAgent { assertToolConcurrency(config.toolConcurrency, 'createAgent'); - const instructions = resolveInstructions(config); + const instructions = withProjectInstructions(resolveInstructions(config), config.projectInstructions); const provider = resolveModelSource(config); const { toolRegistry, toolsConfig } = registerTools(config.tools ?? {}); @@ -179,22 +210,41 @@ export function createAgent(config: CreateAgentConfig = {}): SimpleAgent { if (config.provider && config.model) builder.setSettings({ model: config.model }); const agent = builder.build(); + const run = (input: string | Message[], signal?: AbortSignal): Promise => + AgentExecutor.execute({ + agent, + input, + provider, + toolRegistry, + skills: config.skills, + maxSteps: config.maxSteps, + toolConcurrency: config.toolConcurrency, + signal, + }); + return { async send(message: string, options: SendOptions = {}): Promise { - return AgentExecutor.execute({ - agent, - input: message, - provider, - toolRegistry, - skills: config.skills, - maxSteps: config.maxSteps, - toolConcurrency: config.toolConcurrency, - signal: options.signal, - }); + return run(message, options.signal); }, + session: (options?: SessionOptions) => new AgentSession(run, options), }; } +/** Appends the nearest AGENTS.md / CLAUDE.md to `instructions` when `projectInstructions` is set. */ +function withProjectInstructions( + instructions: string, + option: CreateAgentBase['projectInstructions'] +): string { + if (!option) return instructions; + const found = loadProjectInstructions(option === true ? {} : option); + if (!found) return instructions; + return `${instructions} + +## Project instructions (from ${basename(found.path)}) + +${found.content}`; +} + /** Used when neither `instructions` nor `prompt` is given. */ const DEFAULT_INSTRUCTIONS = 'You are a helpful assistant.'; diff --git a/src/index.ts b/src/index.ts index 927a82d95..ddfe59b44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,12 @@ export * from './utils'; // createAgent() convenience API (LOU-H1) export * from './createAgent'; +// Sessions: multi-turn conversations for createAgent() (LOU-W4) +export * from './session'; + +// AGENTS.md / CLAUDE.md auto-loading (LOU-W7) +export * from './projectInstructions'; + // Skills: progressive disclosure of instructions (LOU-Y2) export * from './skills'; diff --git a/src/projectInstructions.test.ts b/src/projectInstructions.test.ts new file mode 100644 index 000000000..6f80a7e1c --- /dev/null +++ b/src/projectInstructions.test.ts @@ -0,0 +1,121 @@ +/** + * LOU-W7: AGENTS.md / CLAUDE.md auto-loading. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadProjectInstructions } from './projectInstructions'; +import { createAgent } from './createAgent'; +import { mockModel } from './testing'; + +let root: string; +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'proj-'))); +}); +afterEach(() => { + vi.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); +}); + +function put(rel: string, content = 'x'): string { + const path = join(root, rel); + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, content); + return path; +} + +describe('loadProjectInstructions', () => { + it('finds the nearest directory that has a file, walking up', () => { + put('.git/HEAD'); + put('AGENTS.md', 'root rules'); + const pkg = put('packages/api/AGENTS.md', 'api rules'); + mkdirSync(join(root, 'packages/api/src/deep'), { recursive: true }); + + const found = loadProjectInstructions({ cwd: join(root, 'packages/api/src/deep') }); + + expect(found).toEqual({ path: pkg, content: 'api rules' }); + }); + + it('prefers AGENTS.md over CLAUDE.md in the same directory, and honours `files` order', () => { + put('.git/HEAD'); + put('AGENTS.md', 'agents'); + put('CLAUDE.md', 'claude'); + expect(loadProjectInstructions({ cwd: root })?.content).toBe('agents'); + expect(loadProjectInstructions({ cwd: root, files: ['CLAUDE.md', 'AGENTS.md'] })?.content).toBe('claude'); + expect(loadProjectInstructions({ cwd: root, files: ['CLAUDE.md'] })?.content).toBe('claude'); + }); + + it('stops at the directory containing .git', () => { + put('AGENTS.md', 'outside the repo'); + put('repo/.git/HEAD'); + mkdirSync(join(root, 'repo/src'), { recursive: true }); + expect(loadProjectInstructions({ cwd: join(root, 'repo/src') })).toBeUndefined(); + }); + + it('checks the repo root itself and accepts a .git file (worktree)', () => { + put('repo/.git', 'gitdir: elsewhere'); + const file = put('repo/AGENTS.md', 'in repo root'); + expect(loadProjectInstructions({ cwd: join(root, 'repo') })?.path).toBe(file); + }); + + it('stops at `stopAt` (inclusive)', () => { + put('AGENTS.md', 'top'); + mkdirSync(join(root, 'a/b/c'), { recursive: true }); + expect(loadProjectInstructions({ cwd: join(root, 'a/b/c'), stopAt: join(root, 'a/b') })).toBeUndefined(); + put('a/b/AGENTS.md', 'at stop'); + expect(loadProjectInstructions({ cwd: join(root, 'a/b/c'), stopAt: join(root, 'a/b') })?.content).toBe( + 'at stop' + ); + }); + + it('truncates long files with a marker', () => { + put('.git/HEAD'); + put('AGENTS.md', 'a'.repeat(100)); + const found = loadProjectInstructions({ cwd: root, maxChars: 10 }); + expect(found?.content.startsWith('a'.repeat(10))).toBe(true); + expect(found?.content).toContain('[... truncated'); + expect(found?.content).not.toContain('a'.repeat(11)); + expect(loadProjectInstructions({ cwd: root })?.content).toBe('a'.repeat(100)); + }); + + it('returns undefined when there is no file (and skips empty files and directories)', () => { + put('.git/HEAD'); + put('AGENTS.md', ' \n'); + mkdirSync(join(root, 'CLAUDE.md')); + expect(loadProjectInstructions({ cwd: root })).toBeUndefined(); + }); +}); + +describe('createAgent({ projectInstructions })', () => { + it('is off by default', async () => { + put('.git/HEAD'); + put('AGENTS.md', 'use tabs'); + const model = mockModel(['ok']); + await createAgent({ instructions: 'Be brief.', provider: model }).send('hi'); + expect(model.calls[0].messages[0].content).toBe('Be brief.'); + }); + + it('appends the file under a heading after the agent instructions', async () => { + put('.git/HEAD'); + put('CLAUDE.md', 'use tabs'); + const model = mockModel(['ok']); + const agent = createAgent({ + instructions: 'Be brief.', + provider: model, + projectInstructions: { cwd: root }, + }); + await agent.send('hi'); + expect(model.calls[0].messages[0].content).toBe( + 'Be brief.\n\n## Project instructions (from CLAUDE.md)\n\nuse tabs' + ); + }); + + it('`true` uses process.cwd() and leaves instructions alone when nothing is found', async () => { + put('.git/HEAD'); + vi.spyOn(process, 'cwd').mockReturnValue(root); + const model = mockModel(['ok']); + await createAgent({ instructions: 'Be brief.', provider: model, projectInstructions: true }).send('hi'); + expect(model.calls[0].messages[0].content).toBe('Be brief.'); + }); +}); diff --git a/src/projectInstructions.ts b/src/projectInstructions.ts new file mode 100644 index 000000000..6e5c5a131 --- /dev/null +++ b/src/projectInstructions.ts @@ -0,0 +1,80 @@ +/** + * Project instructions (LOU-W7): find the nearest AGENTS.md / CLAUDE.md so an + * agent can follow a repository's own conventions. + */ + +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +/** Options for {@link loadProjectInstructions}. */ +export interface LoadProjectInstructionsOptions { + /** Directory to start from. Defaults to `process.cwd()`. */ + cwd?: string; + /** File names to look for in each directory, first match wins. Defaults to `['AGENTS.md', 'CLAUDE.md']`. */ + files?: readonly string[]; + /** Last directory to look in (inclusive). Defaults to the nearest directory containing `.git`, else the filesystem root. */ + stopAt?: string; + /** Longest content to return, in characters; longer files are cut with a marker. Defaults to 32000. */ + maxChars?: number; +} + +/** A project instructions file that was found. */ +export interface ProjectInstructions { + /** Absolute path of the file. */ + path: string; + /** Its text, truncated (with a marker) beyond `maxChars`. */ + content: string; +} + +const DEFAULT_FILES = ['AGENTS.md', 'CLAUDE.md'] as const; +const DEFAULT_MAX_CHARS = 32_000; + +function readIfFile(path: string): string | undefined { + try { + return statSync(path).isFile() ? readFileSync(path, 'utf8') : undefined; + } catch { + return undefined; + } +} + +function findInDirectory(dir: string, files: readonly string[]): ProjectInstructions | undefined { + for (const name of files) { + const path = join(dir, name); + const content = readIfFile(path); + if (content !== undefined && content.trim() !== '') return { path, content }; + } + return undefined; +} + +function truncate(found: ProjectInstructions, maxChars: number): ProjectInstructions { + if (found.content.length <= maxChars) return found; + const marker = `\n\n[... truncated: file is longer than ${maxChars} characters ...]`; + return { path: found.path, content: found.content.slice(0, maxChars) + marker }; +} + +/** + * Find the project instructions file: walk up from `cwd`, and in the nearest + * directory that has one of `files` return the first match. The walk ends at + * `stopAt`, at the first directory containing `.git`, or at the filesystem + * root. Returns `undefined` when there is none. Reads files synchronously. + * + * @example + * ```ts + * const found = loadProjectInstructions({ cwd: process.cwd() }); + * if (found) console.log(found.path); + * ``` + */ +export function loadProjectInstructions( + options: LoadProjectInstructionsOptions = {} +): ProjectInstructions | undefined { + const files = options.files ?? DEFAULT_FILES; + const stop = options.stopAt === undefined ? undefined : resolve(options.stopAt); + let dir = resolve(options.cwd ?? process.cwd()); + for (;;) { + const found = findInDirectory(dir, files); + if (found) return truncate(found, options.maxChars ?? DEFAULT_MAX_CHARS); + const parent = dirname(dir); + if (dir === stop || existsSync(join(dir, '.git')) || parent === dir) return undefined; + dir = parent; + } +} diff --git a/src/session/AgentSession.ts b/src/session/AgentSession.ts new file mode 100644 index 000000000..167cf506f --- /dev/null +++ b/src/session/AgentSession.ts @@ -0,0 +1,144 @@ +/** + * AgentSession (LOU-W4): a multi-turn conversation on top of the stateless + * executor. The session owns the transcript and passes it in on every + * `send()`; it does not use checkpoints or resume. + */ + +import { randomUUID } from 'node:crypto'; +import type { Message } from '../providers/llm'; +import type { ExecutionResult } from '../execution/AgentExecutor'; +import { MemorySessionStore, assertSessionId, type SessionStore } from './sessionStore'; + +/** Options for `agent.session()`. */ +export interface SessionOptions { + /** + * Session id (1-128 characters of `A-Za-z0-9_-`). Defaults to a generated + * UUID. Passing an id that exists in `store` continues that conversation. + */ + id?: string; + /** Where the transcript is kept. Defaults to a new `MemorySessionStore`. */ + store?: SessionStore; +} + +/** + * Runs one turn: `input` is the whole transcript so far, ending in the new + * user message. Supplied by `createAgent()`. + */ +export type SessionRunner = (input: Message[], signal?: AbortSignal) => Promise; + +/** + * Longest prefix of `messages` that a provider accepts: every assistant + * tool-call turn is followed by a result for each of its calls, and no tool + * message is orphaned. + */ +export function providerValidPrefix(messages: readonly Message[]): Message[] { + let i = 0; + while (i < messages.length) { + const message = messages[i]; + if (message.role === 'tool') break; + const calls = message.role === 'assistant' ? (message.toolCalls ?? []) : []; + if (calls.length === 0) { + i += 1; + continue; + } + let end = i + 1; + const answered = new Set(); + while (end < messages.length && messages[end].role === 'tool') { + answered.add(messages[end].toolCallId ?? ''); + end += 1; + } + if (!calls.every((call) => answered.has(call.id))) break; + i = end; + } + return messages.slice(0, i); +} + +/** + * A conversation that remembers earlier turns. Create one with + * `agent.session()`. + * + * @example + * ```ts + * const session = agent.session(); + * await session.send('My name is Ali.'); + * const { text } = await session.send('What is my name?'); + * ``` + */ +export class AgentSession { + readonly id: string; + private readonly store: SessionStore; + private readonly run: SessionRunner; + private history: Message[] = []; + private loaded = false; + private tail: Promise = Promise.resolve(); + + constructor(run: SessionRunner, options: SessionOptions = {}) { + if (options.id !== undefined) assertSessionId(options.id); + this.id = options.id ?? randomUUID(); + this.store = options.store ?? new MemorySessionStore(); + this.run = run; + } + + /** + * Snapshot of the transcript so far (no system prompt). For a session + * continued from a store it is empty until the first `send()` or + * `load()` has completed. + */ + get messages(): readonly Message[] { + return structuredClone(this.history); + } + + /** Read the saved transcript from the store (done automatically by `send()`). */ + async load(): Promise { + await this.enqueue(() => this.ensureLoaded()); + return this.messages; + } + + /** + * Send a user message with the whole conversation so far. Concurrent calls + * run one after another, in call order. + * + * A call that throws or is aborted leaves the transcript as it was before + * the call (an aborted call resolves with `finishReason: 'aborted'`). + * + * @example + * ```ts + * const result = await session.send('And in Paris?', { signal: AbortSignal.timeout(10_000) }); + * ``` + */ + send(input: string, options: { signal?: AbortSignal } = {}): Promise { + return this.enqueue(() => this.turn(input, options.signal)); + } + + /** Forget the conversation (also deletes it from the store). */ + clear(): Promise { + return this.enqueue(async () => { + await this.store.delete(this.id); + this.history = []; + this.loaded = true; + }); + } + + private enqueue(task: () => Promise): Promise { + const result = this.tail.then(task); + this.tail = result.catch(() => undefined); + return result; + } + + private async ensureLoaded(): Promise { + if (this.loaded) return; + this.history = (await this.store.load(this.id)) ?? []; + this.loaded = true; + } + + private async turn(input: string, signal?: AbortSignal): Promise { + await this.ensureLoaded(); + const result = await this.run([...this.history, { role: 'user', content: input }], signal); + if (result.finishReason === 'aborted') return result; + const withoutSystem = result.messages[0]?.role === 'system' ? result.messages.slice(1) : result.messages; + const next = providerValidPrefix(withoutSystem); + await this.store.save(this.id, next); + this.history = next; + return result; + } +} diff --git a/src/session/index.ts b/src/session/index.ts new file mode 100644 index 000000000..27e917130 --- /dev/null +++ b/src/session/index.ts @@ -0,0 +1,4 @@ +export { AgentSession } from './AgentSession'; +export type { SessionOptions } from './AgentSession'; +export { MemorySessionStore, FileSessionStore } from './sessionStore'; +export type { SessionStore } from './sessionStore'; diff --git a/src/session/session.test.ts b/src/session/session.test.ts new file mode 100644 index 000000000..166583b03 --- /dev/null +++ b/src/session/session.test.ts @@ -0,0 +1,219 @@ +/** + * LOU-W4: sessions (multi-turn conversations) for createAgent(). + */ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createAgent } from '../createAgent'; +import { defineTool } from '../tools/defineTool'; +import { PropagatingToolError } from '../execution/AgentExecutor'; +import { mockModel } from '../testing'; +import { FileSessionStore, MemorySessionStore } from './index'; +import { providerValidPrefix } from './AgentSession'; +import type { Message } from '../providers/llm'; + +const convo = (call: { messages: readonly Message[] } | undefined): readonly Message[] => + (call?.messages ?? []).filter((m) => m.role !== 'system'); +const roles = (messages: readonly { role: string }[]): string[] => messages.map((m) => m.role); + +describe('AgentSession', () => { + it('shows the second turn the first exchange', async () => { + const model = mockModel(['Nice to meet you, Ali.', 'Your name is Ali.']); + const session = createAgent({ prompt: 'Be brief.', provider: model }).session(); + + await session.send('My name is Ali.'); + const second = await session.send('What is my name?'); + + expect(second.text).toBe('Your name is Ali.'); + expect(model.calls[1].messages.map((m) => `${m.role}:${m.content}`)).toEqual([ + 'system:Be brief.', + 'user:My name is Ali.', + 'assistant:Nice to meet you, Ali.', + 'user:What is my name?', + ]); + expect(session.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user', 'assistant']); + expect(session.id).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('exposes a snapshot that cannot corrupt the session', async () => { + const session = createAgent({ provider: mockModel(['a', 'b']) }).session(); + await session.send('1'); + (session.messages as { content: string }[])[0].content = 'tampered'; + expect(session.messages[0].content).toBe('1'); + }); + + it('keeps tool-call turns in a provider-valid order', async () => { + const lookup = defineTool({ + name: 'lookup', + description: 'look up', + input: z.object({ q: z.string() }), + execute: async ({ q }) => `result for ${q}`, + }); + const model = mockModel([{ toolCalls: [{ name: 'lookup', args: { q: 'x' } }] }, 'It is x.', 'Still x.']); + const session = createAgent({ provider: model, tools: [lookup] }).session(); + + await session.send('find x'); + expect(roles(session.messages)).toEqual(['user', 'assistant', 'tool', 'assistant']); + await session.send('again?'); + + const sent = convo(model.calls[2]); + expect(roles(sent)).toEqual(['user', 'assistant', 'tool', 'assistant', 'user']); + expect(sent[1].toolCalls?.[0].id).toBe(sent[2].toolCallId); + }); + + it('serializes concurrent sends so the transcript cannot interleave', async () => { + const model = mockModel([ + { text: 'r1', delayMs: 30 }, + { text: 'r2', delayMs: 1 }, + { text: 'r3', delayMs: 1 }, + ]); + const session = createAgent({ provider: model }).session(); + + const results = await Promise.all([session.send('a'), session.send('b'), session.send('c')]); + + expect(results.map((r) => r.text)).toEqual(['r1', 'r2', 'r3']); + expect(session.messages.map((m) => m.content)).toEqual(['a', 'r1', 'b', 'r2', 'c', 'r3']); + expect(convo(model.calls[2]).map((m) => m.content)).toEqual(['a', 'r1', 'b', 'r2', 'c']); + }); + + it('keeps the queue alive after a failed send', async () => { + const model = mockModel([{ error: new Error('boom') }, 'fine']); + const session = createAgent({ provider: model }).session(); + const failed = session.send('one'); + const ok = session.send('two'); + await expect(failed).rejects.toThrow('boom'); + expect((await ok).text).toBe('fine'); + expect(session.messages.map((m) => m.content)).toEqual(['two', 'fine']); + }); + + it('leaves the transcript unchanged when a tool throws a propagating error', async () => { + const fatal = defineTool({ + name: 'fatal', + description: 'always fails hard', + input: z.object({}), + execute: async () => { + throw new PropagatingToolError('stop everything'); + }, + }); + const model = mockModel(['hello', { toolCalls: [{ name: 'fatal' }] }, 'recovered']); + const session = createAgent({ provider: model, tools: [fatal] }).session(); + + await session.send('hi'); + const before = session.messages; + await expect(session.send('do the fatal thing')).rejects.toThrow('stop everything'); + expect(session.messages).toEqual(before); + + await session.send('are you ok?'); + expect(roles(convo(model.calls[2]))).toEqual(['user', 'assistant', 'user']); + }); + + it('leaves the transcript unchanged when a send is aborted', async () => { + const controller = new AbortController(); + const slow = defineTool({ + name: 'slow', + description: 'slow', + input: z.object({}), + execute: async () => { + controller.abort(); + return 'done'; + }, + }); + const model = mockModel(['hello', { toolCalls: [{ name: 'slow' }, { name: 'slow' }] }, 'next']); + const session = createAgent({ provider: model, tools: [slow] }).session(); + await session.send('hi'); + const before = session.messages; + + const aborted = await session.send('go', { signal: controller.signal }); + + expect(aborted.finishReason).toBe('aborted'); + expect(session.messages).toEqual(before); + await session.send('still there?'); + expect(roles(convo(model.calls[2]))).toEqual(['user', 'assistant', 'user']); + }); + + it('clear() forgets the conversation, including in the store', async () => { + const store = new MemorySessionStore(); + const model = mockModel(['a', 'b']); + const session = createAgent({ provider: model }).session({ id: 'c1', store }); + await session.send('1'); + await session.clear(); + + expect(session.messages).toEqual([]); + expect(await store.load('c1')).toBeUndefined(); + await session.send('2'); + expect(convo(model.calls[1]).map((m) => m.content)).toEqual(['2']); + }); + + it('rejects invalid ids', () => { + const agent = createAgent({ provider: mockModel([]) }); + for (const id of ['../evil', 'a/b', 'a.b', '', 'x'.repeat(129)]) { + expect(() => agent.session({ id })).toThrow(/Invalid session id/); + } + expect(() => agent.session({ id: 'user_42-A' })).not.toThrow(); + }); + + describe('FileSessionStore', () => { + it('round-trips a conversation across two createAgent instances', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sessions-')); + try { + const first = createAgent({ provider: mockModel(['Hi Ali.']) }); + const s1 = first.session({ id: 'ali', store: new FileSessionStore(dir) }); + await s1.send('My name is Ali.'); + + const model = mockModel(['Ali.']); + const s2 = createAgent({ provider: model }).session({ id: 'ali', store: new FileSessionStore(dir) }); + expect(await s2.load()).toHaveLength(2); + await s2.send('What is my name?'); + + expect(convo(model.calls[0]).map((m) => m.content)).toEqual([ + 'My name is Ali.', + 'Hi Ali.', + 'What is my name?', + ]); + expect(readdirSync(dir)).toEqual(['ali.json']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('refuses path traversal ids and treats missing sessions as empty', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sessions-')); + try { + const store = new FileSessionStore(join(dir, 'nested')); + await expect(store.load('../x')).rejects.toThrow(/Invalid session id/); + await expect(store.save('a/b', [])).rejects.toThrow(/Invalid session id/); + await expect(store.delete('..')).rejects.toThrow(/Invalid session id/); + expect(await store.load('missing')).toBeUndefined(); + await store.delete('missing'); + await store.save('ok', [{ role: 'user', content: 'x' }]); + await store.delete('ok'); + expect(await store.load('ok')).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); +}); + +describe('providerValidPrefix', () => { + const call = { id: 'c1', name: 't', arguments: {} }; + it('drops an assistant tool-call turn without its results', () => { + const messages = [ + { role: 'user' as const, content: 'q' }, + { role: 'assistant' as const, content: '', toolCalls: [call] }, + ]; + expect(providerValidPrefix(messages)).toEqual([messages[0]]); + }); + + it('drops a partially answered batch and orphaned tool messages', () => { + const two = [call, { ...call, id: 'c2' }]; + const partial = [ + { role: 'assistant' as const, content: '', toolCalls: two }, + { role: 'tool' as const, content: '"x"', toolCallId: 'c1' }, + ]; + expect(providerValidPrefix(partial)).toEqual([]); + expect(providerValidPrefix([{ role: 'tool', content: '"x"', toolCallId: 'c1' }])).toEqual([]); + }); +}); diff --git a/src/session/sessionStore.ts b/src/session/sessionStore.ts new file mode 100644 index 000000000..744147460 --- /dev/null +++ b/src/session/sessionStore.ts @@ -0,0 +1,131 @@ +/** + * Session stores (LOU-W4): where a session's transcript lives between + * `send()` calls (and, for `FileSessionStore`, between processes). + */ + +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { Message } from '../providers/llm'; + +/** + * Persistence for session transcripts. A transcript is the conversation + * without the system prompt (the agent supplies that on every run). + * + * @example + * ```ts + * const store: SessionStore = new MemorySessionStore(); + * await store.save('chat-1', [{ role: 'user', content: 'hi' }]); + * const messages = await store.load('chat-1'); + * ``` + */ +export interface SessionStore { + /** The saved transcript, or `undefined` when the id was never saved. */ + load(id: string): Promise; + /** Replace the transcript saved under `id`. */ + save(id: string, messages: readonly Message[]): Promise; + /** Remove the transcript saved under `id`; a missing id is not an error. */ + delete(id: string): Promise; +} + +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +/** + * Throws unless `id` is 1-128 characters of letters, digits, `_` or `-`. + * Session ids become file names, so anything else (`../`, `/`, `.`) is refused. + */ +export function assertSessionId(id: string): void { + if (typeof id !== 'string' || !SESSION_ID_PATTERN.test(id)) { + throw new Error( + `Invalid session id ${JSON.stringify(id)}: use 1-128 characters from A-Z, a-z, 0-9, '_' and '-' ` + + "(e.g. 'user-42'). Omit the id to get a generated one." + ); + } +} + +/** + * In-memory store: the default. Transcripts live as long as the process + * (and this store instance). + * + * @example + * ```ts + * const store = new MemorySessionStore(); + * const session = agent.session({ store }); + * ``` + */ +export class MemorySessionStore implements SessionStore { + private readonly sessions = new Map(); + + async load(id: string): Promise { + assertSessionId(id); + const saved = this.sessions.get(id); + return saved && structuredClone(saved); + } + + async save(id: string, messages: readonly Message[]): Promise { + assertSessionId(id); + this.sessions.set(id, structuredClone([...messages])); + } + + async delete(id: string): Promise { + assertSessionId(id); + this.sessions.delete(id); + } +} + +/** + * One JSON file per session (`/.json`), written atomically (temp + * file + rename) so a crash never leaves a half-written transcript. The + * directory is created on first save. + * + * @example + * ```ts + * const store = new FileSessionStore('./.loushy/sessions'); + * const session = agent.session({ id: 'user-42', store }); + * ``` + */ +export class FileSessionStore implements SessionStore { + private readonly dir: string; + + constructor(dir: string) { + this.dir = resolve(dir); + } + + private fileFor(id: string): string { + assertSessionId(id); + return join(this.dir, `${id}.json`); + } + + async load(id: string): Promise { + const file = this.fileFor(id); + let raw: string; + try { + raw = await readFile(file, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error(`Session file ${file} is corrupt: expected a JSON array of messages.`); + } + return parsed as Message[]; + } + + async save(id: string, messages: readonly Message[]): Promise { + const file = this.fileFor(id); + await mkdir(this.dir, { recursive: true }); + const temp = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(temp, JSON.stringify(messages), 'utf8'); + await rename(temp, file); + } catch (error) { + await rm(temp, { force: true }); + throw error; + } + } + + async delete(id: string): Promise { + await rm(this.fileFor(id), { force: true }); + } +}