Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] - 2026-09-28

### Added
- `loushy dev` for agent directories and TS agents, with hot reload (LOU-D31): `loushy dev <path>` takes a spec file (as before), an agent directory (`loadAgentDir()`) or a `.ts`/`.js` module whose default (or `agent`) export is a `SimpleAgent` or a `createAgent()` options object, detected by path type and extension (a bad path, extension or export fails with `LOUSHY_CONFIG_INVALID` / `LOUSHY_SPEC_UNSUPPORTED_FORMAT`). It watches the directory tree, or the module and its relative imports, and rebuilds the agent about 100 ms after a change with a `?t=<time>` cache-busted import, so edited tools reload; the new agent is built and `ready()` before it replaces the old one, which is then `close()`d. A failed rebuild keeps the last good agent and reports the error in the console, in a banner on the chat page and in the new `GET /dev/status` (`{ kind, path, reloads, error }`). `startDevServer(path, port?, host?, { overrides?, debounceMs? })` gains the options argument. Spec-file behaviour is unchanged; the chat is not streamed or per-tab (LOU-D32). See docs/cli.md#loushy-dev and docs/agent-directories.md#run-it-with-loushy-dev.
- Multimodal input through the public API (LOU-V12): `agent.send()` / `agent.stream()`, `session.send()` / `stream()`, `t.send()` in evals and `useLoushyAgent().send()` take the new exported `AgentInput` (`string | ContentPart[] | Message[]`): a string or parts become one user message, a `Message[]` is passed through (in a session it is appended to the transcript). Checkpoints, `agent.resume()` and queued input hold the parts like any message. The React user bubble shows the text with an `[image]` / `[file]` marker per non-text part. `SqliteStore` sessions and checkpoints save `Uint8Array` parts as `{ "$bytes": "<base64>" }`, as `FileSessionStore` does. Finishes LOU-V11 (its note that `agent.send()` / `session.send()` take no parts is out of date). See docs/providers.md#multimodal-input.
- Built-in `ask_question` tool (LOU-X9): `createAgent({ askQuestion: true })` (off by default) or `askQuestionTool()` gives the agent `ask_question({ question, options?, allowFreeText? })`. A call pauses the run through the approval mechanism, so it waits durably like any approval (sessions, `memoryStore()` / `SqliteStore`, a restart between question and answer). The pending record gains optional `kind: 'question'` and `question: { text, options?, allowFreeText? }` (new types `ApprovalKind`, `ApprovalQuestion`; `describeApproval()` and `ASK_QUESTION_TOOL_NAME` are exported), and so do the `approval.requested` event and the React hook's `pendingApproval`. The new `agent.approvals.answer({ id, answer })` (the same as `resolve({ id, approved: true, note: answer })`) continues the run with `{ answer, option? }` as the tool result (`option`: index of the matching option); `approved: false` gives the model a `kind: 'rejected'` error saying the user declined to answer. `useLoushyAgent()` gets `answer(text)`. The `approve` callback may now return a string (`ApproveToolCall` returns `boolean | string`), which approves with that note, so code can answer questions. A tool run after an approval sees the decision's note as `ctx.approval.note` (new optional `ToolExecutionContext.approval`). See docs/approvals.md "Asking the user a question".
- Agent Forge time travel API (LOU-D45): Agent Forge's `FileCheckpointStore` keeps the bounded per-session checkpoint history (`history()`, `historyLimit` option, `delete({ keepHistory })`) with the same semantics as `LocalStorageCheckpointStore`, checked by the SDK's history contract suite. The runtime control server gains `GET /runs/:id/history` (each step's status, save time, finish reason, tool calls, tokens and cost), `POST /runs/:id/fork` (`{ fromStep, patch?: { toolResult?, appendInput?, businessState? } }`, forks with `AgentExecutor.fork()` and starts the fork as a run of its own) and `GET /runs/compare?a=&b=` (`compareTrajectories()`). Fixed on the way: Agent Forge's chat transcript compiles against multimodal `Message.content` (LOU-V11) again, keeping each message's text. See docs/agent-forge.md#time-travel.
Expand Down
4 changes: 2 additions & 2 deletions bin/loushy.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const path = require('node:path');
const USAGE = [
'Usage:',
' loushy init [dir] [--provider P] [--template T] [--yes] [--no-install] [--no-git] [--package-manager PM] [--force]',
' loushy dev <config.yaml|config.json> [--port N] [--host H]',
' loushy dev <spec.yaml|spec.json|agent-dir|agent.ts> [--port N] [--host H]',
' loushy build --target=<name> --agent=<path> [--out=<dir>]',
' loushy studio [--port N] [--host H] [--prod|--dev]',
' loushy mcp <agent.yaml|json> [--http --port N --host H]',
Expand Down Expand Up @@ -36,7 +36,7 @@ async function runDev(rest) {
const host = readFlag(rest, 'host', '127.0.0.1');

if (!configPath) {
console.error('loushy dev: a config file path is required. Usage: loushy dev <config.yaml|config.json> [--port N] [--host H]');
console.error('loushy dev: a path is required (a spec file, an agent directory or a .ts/.js agent module). Usage: loushy dev <spec.yaml|spec.json|agent-dir|agent.ts> [--port N] [--host H]');
process.exitCode = 1;
return;
}
Expand Down
17 changes: 15 additions & 2 deletions docs/agent-directories.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,20 @@ says so. Compile the directory first, or use `.js`/`.mjs` tools with an
`import` syntax needs `"type": "module"` in the nearest `package.json` (or the
`.mjs` extension).

## Run it with `loushy dev`

```bash
npx loushy dev ./my-agent
```

Serves the chat UI and `POST /chat` for the directory and reloads it when
`instructions.md`, the config file, `tools/`, `skills/` or `subagents/` change
(see [`loushy dev`](cli.md#loushy-dev) for the details). A tool file is
imported afresh on each reload, so an edit to `tools/*.ts` takes effect on the
next message. A failed reload (a syntax error, an empty `instructions.md`) is
logged and shown in the chat page, and the previous agent keeps answering.

## What is not covered

`loushy dev` and `loushy mcp` still take an agent spec file
([Configuration](configuration.md)), not a directory.
`loushy mcp` still takes an agent spec file ([Configuration](configuration.md)),
not a directory.
47 changes: 41 additions & 6 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Run it with `npx loushy <command>` inside a project that has the SDK installed.
| ------- | ------------ | ------- |
| `loushy init [dir]` | Scaffold a project: an agent with an example tool, an offline test, `.env.example`; installs dependencies and runs `git init`. | [Installation](./installation.md#scaffolding-a-new-project) |
| `loushy doctor [spec] [--json]` | Check Node, peer packages, provider keys, and optionally a spec file; prints a fix for every problem. | [Installation](./installation.md#troubleshooting-loushy-doctor) |
| `loushy dev <spec>` | Local dev server for a spec file: chat UI, `POST /chat`, hot reload on save. | [Below](#loushy-dev) |
| `loushy dev <path>` | Local dev server for a spec file, an agent directory or a TS agent: chat UI, `POST /chat`, hot reload on save. | [Below](#loushy-dev) |
| `loushy mcp <spec>` | Serve the agent as an MCP server (stdio, or HTTP with `--http`). | [Configuration](./configuration.md#serve-an-agent-over-mcp) |
| `loushy eval [globs...]` | Run `*.eval.ts` files under vitest; print a summary and write JUnit/JSON reports. | [Evals](./evals.md#loushy-eval) |
| `loushy build --target=<t> --agent=<spec>` | Build a deployable Node server, Docker image or Cloudflare Worker. | [Deployment](./deployment.md) |
Expand All @@ -22,7 +22,7 @@ Every command that takes a `<spec>` reads an agent spec file (`.yaml`,

```text
loushy init [dir] [--provider P] [--template T] [--yes] [--no-install] [--no-git] [--package-manager PM] [--force]
loushy dev <config.yaml|config.json> [--port N] [--host H]
loushy dev <spec.yaml|spec.json|agent-dir|agent.ts> [--port N] [--host H]
loushy build --target=<name> --agent=<path> [--out=<dir>]
loushy studio [--port N] [--host H] [--prod|--dev]
loushy mcp <agent.yaml|json> [--http --port N --host H]
Expand All @@ -38,13 +38,48 @@ with `node bin/loushy.js init my-agent --sdk-path .` (see
## `loushy dev`

```bash
npx loushy dev agent.yaml
npx loushy dev agent.yaml # a spec file
npx loushy dev ./my-agent # an agent directory
npx loushy dev src/agent.ts # a TypeScript (or JavaScript) module
npx loushy dev agent.yaml --port 4000 --host 0.0.0.0
```

Serves `GET /` (a chat UI), `GET /health` and `POST /chat`
(`{ "message": "..." }`, 1MB body limit). The agent is reloaded whenever the
spec file changes, and the last good config is kept if an edit is invalid.
Serves `GET /` (a chat UI), `GET /health`, `GET /dev/status` and `POST /chat`
(`{ "message": "..." }`, 1MB body limit). What `<path>` is follows from the path:

| Path | Loaded with |
| ---- | ----------- |
| `.yaml`, `.yml`, `.json` | `loadSpec()` + `specToAgent()` ([Configuration](./configuration.md)) |
| a directory | `loadAgentDir()` ([Agent directories](./agent-directories.md)) |
| `.ts`, `.mts`, `.js`, `.mjs`, `.cjs` | the module's default export, or its `agent` export: a `SimpleAgent` (what `createAgent()` returns) or a `createAgent()` options object |

Any other extension, a missing path, or a module with no agent export fails
with a coded error (`LOUSHY_SPEC_UNSUPPORTED_FORMAT`, `LOUSHY_CONFIG_INVALID`)
and its fix. A `.ts` module is imported by the running Node (22.19 or later
strips types, so relative imports need their file extension); use `.js` for
code that needs a transpiler.

**Hot reload.** The agent is rebuilt a moment (100 ms) after a file changes,
without restarting the server or dropping the port:

- a spec: the spec file;
- a directory: everything under it (`instructions.md`, the config file,
`tools/`, `skills/`, `subagents/`), except `node_modules` and `.git`;
- a module: the file and the local files it imports (relative `import` /
`require` specifiers, found once at startup; no bundler).

Directory and module files are imported with a `?t=<time>` cache-busting query,
so an edited tool or agent file is re-evaluated. A file *imported by* that file
(a shared helper) stays cached by Node: restart `loushy dev` after editing one.
The console logs what reloaded. The new agent is built (and `ready()`, so MCP
servers connect) before it replaces the old one, and the old one is then
`close()`d. If the rebuild fails, the last good agent keeps answering, the error
is logged and shown in a banner on the chat page (and as `error` in
`GET /dev/status`); the next good edit clears it.

Directories and modules are your code and run with your permissions; only run
`loushy dev` on ones you trust. `POST /chat` talks to one shared agent, not one
conversation per browser tab.

- `--port` - default `3737`.
- `--host` - default `127.0.0.1` (localhost only). Pass e.g. `--host=0.0.0.0`
Expand Down
64 changes: 56 additions & 8 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1584,10 +1584,23 @@ says so. Compile the directory first, or use `.js`/`.mjs` tools with an
`import` syntax needs `"type": "module"` in the nearest `package.json` (or the
`.mjs` extension).

## Run it with `loushy dev`

```bash
npx loushy dev ./my-agent
```

Serves the chat UI and `POST /chat` for the directory and reloads it when
`instructions.md`, the config file, `tools/`, `skills/` or `subagents/` change
(see [`loushy dev`](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/cli.md#loushy-dev) for the details). A tool file is
imported afresh on each reload, so an edit to `tools/*.ts` takes effect on the
next message. A failed reload (a syntax error, an empty `instructions.md`) is
logged and shown in the chat page, and the previous agent keeps answering.

## What is not covered

`loushy dev` and `loushy mcp` still take an agent spec file
([Configuration](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md)), not a directory.
`loushy mcp` still takes an agent spec file ([Configuration](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md)),
not a directory.

# Agent Forge

Expand Down Expand Up @@ -2138,7 +2151,7 @@ Run it with `npx loushy <command>` inside a project that has the SDK installed.
| ------- | ------------ | ------- |
| `loushy init [dir]` | Scaffold a project: an agent with an example tool, an offline test, `.env.example`; installs dependencies and runs `git init`. | [Installation](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/installation.md#scaffolding-a-new-project) |
| `loushy doctor [spec] [--json]` | Check Node, peer packages, provider keys, and optionally a spec file; prints a fix for every problem. | [Installation](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/installation.md#troubleshooting-loushy-doctor) |
| `loushy dev <spec>` | Local dev server for a spec file: chat UI, `POST /chat`, hot reload on save. | [Below](#loushy-dev) |
| `loushy dev <path>` | Local dev server for a spec file, an agent directory or a TS agent: chat UI, `POST /chat`, hot reload on save. | [Below](#loushy-dev) |
| `loushy mcp <spec>` | Serve the agent as an MCP server (stdio, or HTTP with `--http`). | [Configuration](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md#serve-an-agent-over-mcp) |
| `loushy eval [globs...]` | Run `*.eval.ts` files under vitest; print a summary and write JUnit/JSON reports. | [Evals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/evals.md#loushy-eval) |
| `loushy build --target=<t> --agent=<spec>` | Build a deployable Node server, Docker image or Cloudflare Worker. | [Deployment](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/deployment.md) |
Expand All @@ -2151,7 +2164,7 @@ Every command that takes a `<spec>` reads an agent spec file (`.yaml`,

```text
loushy init [dir] [--provider P] [--template T] [--yes] [--no-install] [--no-git] [--package-manager PM] [--force]
loushy dev <config.yaml|config.json> [--port N] [--host H]
loushy dev <spec.yaml|spec.json|agent-dir|agent.ts> [--port N] [--host H]
loushy build --target=<name> --agent=<path> [--out=<dir>]
loushy studio [--port N] [--host H] [--prod|--dev]
loushy mcp <agent.yaml|json> [--http --port N --host H]
Expand All @@ -2167,13 +2180,48 @@ with `node bin/loushy.js init my-agent --sdk-path .` (see
## `loushy dev`

```bash
npx loushy dev agent.yaml
npx loushy dev agent.yaml # a spec file
npx loushy dev ./my-agent # an agent directory
npx loushy dev src/agent.ts # a TypeScript (or JavaScript) module
npx loushy dev agent.yaml --port 4000 --host 0.0.0.0
```

Serves `GET /` (a chat UI), `GET /health` and `POST /chat`
(`{ "message": "..." }`, 1MB body limit). The agent is reloaded whenever the
spec file changes, and the last good config is kept if an edit is invalid.
Serves `GET /` (a chat UI), `GET /health`, `GET /dev/status` and `POST /chat`
(`{ "message": "..." }`, 1MB body limit). What `<path>` is follows from the path:

| Path | Loaded with |
| ---- | ----------- |
| `.yaml`, `.yml`, `.json` | `loadSpec()` + `specToAgent()` ([Configuration](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md)) |
| a directory | `loadAgentDir()` ([Agent directories](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-directories.md)) |
| `.ts`, `.mts`, `.js`, `.mjs`, `.cjs` | the module's default export, or its `agent` export: a `SimpleAgent` (what `createAgent()` returns) or a `createAgent()` options object |

Any other extension, a missing path, or a module with no agent export fails
with a coded error (`LOUSHY_SPEC_UNSUPPORTED_FORMAT`, `LOUSHY_CONFIG_INVALID`)
and its fix. A `.ts` module is imported by the running Node (22.19 or later
strips types, so relative imports need their file extension); use `.js` for
code that needs a transpiler.

**Hot reload.** The agent is rebuilt a moment (100 ms) after a file changes,
without restarting the server or dropping the port:

- a spec: the spec file;
- a directory: everything under it (`instructions.md`, the config file,
`tools/`, `skills/`, `subagents/`), except `node_modules` and `.git`;
- a module: the file and the local files it imports (relative `import` /
`require` specifiers, found once at startup; no bundler).

Directory and module files are imported with a `?t=<time>` cache-busting query,
so an edited tool or agent file is re-evaluated. A file *imported by* that file
(a shared helper) stays cached by Node: restart `loushy dev` after editing one.
The console logs what reloaded. The new agent is built (and `ready()`, so MCP
servers connect) before it replaces the old one, and the old one is then
`close()`d. If the rebuild fails, the last good agent keeps answering, the error
is logged and shown in a banner on the chat page (and as `error` in
`GET /dev/status`); the next good edit clears it.

Directories and modules are your code and run with your permissions; only run
`loushy dev` on ones you trust. `POST /chat` talks to one shared agent, not one
conversation per browser tab.

- `--port` - default `3737`.
- `--host` - default `127.0.0.1` (localhost only). Pass e.g. `--host=0.0.0.0`
Expand Down
17 changes: 16 additions & 1 deletion src/agentDir/importModule.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { pathToFileURL } from 'node:url';

const cacheBust = new AsyncLocalStorage<string>();

/**
* Runs `fn` so every file `importModule()` loads inside it is imported with a
* `?t=<token>` query: a fresh module instance instead of the cached one
* (`loushy dev` hot reload, LOU-D31). Only the file itself is re-evaluated;
* modules it imports stay cached by the runtime.
*/
export function withFreshImports<T>(token: string, fn: () => Promise<T>): Promise<T> {
return cacheBust.run(token, fn);
}

/** Node error codes that mean "this process cannot load a TypeScript file". */
const NO_TS_LOADER_CODES = new Set(['ERR_UNKNOWN_FILE_EXTENSION', 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX']);

Expand Down Expand Up @@ -29,7 +42,9 @@ export function explainImportError(file: string, error: unknown): Error {
/** Dynamically imports a user file, with path-bearing errors (see {@link explainImportError}). */
export async function importModule(file: string): Promise<Record<string, unknown>> {
try {
return (await import(pathToFileURL(file).href)) as Record<string, unknown>;
const token = cacheBust.getStore();
const href = pathToFileURL(file).href;
return (await import(token === undefined ? href : `${href}?t=${token}`)) as Record<string, unknown>;
} catch (error) {
throw explainImportError(file, error);
}
Expand Down
1 change: 1 addition & 0 deletions src/cli/__fixtures__/dev-agent/agent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "dev-fixture", "description": "loushy dev fixture" }
1 change: 1 addition & 0 deletions src/cli/__fixtures__/dev-agent/instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
You are the dev fixture agent.
9 changes: 9 additions & 0 deletions src/cli/__fixtures__/dev-agent/tools/ping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { z } from 'zod';
import { defineTool } from '../../../../tools/defineTool';

export default defineTool({
name: 'ping',
description: 'Reply with pong',
input: z.object({}),
execute: () => 'pong',
});
9 changes: 9 additions & 0 deletions src/cli/__fixtures__/dev-module/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createAgent } from '../../../createAgent';
import { mockModel } from '../../../testing';
import { GREETING } from './greeting';

export default createAgent({
name: 'dev-module',
instructions: GREETING,
provider: mockModel(['module says hi', 'module says hi']),
});
1 change: 1 addition & 0 deletions src/cli/__fixtures__/dev-module/bad.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default { not: 'an agent' };
1 change: 1 addition & 0 deletions src/cli/__fixtures__/dev-module/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const agent = { name: 'dev-config', instructions: 'You are the config-export agent.' };
1 change: 1 addition & 0 deletions src/cli/__fixtures__/dev-module/greeting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const GREETING = 'You are the dev module agent.';
Loading
Loading