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
9 changes: 9 additions & 0 deletions changelog.d/writable-direct-mounts.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- **A direct workspace mount can be written:** the workspace no longer refuses a mount with `backend: 'direct'` whose `mode` isn't `'read-only'`, and one that names no `mode` is writable, as any mount with none is. Its `write`, `edit`, `edit_many` and `delete` land on disk when they answer, or don't happen, and none of its files go into the store. The settings a direct mount refuses are otherwise as before.
- **A write is whole:** the new bytes are staged beside the file, in `.workspace-staged-<id>`, at the file's mode, special bits included as far as the kernel lets the process set them, and renamed over it, so a reader beside the agent never sees half a file, an edited `0755` script stays `0755`, and a process killed mid-write leaves the old file whole. A process that dies between staging and its rename leaves the staged file, which `ls` and `git status` show by that name and nothing sweeps; so does a refused write whose removal of its staged file fails, or whose directory another writer moved meanwhile. A new file takes `0666` less the process's umask, as a shell's would. The file's owner and group become the process's and the ones its directory gives a new file, and an ACL or extended attribute on it isn't carried over, as with any write that renames over a file. A file the process may not write is refused, as a store-backed push's write is, and so is a file in a directory it may not write, where the staged file is made. A file with other hard links gets a file of its own at its path, the other links keep the old text, and the answer's `note` says so. Nothing is synced, so a power loss or a kernel crash can lose a write its answer said had landed.
- **An edit is planned against disk's text as it is now,** by the store-backed kind's planner, and its answer is a store-backed edit's, changed region included. Just before the rename, the file is checked unchanged since the edit read it, by its bytes, read again, and by its device, inode, size, mode, owner, group and change times. A file another writer changed meanwhile is read and planned again, three attempts in all, so the edit lands on top of that change, and it's refused where its match is gone or now occurs a different number of times, where a file changed at every attempt, or where the next read refuses it as any read would, with nothing written. The answer's `note` says when a file was read again. The bytes are compared because a kernel that stamps files from a coarse clock, as Linux before 6.13 does, can leave a same-size change made within one tick unstamped.
- **`edit_many`** checks every edit in every file, stages every file and checks each unchanged before it renames any, so a full disk while staging, or another writer's change, is met before any file is replaced. If a rename then fails partway, the files before it are written and the rest are as they were, the answer names each file's outcome, and nothing puts the written ones back. A refusal says the call wrote none of its edits. Two paths that reach one file, through a link or as hard links, are refused, so the file is named one way.
- **`delete`** unlinks the file through the mount's boundary, as a store-backed push's deletion does, a followed link's entry included. One that fails after reaching the file says the file may be gone.
- **Links:** on a mount that doesn't follow links, a write, edit or deletion at a link is refused. On one that follows them, a write or edit goes to the file the link leads to, where that stays inside the mount, and the link stays.
- **After an undo or another move of the store's head,** the files stay as the departed turns left them. At the agent's next start, opening, continuing or resuming, the framework stores one notice in its history, before that start's compile, for each mount the agent wrote direct whose latest such write its history no longer holds, whether the mount runs direct now or as `'chronicle'`: "Writes to `<mount>` from turns the history no longer holds are still on disk. If it's a git checkout, `git status` and `git diff` show them; a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds." Each agent is told only of its own writes. Whether the notice was given is judged by its own presence in the history: an undo that takes it away gives it again, a redo back to the latest write needs none, and going back to a branch told earlier, after another branch's notice, gives it again. The latest write's position and the notice are saved at a clean stop, in a global record the store keeps whatever branch it's on, with the mount's configured root, so a restart on another branch, such as an offline rollback's, still gives the notice, a mount left out of one session's config keeps its positions for the next, and a mount whose path changed gets none at its new path, while its old path's are kept for when it's back; a crash loses the positions of writes made since the last one, and a later move away from those writes isn't told; it can also repeat a notice given since then. A record the store can't read leaves a session with no positions to restore, and the record as it was; one that reads but isn't of the shape saved is replaced at that session's stop. A head that moves away under a running turn and comes back before the agent's next start loses the notice for the writes made while it was away.
- **Tool descriptions:** `write`, `edit`, `edit_many` and `delete` gain a sentence about the direct kind only on a host with a writable direct mount. `ls` without a path, and `status`, give such a mount's `mode` as `'read-write'` where its config names none.
- **The framework's own files pass over a mount configured direct:** tool-result spill files, saved MCP payloads and fetched references go to the first writable mount without an include that isn't direct, so none lands in a checkout, where `git status` would show it and an undo would leave it. A host whose only writable mount is direct gets no spill file and no stored reference, and the truncation note, and the stub for an MCP payload it can't show, say no mount that keeps its files in the store can hold it. `getMounts()` gives `backend: 'direct'` for a mount configured direct, whether it runs direct this session or, while the store holds work there that disk lacks, as `'chronicle'`. While a mount configured direct runs as `'chronicle'`, it takes no `write`, `edit`, `edit_many`, `delete` or `writeBinary`, which would stay in the store: each is refused, saying why and that a `sync` of a held path or a `materialize` naming the mount settles what's held, after which a start runs it direct. Its start alert says the same, and no longer says to configure it `'chronicle'` to materialize it, and `getMounts()` gives it as `'read-only'` for that session.
8 changes: 5 additions & 3 deletions docs/tool-result-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ exchange, its unsubmitted payload is not admitted to memory.
Each withheld result's original is written, as the result was stored (its
text; for a result over the inline cap, its capped text, whose own note names
the file with the whole output), to a file under the `tool-results/`
directory of the first writable mount without an include, named
directory of the first writable mount without an include that isn't
configured direct (a direct mount's directory is a checkout, whether it runs
direct or as 'chronicle'), named
`<date>-withheld-<batch>-<index>-<toolUseId>.txt` with any character outside
`A-Za-z0-9._-` replaced and the name cut to 80 characters. Its stub names it:

Expand All @@ -168,7 +170,7 @@ directory of the first writable mount without an include, named

Otherwise the stub names the guard's audit record, for an operator:

- with no writable mount without an include, or once the framework is
- with no writable mount without an include that isn't configured direct, or once the framework is
stopping: "Its result is kept in the guard's audit record
(framework/tool-result-guard), for an operator.";
- when the write failed: "Writing its result to workspace file … failed, so it
Expand Down Expand Up @@ -224,7 +226,7 @@ Two audit records describe it, each with `batchId`, `messageId` and
`{ toolUseId, path }` is a written file; with `error`, a failed write; with
`unfinished: true`, a write still under way at the deadline. `{ toolUseId,
error }` is a write that threw. `{ toolUseId, skipped }` is no write:
`no_workspace_file` (no writable mount without an include, or the framework
`no_workspace_file` (no writable mount without an include that isn't configured direct, or the framework
was stopping), `workspace_unanswered` (an earlier write was still under way past its
deadline), `batch_deadline` (the batch had no time left to start another write), or `branch_changed` (the workspace declined the write before its tree entry was set).

Expand Down
108 changes: 97 additions & 11 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5426,6 +5426,16 @@ export class AgentFramework {
}
this.resetCompletedStop('addModule()');
await this.moduleRegistry.addModule(module);
// The workspace judges whether an agent's history holds a message as the
// framework judges its own notices, for its head-move notice
// (noticeHeadMoves) and the writes it weighs against it. A module taken
// for the workspace by its writeBinary (getWorkspaceModule) may have no
// such call, as a host's own or a test's stand-in hasn't.
const workspace = this.getWorkspaceModule();
if (workspace === module && typeof workspace.useHistory === 'function') {
workspace.useHistory((agentName, messageId) =>
this.agents.get(agentName)?.getContextManager().getMessage(messageId as MessageId) != null);
}
this.emitTrace({ type: 'module:added', moduleName: module.name });
}

Expand Down Expand Up @@ -5795,7 +5805,7 @@ export class AgentFramework {
}
const mount = this.firstWritableMountName(workspace);
if (!mount) {
console.error(`[refs] couldn't store ${fileName}: no writable workspace mount without an include`);
console.error(`[refs] couldn't store ${fileName}: ${this.noFileMountText()}`);
return null;
}
const path = `${mount}/refs/${fileName}`;
Expand Down Expand Up @@ -14952,6 +14962,48 @@ export class AgentFramework {
}
}

/**
* Turn start, every one: where the agent's history holds neither its
* latest direct write to a mount nor the notice of its departure
* from it, as after an undo or another move of the store's head away from
* that write, the workspace's notice that those writes are still on disk
* goes into the history, privately, before the compile, as the drafts
* notice does, and the workspace keeps its id (headMoveNotice). On an
* unmoved head it finds nothing due and stores nothing.
*/
private noticeHeadMoves(agent: Agent): void {
const workspace = this.getWorkspaceModule();
// A module taken for the workspace by its writeBinary may have no such
// check (addModule says why), and then nothing is due.
if (!workspace || typeof workspace.headMoveNotice !== 'function' || typeof workspace.headMoveNoticeStored !== 'function') return;
let due: { text: string; mounts: string[] } | null;
try {
due = workspace.headMoveNotice(agent.name);
} catch (err) {
console.error('[workspace] head-move check failed:', err);
return;
}
if (!due) return;
let mid: string;
try {
mid = agent.getContextManager().addMessage(
'user',
[{ type: 'text', text: due.text }],
{ system: true, kind: 'workspace-head-move', mounts: due.mounts } as MessageMetadata,
);
} catch (err) {
console.error('[workspace] head-move notice failed:', err);
return;
}
// Recorded as soon as it's stored, so nothing after can leave it stored and unrecorded.
workspace.headMoveNoticeStored(agent.name, due.mounts, mid);
try {
this.emitTrace({ type: 'message:added', messageId: mid, source: 'workspace-head-move' });
} catch (err) {
console.error('[workspace] head-move notice trace failed:', err);
}
}

/**
* Logical turn end, after the turn's deferred notices are flushed: record
* as noticed exactly the drafts a stored message names (the receipt, a
Expand Down Expand Up @@ -17379,6 +17431,10 @@ export class AgentFramework {
}
this.midTurnInputSignals.delete(agent.name);
}
// At every start, opening, continuing or resuming: a head can move under
// a running turn without ending it, so the notice that writes the history
// no longer holds are still on disk goes in before this start's compile.
this.noticeHeadMoves(agent);

if (opensTurn) {
// The turn's trigger channel (item-3 redux): the channel of a
Expand Down Expand Up @@ -20499,22 +20555,47 @@ export class AgentFramework {
}

/**
* The first read-write mount without an include, or null. The framework
* keeps its own files there (`tool-results/`, `refs/`). A mount with an
* include holds only the files it names, so it would refuse them, and a
* setup whose only writable mount has one is treated as having none.
* The first read-write mount without an include that isn't configured
* direct, or null. The framework keeps its own files there
* (`tool-results/`, `refs/`). A mount with an include holds only the files
* it names, so it would refuse them, and a setup whose only writable mount
* has one, or is direct, is treated as having none.
*/
private firstWritableMountName(workspace: WorkspaceModule): string | null {
const mounts = (workspace as unknown as {
getMounts?: () => Array<{ name: string; mode: string; include?: readonly string[] }>;
getMounts?: () => Array<{ name: string; mode: string; include?: readonly string[]; backend?: 'direct' }>;
}).getMounts?.();
if (mounts) {
const rw = mounts.find((m) => m.mode !== 'read-only' && m.include === undefined);
// Not a mount configured direct, whether it runs direct or as
// 'chronicle' this session: it's a directory agents write directly
// when it runs direct, so these, the framework's files about the
// agent's calls, would stay there after an undo, and in a checkout, as
// a direct mount often is, `git status` would show them.
const rw = mounts.find((m) => m.mode !== 'read-only' && m.include === undefined && m.backend !== 'direct');
return rw?.name ?? null;
}
return null;
}

/**
* Why firstWritableMountName found no mount, for the words that say so:
* none writable without an include, or only mounts configured direct, which
* it passes over.
*/
private noFileMountText(): string {
return this.directPassedOver()
? 'no writable workspace mount without an include that keeps its files in the store'
: 'no writable workspace mount without an include';
}

/** Whether firstWritableMountName passed over a writable mount without an include because it's configured direct. */
private directPassedOver(): boolean {
const mounts = (this.getWorkspaceModule() as unknown as {
getMounts?: () => Array<{ mode: string; include?: readonly string[]; backend?: 'direct' }>;
} | null)?.getMounts?.();
return mounts?.some((m) => m.mode !== 'read-only' && m.include === undefined && m.backend === 'direct') ?? false;
}

/**
* A script's word to its agent: a wake_agent() payload, or the ending of a
* left script that failed, crashed or was stopped at its limit. It enters
Expand Down Expand Up @@ -20676,7 +20757,7 @@ export class AgentFramework {
}

/** Where a tool result labelled `label` is written: `<first writable
* mount without an include>/tool-results/<label>.txt` (see
* mount without an include, not configured direct>/tool-results/<label>.txt` (see
* firstWritableMountName), or null when there is no such mount. */
private toolResultFilePlace(label: string): { workspace: WorkspaceModule; path: string; safeLabel: string } | null {
const workspace = this.getWorkspaceModule();
Expand Down Expand Up @@ -20891,7 +20972,7 @@ export class AgentFramework {
return `\n\n[truncated — showing ${headLength} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
+ ` (${written.error}); content over the cap was not retained]`;
}
return '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace mount without an include, full content not retained]';
return '\n\n[truncated — original was ' + content.length + ' chars; ' + this.noFileMountText() + ', full content not retained]';
}

/**
Expand Down Expand Up @@ -27456,7 +27537,12 @@ export class AgentFramework {
return null;
}
: null;
return normalizeStandardToolResult(result, label, save, mcplToolNoWordsText(serverId, callName));
// Where the only mount that could hold it is direct, the stub says so,
// rather than that no workspace is mounted (noFileMountText).
const noSave = workspace && !mount && this.directPassedOver()
? 'no workspace mount that keeps its files in the store can save it'
: undefined;
return normalizeStandardToolResult(result, label, save, mcplToolNoWordsText(serverId, callName), noSave);
}
const blocks = (Array.isArray(result.content) ? result.content : []) as McpToolResultContent[];
// When the result contains non-text blocks (RFC-005 references, images),
Expand Down Expand Up @@ -28512,7 +28598,7 @@ export class AgentFramework {
});
let result: Awaited<ReturnType<typeof workspace.writeBinary>>;
try {
result = await workspace.writeBinary(destination, bytes, image.mediaType);
result = await workspace.writeBinary(destination, bytes, image.mediaType, { agentName });
} catch (error) {
// A throw leaves the file's tree entry as it was, unless its
// error says that whether the entry changed isn't known
Expand Down
8 changes: 6 additions & 2 deletions src/mcpl/tool-result-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,17 @@ export interface StandardToolResult {
* Content that is present but isn't a list of blocks makes it a malformed
* result, never an empty success. A tool's error result with no words, or
* only whitespace, reads as `noWords`, which the framework words for its
* server and tool.
* server and tool. A payload stub with no `save` says why nothing could
* keep it in `noSave`: no workspace mounted unless the caller words it
* otherwise, as the framework does for a host whose only writable mount
* is direct.
*/
export async function normalizeStandardToolResult(
result: StandardToolResult,
label: string,
save: SaveToolPayload | null,
noWords = 'Tool call failed',
noSave = 'no workspace is mounted to save it',
): Promise<ToolResult> {
const malformed = result.content !== undefined && !Array.isArray(result.content);
const raw: unknown[] = Array.isArray(result.content) ? result.content : [];
Expand All @@ -162,7 +166,7 @@ export async function normalizeStandardToolResult(
const what = `[${kind}${opts.origin ? ` ${bounded(opts.origin, URI_POINTS)}` : ''}: ${bounded(mimeType)}, ${size}`;
if (!save) {
lost++;
return `${what}. Not retained: ${why}, and no workspace is mounted to save it]`;
return `${what}. Not retained: ${why}, and ${noSave}]`;
}
const fileName = `${label}-${++saved}.${extensionFor(mimeType)}`;
const path = await save(fileName, Buffer.from(base64, 'base64'), mimeType).catch((error: unknown) => {
Expand Down
Loading
Loading