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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ opt-out.
### Fixed
- The file tools' `deny` list (`.git` by default) is now also checked where a path really leads: a symlink or Windows junction inside the workspace pointing at `.git` or into it (`gitj -> .git`) let `edit_file gitj/config` rewrite `.git/config` (for example `core.hooksPath`, which runs code on the next git command) and `write_file gitj/PWNED` create files in `.git`. Reads, writes, edits, `list_dir`, and `glob`/`grep` given such a link as `path` are now refused. Providers with links can implement the new optional `FsProvider.realPath(path)`; `NodeWorkspace` does. See [Workspace tools](docs/workspace-tools.md#security-model).
- `NodeWorkspace.writeFile` (and so `write_file` / `edit_file`) writes a temp file in the same directory and renames it into place, so a process killed mid-write no longer leaves a truncated file. A replaced file keeps its permission bits; a read-only file is still refused.
- `WorkspaceCheckpoints` stores a file's backup before `write_file` / `edit_file` writes it, not after. A process killed between the write and the backup lost the original: on resume the re-run call backed up the agent's own content, so `rewind()` reported `restored` but put back the agent's version (or, for `edit_file`, recorded nothing). A re-run now keeps the turn's first backup, and rewind treats a file still holding its backed-up content as nothing to undo. `track()` also accepts a `PreparedWorkspaceWrite` (`{ prepare, write }`), which the built-in tools use.
- `edit_file` re-run after a crash (the edit already in the file: `old_string` gone, a non-empty `new_string` present, also with `replace_all`) now succeeds with "the edit was already applied" and writes nothing, instead of failing with `old_string was not found` and sending the model into a loop. A `write_file` / `edit_file` that leaves the file unchanged no longer records a checkpoint backup.
- [Sessions](docs/sessions.md) no longer says to call `resume()` before resolving an approval after a restart: `agent.approvals.resolve()` / `streamResolve()` from a fresh process finds the session itself. It and [Build a coding agent](docs/build-a-coding-agent.md) now say how to show a resumed turn's progress (`session.on()`, `onEvent`), and the guide points long coding turns at compaction.
- MCP tool schemas with OpenAPI's `nullable: true` (any type) now accept `null`; the model's valid `null` was rejected with `ToolArgumentsValidationError`.
- A failed MCP tool call (server exited, timeout, protocol error) now rejects with `LOUSHO_MCP_CALL_FAILED`, naming `<server>__<tool>` with the MCP error as `cause`, instead of a bare `MCP error -32000: Connection closed`. [MCP](docs/mcp.md) now says stdio `env` is merged onto the MCP SDK's default allow-list, not `process.env`.
- Flows (consumer harness): a `{{variable}}` holding an object (a `toolCall` result, an MCP result) no longer reaches a prompt as `[object Object]`: an object whose only field is `text` (an MCP tool result) is that text, a raw MCP `{ content }` result its text parts, and any other object or array its JSON. Placeholders take dotted paths (`{{res.user.name}}`, `{{list.0}}`, own properties only), also as whole tool-argument values. An `evaluator` step stores its result in `outputVariable`. See [Template values](docs/flows.md#template-values).
Expand Down
10 changes: 9 additions & 1 deletion api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4758,6 +4758,14 @@ export function prepareCompaction(messages: Message[], options: CompactMessagesO
input: CompactionInput;
};

// @public
export interface PreparedWorkspaceWrite {
// (undocumented)
prepare(): Promise<string>;
// (undocumented)
write(content: string): Promise<void>;
}

// @public
export interface PreToolCallDecision {
inputBy: string[];
Expand Down Expand Up @@ -7177,7 +7185,7 @@ export class WorkspaceCheckpoints {
dryRun?: boolean;
force?: boolean;
}): Promise<RewindResult>;
track(ctx: ToolExecutionContext | undefined, path: string, write: () => Promise<string>): Promise<void>;
track(ctx: ToolExecutionContext | undefined, path: string, write: (() => Promise<string>) | PreparedWorkspaceWrite): Promise<void>;
}

// @public
Expand Down
10 changes: 9 additions & 1 deletion api/tools.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1877,6 +1877,14 @@ type PermissionToolMatcher = string | readonly string[] | RegExp;
// @public
type PiiType = 'email' | 'phone' | 'credit-card' | 'iban' | 'us-ssn' | 'ip-address';

// @public
export interface PreparedWorkspaceWrite {
// (undocumented)
prepare(): Promise<string>;
// (undocumented)
write(content: string): Promise<void>;
}

// @public
interface Principal {
authenticator: string;
Expand Down Expand Up @@ -2869,7 +2877,7 @@ export class WorkspaceCheckpoints {
dryRun?: boolean;
force?: boolean;
}): Promise<RewindResult>;
track(ctx: ToolExecutionContext | undefined, path: string, write: () => Promise<string>): Promise<void>;
track(ctx: ToolExecutionContext | undefined, path: string, write: (() => Promise<string>) | PreparedWorkspaceWrite): Promise<void>;
}

// @public
Expand Down
28 changes: 28 additions & 0 deletions docs/build-a-coding-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,34 @@ after each answer the program asks for the next line, and an empty line or
keep a conversation across runs, give the agent a store (see
[Choosing a store](./sessions.md#choosing-a-store)).

### Long turns and restarts

- **Context.** A coding turn reads files, runs tests and edits over many
steps; every tool result stays in the transcript, so a 40-step turn can
outgrow a 128k-token window before it finishes. Pass
`createAgent({ compaction: true })` (or a summarizer, see
[Context compaction](./compaction.md)): it compacts before each model call,
between the steps of one turn too.
- **Crashes.** With a durable store, a turn killed mid-way is finished by the
next `session.send()` or by `session.resume()`. A tool call that was running
when the process died runs again ([at-least-once](./durable-execution.md#at-least-once-tools-make-side-effects-idempotent)).
The built-in `write_file` and `edit_file` are safe to re-run: `write_file`
writes the same content again, and an `edit_file` whose edit is already in
the file (`old_string` gone, `new_string` present) succeeds with "the edit
was already applied" instead of failing. With
[`WorkspaceCheckpoints`](./workspace-tools.md#the-tools), each change's backup is stored
before the file is written, and a re-run keeps the first backup, so
`rewind()` still restores the original after a crash.
- **Progress while resuming.** `session.resume()` returns a result, not a
stream, and `session.stream()` does not stream the pending turn it finishes
before the new message. To show progress, register
`session.on((event) => ...)` before calling it, or pass
`createAgent({ onEvent })`: both get the resumed turn's `AgentEvent`s
(`tool.start`, `text.delta`, ..., `run.done`) as they happen.
- **Approvals after a restart.** `agent.approvals.streamResolve({ id, approved })`
(or `resolve()`) from a fresh process, given the same durable store,
continues the paused turn in its session; no `session.resume()` first.

## 6. Test it without a model

The agent needs no network to test. A `MemoryWorkspace` keeps the files in
Expand Down
10 changes: 10 additions & 0 deletions docs/durable-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,16 @@ const chargeCard = defineTool({
(`toolCallId` is set for every tool run, including tools that route through
`sandboxExecute`.)

The built-in file tools are safe to re-run. `write_file` writes the same
content again; an `edit_file` whose edit is already in the file (`old_string`
gone, a non-empty `new_string` present) succeeds and says the edit was already
applied, instead of telling the model that an applied edit failed. With
`WorkspaceCheckpoints`, each change's backup is stored before the file is
written and a re-run keeps the first backup, so `rewind()` restores the
original content after a crash too (see
[Workspace tools](./workspace-tools.md#the-tools)). The `shell` tool is not:
a command that was running when the process died runs again.

`toolCallId` (and `ctx.approval.id` for an approved call) identifies one call
the model made. It only protects against that call running twice. If the
process dies before the call is recorded, the model may make the call again
Expand Down
13 changes: 10 additions & 3 deletions docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,16 @@ console.log(finished?.text, await agent.session({ id: 'user-42' }).pending()); /
the transcript, until it finishes. While it waits, `resume()`, `send()` and
`stream()` throw `SessionAwaitingApprovalError` (with its `approvalId`), and
`agent.approvals.resolve({ id, approved })` continues the turn in this
session. After a restart, open the session and call `resume()` (or `send()`)
once before resolving, so the agent knows which session the approval belongs
to; give both agents the same durable `store` (or `approvalStore`).
session. After a restart, `agent.approvals.resolve()` or `streamResolve()`
from the new process continues it too: the agent finds the session from the
pause record (for session ids of letters, digits, `_` and `-`), so there is
no need to open the session or call `resume()` first. Give both agents the
same durable `store` (or `approvalStore`).
- `resume()` returns the finished turn's result; it does not stream, and
`stream()` does not stream the pending turn it finishes first either. To
show a resumed turn's progress, register `session.on(listener)` before the
call (or pass `createAgent({ onEvent })`): it gets the turn's `AgentEvent`s
as they happen.
- A paused turn locks the session id: the session records it under
`<id>.paused` in the checkpoint store, so `agent.send(message, { sessionId: id })`,
`agent.stream()` with that `sessionId`, and a session object over a different
Expand Down
13 changes: 12 additions & 1 deletion docs/workspace-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const paused = await AgentExecutor.execute({ agent, input, provider, toolRegistr
| ------------ | ------------------------------------------------- | ------- |
| `read_file` | `path`, optional `offset` (1-based line), `limit` | Numbered lines (`" 12\tcode"`). When the output is cut, the last line says which lines were shown and the `offset` to continue from. Files over 10 MB are refused. |
| `write_file` | `path`, `content` | Creates or overwrites the file and creates missing parent directories. |
| `edit_file` | `path`, `old_string`, `new_string`, optional `replace_all` | Replaces the exact text. Fails with a message the model can act on when `old_string` is missing (including a hint about CRLF line endings) or appears more than once without `replace_all`. Refuses a file that is not UTF-8 (latin1, Windows-1252, UTF-16) and leaves it unchanged, when the provider has `readFileBytes` (as `NodeWorkspace` does). A UTF-8 BOM is kept. |
| `edit_file` | `path`, `old_string`, `new_string`, optional `replace_all` | Replaces the exact text. Fails with a message the model can act on when `old_string` is missing (including a hint about CRLF line endings) or appears more than once without `replace_all`. When `old_string` is missing but a non-empty `new_string` is in the file, the edit was already applied (a call re-run after a crash): it succeeds without writing and says so. Refuses a file that is not UTF-8 (latin1, Windows-1252, UTF-16) and leaves it unchanged, when the provider has `readFileBytes` (as `NodeWorkspace` does). A UTF-8 BOM is kept. |
| `list_dir` | optional `path` | Sorted entries; directories end with `/`. |
| `glob` | `pattern`, optional `path` | Sorted paths of matching files. `*` stays within one directory, `**` crosses directories, plus `?`, `[abc]` and `{a,b}`. |
| `grep` | `pattern` (JavaScript regex), optional `path`, `glob`, `ignore_case` | `path:line: text` for each matching line. An invalid regex, or one with nested repetition like `(a+)+`, is a tool error. Binary files and files over 2 MB are skipped. |
Expand Down Expand Up @@ -186,6 +186,17 @@ well, fork the session with `session.fork({ fromStep })` (see
`session.history()` is the turn number used here), or a single run with
`agent.fork()` (see [Fork and replay](./durable-execution.md#fork-and-replay)).

Backups survive a crash. The backup is stored before the file is written, so
a process killed between the two loses nothing: the file still holds the
backed-up content, and rewind treats it as nothing to undo. A tool call that
was running when the process died runs again on resume (see
[At-least-once tools](./durable-execution.md#at-least-once-tools-make-side-effects-idempotent));
the re-run finds the turn's backup of that path and keeps it, so `rewind()`
restores the content from before the first attempt, not the agent's own
half-finished write. A `write_file` or `edit_file` that would leave the file
as it is (the same content, an edit already applied) records and writes
nothing.

What is not tracked: changes made by the `shell` tool, files written by a
sub-agent with its own file tools (unless it gets the same
`WorkspaceCheckpoints`, in which case they are recorded under its own
Expand Down
Loading
Loading