@myagentroam/agent 0.10.7 → 0.10.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/agent-core/extensions/compaction.d.ts +1 -0
  2. package/dist/agent-core/extensions/model-request.js +1 -1
  3. package/dist/agent-core/extensions/session-messages.d.ts +1 -1
  4. package/dist/agent-core/host/contracts.d.ts +2 -0
  5. package/dist/agent-core/host/local-agent-host.d.ts +2 -0
  6. package/dist/agent-core/host/local-agent-host.js +2 -0
  7. package/dist/agent-core/model/anthropic-messages.js +10 -2
  8. package/dist/agent-core/model/contracts.d.ts +8 -0
  9. package/dist/agent-core/model/openai-responses.js +8 -2
  10. package/dist/agent-core/model/usage-observer.d.ts +31 -0
  11. package/dist/agent-core/model/usage-observer.js +82 -0
  12. package/dist/agent-core/runtime/events.d.ts +2 -0
  13. package/dist/agent-core/sdk/agent.js +9 -1
  14. package/dist/agent-core/sdk/extension-models.d.ts +1 -1
  15. package/dist/agent-core/sdk/extension-models.js +9 -1
  16. package/dist/agent-core/sdk/session.js +31 -3
  17. package/dist/agent-core/session/catalog.d.ts +1 -0
  18. package/dist/agent-core/session/catalog.js +1 -0
  19. package/dist/agent-core/session/jsonl-store.d.ts +9 -0
  20. package/dist/agent-core/session/jsonl-store.js +24 -0
  21. package/dist/agent-core/session/tool-result-resources.d.ts +26 -0
  22. package/dist/agent-core/session/tool-result-resources.js +285 -0
  23. package/dist/agent-core/session/transaction-recovery.js +1 -1
  24. package/dist/agent-core/tools/code-mode/dispatch.js +5 -0
  25. package/dist/agent-core/tools/code-mode/index.js +7 -1
  26. package/dist/agent-core/tools/code-mode/observations.d.ts +1 -0
  27. package/dist/agent-core/tools/contracts.d.ts +2 -0
  28. package/dist/agent-core/tools/execution.js +11 -2
  29. package/dist/agent-core/tools/file-change-diff.d.ts +10 -0
  30. package/dist/agent-core/tools/file-change-diff.js +139 -0
  31. package/dist/agent-core/tools/local/apply-patch.js +19 -8
  32. package/dist/agent-core/tools/result-extensions.d.ts +46 -0
  33. package/dist/agent-core/tools/result-extensions.js +134 -0
  34. package/dist/agent-core/tools/runtime-context.d.ts +1 -0
  35. package/dist/agent-core/tools/runtime.js +6 -3
  36. package/dist/index.d.ts +4 -0
  37. package/dist/index.js +1 -0
  38. package/dist/shared/auto-title.d.ts +2 -0
  39. package/dist/shared/auto-title.js +6 -0
  40. package/dist/shared/model-text.d.ts +2 -0
  41. package/dist/shared/model-text.js +6 -0
  42. package/package.json +1 -1
@@ -0,0 +1,285 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { MarAgentError } from '../error.js';
6
+ import { parseToolResultExtensions } from '../tools/result-extensions.js';
7
+ const REF = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u;
8
+ const MAX_BYTES = 16 * 1024 * 1024;
9
+ export async function writeToolResultResource(directory, text) {
10
+ const bytes = Buffer.from(text);
11
+ if (bytes.length > MAX_BYTES)
12
+ throw resourceError('LIMIT', 'Tool result resource exceeds its limit.');
13
+ const resourceRef = randomUUID();
14
+ const folder = join(directory, 'tool_results');
15
+ await mkdir(folder, { recursive: true, mode: 0o700 });
16
+ const temporary = join(folder, `.${resourceRef}.tmp`);
17
+ await writeFile(join(folder, `.${resourceRef}.pending`), '', {
18
+ flag: 'wx',
19
+ mode: 0o600,
20
+ flush: true
21
+ });
22
+ try {
23
+ await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600, flush: true });
24
+ await rename(temporary, join(folder, `${resourceRef}.diff`));
25
+ }
26
+ finally {
27
+ await rm(temporary, { force: true });
28
+ }
29
+ return {
30
+ status: 'available',
31
+ resourceRef,
32
+ format: 'unified-diff',
33
+ byteSize: bytes.length,
34
+ sha256: createHash('sha256').update(bytes).digest('hex')
35
+ };
36
+ }
37
+ export function recordToolResources(record) {
38
+ if (!['tool.completed', 'tool.failed'].includes(record.type))
39
+ return [];
40
+ const payload = record.payload;
41
+ const sources = [
42
+ payload?.resultExtensions,
43
+ ...(Array.isArray(payload?.data?.calls)
44
+ ? payload.data.calls.map((call) => call.resultExtensions)
45
+ : [])
46
+ ];
47
+ return sources.flatMap((source) => (parseToolResultExtensions(source) ?? []).flatMap((extension) => extension.entries.flatMap((entry) => (entry.diff.status === 'available' ? [entry.diff] : []))));
48
+ }
49
+ /** A locator is only a hint: reads must verify its exact committed record identity. */
50
+ export async function bindToolResultResources(directory, record, readRecord, degrade = false) {
51
+ for (const resource of recordToolResources(record)) {
52
+ try {
53
+ await bindToolResultResource(directory, record, resource, readRecord);
54
+ }
55
+ catch (error) {
56
+ if (!degrade)
57
+ throw error;
58
+ // Shared descriptors are downgraded before persistence and live publication, never the tool outcome.
59
+ const payload = record.payload;
60
+ for (const extensions of [
61
+ payload.resultExtensions,
62
+ ...(payload.data?.calls ?? []).map((call) => call.resultExtensions)
63
+ ]) {
64
+ for (const extension of extensions ?? [])
65
+ for (const entry of extension.entries) {
66
+ if (entry.diff.status === 'available' &&
67
+ entry.diff.resourceRef === resource.resourceRef)
68
+ entry.diff = { status: 'unavailable', reason: 'storage-failed' };
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ async function bindToolResultResource(directory, record, resource, readRecord) {
75
+ const path = join(directory, 'tool_results', `${resource.resourceRef}.json`);
76
+ try {
77
+ await writeFile(path, JSON.stringify({ sequence: record.sequence, recordId: record.recordId }), { flag: 'wx', mode: 0o600, flush: true });
78
+ }
79
+ catch (error) {
80
+ if (error.code !== 'EEXIST')
81
+ throw error;
82
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
83
+ try {
84
+ const info = await handle.stat();
85
+ if (!info.isFile() || info.size > 1024)
86
+ throw resourceError('CORRUPT', 'Invalid tool resource locator.');
87
+ const locator = JSON.parse(await handle.readFile('utf8'));
88
+ const existing = locator.sequence === record.sequence && locator.recordId === record.recordId
89
+ ? record
90
+ : Number.isSafeInteger(locator.sequence)
91
+ ? await readRecord?.(locator.sequence)
92
+ : undefined;
93
+ if (!existing ||
94
+ existing.recordId !== locator.recordId ||
95
+ !recordToolResources(existing).some((entry) => entry.resourceRef === resource.resourceRef && entry.sha256 === resource.sha256))
96
+ throw resourceError('CORRUPT', 'Tool resource locator conflicts with committed history.');
97
+ }
98
+ catch (cause) {
99
+ throw normalizedResourceError(cause);
100
+ }
101
+ finally {
102
+ await handle.close();
103
+ }
104
+ }
105
+ }
106
+ export function toolResultResourceFiles(records) {
107
+ return new Set(records.flatMap((record) => recordToolResources(record).flatMap((resource) => [
108
+ `${resource.resourceRef}.diff`,
109
+ `${resource.resourceRef}.json`
110
+ ])));
111
+ }
112
+ export async function copyToolResultResources(source, target, records) {
113
+ const copied = new Set();
114
+ for (const record of records) {
115
+ for (const resource of recordToolResources(record)) {
116
+ if (copied.has(resource.resourceRef))
117
+ continue;
118
+ copied.add(resource.resourceRef);
119
+ try {
120
+ const bytes = await readResourceBytes(source, resource);
121
+ const directory = join(target, 'tool_results');
122
+ await mkdir(directory, { recursive: true, mode: 0o700 });
123
+ await writeFile(join(directory, `${resource.resourceRef}.diff`), bytes, {
124
+ flag: 'wx',
125
+ mode: 0o600,
126
+ flush: true
127
+ });
128
+ }
129
+ catch (error) {
130
+ if (error instanceof MarAgentError && error.code === 'MAR_AGENT_TOOL_RESOURCE_NOT_FOUND')
131
+ continue;
132
+ throw error;
133
+ }
134
+ }
135
+ if (recordToolResources(record).length) {
136
+ await mkdir(join(target, 'tool_results'), { recursive: true, mode: 0o700 });
137
+ await bindToolResultResources(target, record, async (sequence) => records.find((item) => item.sequence === sequence));
138
+ }
139
+ }
140
+ }
141
+ export async function completeToolResultResources(directory, record) {
142
+ await Promise.all(recordToolResources(record).map((resource) => rm(join(directory, 'tool_results', `.${resource.resourceRef}.pending`), {
143
+ force: true
144
+ }).catch(() => undefined)));
145
+ }
146
+ export async function cleanupUnreferencedToolResources(directory, readRecords) {
147
+ const folder = join(directory, 'tool_results');
148
+ let names;
149
+ try {
150
+ names = await readdir(folder);
151
+ }
152
+ catch (error) {
153
+ if (error.code === 'ENOENT')
154
+ return;
155
+ throw error;
156
+ }
157
+ const pending = names.filter((name) => name.startsWith('.') && name.endsWith('.pending') && REF.test(name.slice(1, -8)));
158
+ if (!pending.length)
159
+ return;
160
+ const retained = toolResultResourceFiles(await readRecords());
161
+ for (const marker of pending) {
162
+ const ref = marker.slice(1, -8);
163
+ for (const name of [`${ref}.diff`, `${ref}.json`, `.${ref}.tmp`]) {
164
+ if (retained.has(name))
165
+ continue;
166
+ const path = join(folder, name);
167
+ try {
168
+ if ((await lstat(path)).isFile())
169
+ await rm(path);
170
+ }
171
+ catch (error) {
172
+ if (error.code !== 'ENOENT')
173
+ throw error;
174
+ }
175
+ }
176
+ await rm(join(folder, marker), { force: true });
177
+ }
178
+ }
179
+ export async function readToolResultResource(directory, sessionId, input, readRecord) {
180
+ if (!REF.test(input.resourceRef) ||
181
+ (input.maxBytes !== undefined &&
182
+ (!Number.isSafeInteger(input.maxBytes) || input.maxBytes < 4 || input.maxBytes > 256 * 1024)))
183
+ throw resourceError('INPUT_INVALID', 'Invalid tool resource request.');
184
+ let locator;
185
+ try {
186
+ const file = join(directory, 'tool_results', `${input.resourceRef}.json`);
187
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
188
+ try {
189
+ const info = await handle.stat();
190
+ if (!info.isFile() || info.size > 1024)
191
+ throw resourceError('CORRUPT', 'Invalid tool resource locator.');
192
+ locator = JSON.parse(await handle.readFile('utf8'));
193
+ }
194
+ finally {
195
+ await handle.close();
196
+ }
197
+ }
198
+ catch (error) {
199
+ throw normalizedResourceError(error);
200
+ }
201
+ if (!Number.isSafeInteger(locator.sequence) ||
202
+ locator.sequence < 1 ||
203
+ typeof locator.recordId !== 'string')
204
+ throw resourceError('CORRUPT', 'Invalid tool resource locator.');
205
+ const record = await readRecord(locator.sequence);
206
+ const resource = record?.recordId === locator.recordId
207
+ ? recordToolResources(record).find((entry) => entry.resourceRef === input.resourceRef)
208
+ : undefined;
209
+ if (!resource)
210
+ throw resourceError('NOT_FOUND', 'Tool resource is not part of this session history.');
211
+ let offset = 0;
212
+ if (input.cursor !== undefined) {
213
+ try {
214
+ if (input.cursor.length > 1024)
215
+ throw new Error('cursor');
216
+ const cursor = JSON.parse(Buffer.from(input.cursor, 'base64url').toString('utf8'));
217
+ if (cursor.sessionId !== sessionId ||
218
+ cursor.ref !== resource.resourceRef ||
219
+ cursor.sha256 !== resource.sha256 ||
220
+ !Number.isSafeInteger(cursor.offset) ||
221
+ cursor.offset < 0 ||
222
+ cursor.offset >= resource.byteSize)
223
+ throw new Error('cursor');
224
+ offset = cursor.offset;
225
+ }
226
+ catch {
227
+ throw resourceError('INPUT_INVALID', 'Invalid tool resource cursor.');
228
+ }
229
+ }
230
+ const bytes = await readResourceBytes(directory, resource);
231
+ if (offset > 0 && (bytes[offset] & 0xc0) === 0x80)
232
+ throw resourceError('INPUT_INVALID', 'Invalid tool resource cursor boundary.');
233
+ let end = Math.min(bytes.length, offset + (input.maxBytes ?? 256 * 1024));
234
+ while (end < bytes.length && (bytes[end] & 0xc0) === 0x80)
235
+ end--;
236
+ if ((await readRecord(locator.sequence))?.recordId !== locator.recordId)
237
+ throw resourceError('NOT_FOUND', 'Tool resource history has changed.');
238
+ return {
239
+ type: 'file_changes',
240
+ version: 1,
241
+ format: resource.format,
242
+ resourceRef: resource.resourceRef,
243
+ byteSize: resource.byteSize,
244
+ sha256: resource.sha256,
245
+ content: bytes.subarray(offset, end).toString('utf8'),
246
+ ...(end < bytes.length
247
+ ? {
248
+ nextCursor: Buffer.from(JSON.stringify({
249
+ sessionId,
250
+ ref: resource.resourceRef,
251
+ sha256: resource.sha256,
252
+ offset: end
253
+ })).toString('base64url')
254
+ }
255
+ : {})
256
+ };
257
+ }
258
+ async function readResourceBytes(directory, resource) {
259
+ try {
260
+ const handle = await open(join(directory, 'tool_results', `${resource.resourceRef}.diff`), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
261
+ try {
262
+ const stat = await handle.stat();
263
+ if (!stat.isFile() || stat.size !== resource.byteSize || stat.size > MAX_BYTES)
264
+ throw resourceError('CORRUPT', 'Tool resource size does not match its history.');
265
+ const bytes = await handle.readFile();
266
+ if (createHash('sha256').update(bytes).digest('hex') !== resource.sha256)
267
+ throw resourceError('CORRUPT', 'Tool resource checksum does not match its history.');
268
+ return bytes;
269
+ }
270
+ finally {
271
+ await handle.close();
272
+ }
273
+ }
274
+ catch (error) {
275
+ throw normalizedResourceError(error);
276
+ }
277
+ }
278
+ function resourceError(suffix, message) {
279
+ return new MarAgentError(`MAR_AGENT_TOOL_RESOURCE_${suffix}`, message);
280
+ }
281
+ function normalizedResourceError(error) {
282
+ if (error instanceof MarAgentError)
283
+ return error;
284
+ return resourceError(error?.code === 'ENOENT' ? 'NOT_FOUND' : 'CORRUPT', 'Tool result resource is unavailable.');
285
+ }
@@ -92,7 +92,7 @@ export async function recoverSessionStorageTransaction(directory, committedTrans
92
92
  await rename(backup, sidecar);
93
93
  }
94
94
  }
95
- for (const folder of ['input_images', 'generated_images']) {
95
+ for (const folder of ['input_images', 'generated_images', 'tool_results']) {
96
96
  const path = join(directory, folder);
97
97
  let names;
98
98
  try {
@@ -4,6 +4,7 @@ import { AGENT_EXECUTION_POLICY } from '../../runtime/execution-policy.js';
4
4
  import { allowsToolCaller } from '../callers.js';
5
5
  import { boundedModelToolOutput, structuredToolEventData, structuredToolFailureData } from '../output-projection.js';
6
6
  import { executeRegisteredTool } from '../runtime.js';
7
+ import { failedToolResultExtensions } from '../result-extensions.js';
7
8
  /** Capture only the cell's immutable dispatch identity, never the model loop or its AbortSignal. */
8
9
  export function createCodeToolDispatch(input) {
9
10
  const definitions = new Map(input.tools.map((tool) => [tool.name, tool]));
@@ -33,6 +34,7 @@ export function createCodeToolDispatch(input) {
33
34
  input.assertLive();
34
35
  await input.emit({
35
36
  type: 'tool.completed',
37
+ ...(result.resultExtensions ? { resultExtensions: result.resultExtensions } : {}),
36
38
  ...identity,
37
39
  summary,
38
40
  ...(data === undefined ? {} : { data }),
@@ -59,6 +61,9 @@ export function createCodeToolDispatch(input) {
59
61
  const data = structuredToolFailureData(call.name, error);
60
62
  await input.emit({
61
63
  type: 'tool.failed',
64
+ ...(failedToolResultExtensions(error)
65
+ ? { resultExtensions: failedToolResultExtensions(error) }
66
+ : {}),
62
67
  ...identity,
63
68
  code: diagnostic.code,
64
69
  message: diagnostic.message,
@@ -16,6 +16,7 @@ import { TOOL_EXECUTION_LIMITS as limits } from '../execution-limits.js';
16
16
  import { createToolCallScheduler } from '../scheduler.js';
17
17
  import { detectImageMime } from '../local/view-image.js';
18
18
  import { structuredToolFailureData } from '../output-projection.js';
19
+ import { failedToolResultExtensions } from '../result-extensions.js';
19
20
  export const codeModeToolDefinition = {
20
21
  name: 'code_exec',
21
22
  description: `Run JavaScript to orchestrate or compose tool calls.
@@ -474,15 +475,17 @@ export class CodeModeTool {
474
475
  try {
475
476
  if (frame.name === 'code_exec' || !available.has(frame.name))
476
477
  throw new MarAgentError('MAR_AGENT_TOOL_NOT_AVAILABLE', 'The requested nested tool is unavailable.');
477
- const output = await invoke({
478
+ const rawOutput = await invoke({
478
479
  name: frame.name,
479
480
  arguments: frame.arguments,
480
481
  callId: source.callId
481
482
  });
483
+ const { resultExtensions, ...output } = rawOutput;
482
484
  toolResults.set(operationId, { source, outcome: 'completed', result: output });
483
485
  recordCall({
484
486
  ...source,
485
487
  status: 'completed',
488
+ ...(resultExtensions ? { resultExtensions } : {}),
486
489
  result: {
487
490
  content: '',
488
491
  truncated: output.truncated === true,
@@ -516,6 +519,9 @@ export class CodeModeTool {
516
519
  recordCall({
517
520
  ...source,
518
521
  status: 'failed',
522
+ ...(failedToolResultExtensions(error)
523
+ ? { resultExtensions: failedToolResultExtensions(error) }
524
+ : {}),
519
525
  error: { code: diagnostic.code, message: diagnostic.message }
520
526
  });
521
527
  toolResults.set(operationId, {
@@ -4,6 +4,7 @@ export interface CodeToolCallRecord {
4
4
  toolName: string;
5
5
  input: unknown;
6
6
  status: 'running' | 'completed' | 'failed';
7
+ resultExtensions?: readonly import('../result-extensions.js').ToolResultExtension[];
7
8
  result?: {
8
9
  content: string;
9
10
  data?: unknown;
@@ -95,6 +95,8 @@ export interface ToolExecutionResult {
95
95
  dataBase64: string;
96
96
  }[];
97
97
  artifacts?: readonly MarAgentToolArtifact[];
98
+ /** Host-facing evidence, excluded from model and programmatic guest results. */
99
+ resultExtensions?: readonly import('./result-extensions.js').ToolResultExtension[];
98
100
  }
99
101
  export interface CodeCellEventData {
100
102
  calls?: readonly import('./code-mode/observations.js').CodeToolCallRecord[];
@@ -5,6 +5,7 @@ import { CodeCellExecutionError } from './code-mode/cells.js';
5
5
  import { createCodeToolDispatch } from './code-mode/dispatch.js';
6
6
  import { boundedModelToolOutput, structuredToolArtifacts, structuredToolEventData, structuredToolFailureData } from './output-projection.js';
7
7
  import { executeRegisteredTool } from './runtime.js';
8
+ import { failedToolResultExtensions } from './result-extensions.js';
8
9
  import { scheduleToolCallBatch } from './scheduler.js';
9
10
  /** Shared raw context construction for physical tools and native calls in a real model round. */
10
11
  export async function createRoundToolRuntimeContext(scope, call, signal) {
@@ -91,6 +92,7 @@ export async function executeRoundTools(calls, scope) {
91
92
  const artifacts = structuredToolArtifacts(output.artifacts);
92
93
  await scope.emit({
93
94
  type: 'tool.completed',
95
+ ...(output.resultExtensions ? { resultExtensions: output.resultExtensions } : {}),
94
96
  toolName: call.name,
95
97
  callId: call.callId,
96
98
  ...(parentCallId === undefined ? {} : { parentCallId }),
@@ -105,7 +107,9 @@ export async function executeRoundTools(calls, scope) {
105
107
  call,
106
108
  output,
107
109
  content: modelOutput.content,
108
- ...(output.calls ? { calls: output.calls } : {}),
110
+ ...(output.calls
111
+ ? { calls: output.calls.map(({ resultExtensions: _evidence, ...call }) => call) }
112
+ : {}),
109
113
  ...(output.observations ? { observations: output.observations } : {}),
110
114
  ...(output.images?.length ? { images: output.images } : {}),
111
115
  failed: false
@@ -121,6 +125,9 @@ export async function executeRoundTools(calls, scope) {
121
125
  : structuredToolEventData(call.name, partial.data));
122
126
  await scope.emit({
123
127
  type: 'tool.failed',
128
+ ...(failedToolResultExtensions(error)
129
+ ? { resultExtensions: failedToolResultExtensions(error) }
130
+ : {}),
124
131
  toolName: call.name,
125
132
  callId: call.callId,
126
133
  ...(parentCallId === undefined ? {} : { parentCallId }),
@@ -140,7 +147,9 @@ export async function executeRoundTools(calls, scope) {
140
147
  : boundedModelToolOutput(call.name, partial.content, partial.data).content,
141
148
  ...(partial?.images?.length ? { images: partial.images } : {}),
142
149
  failed: true,
143
- ...(partial?.calls ? { calls: partial.calls } : {}),
150
+ ...(partial?.calls
151
+ ? { calls: partial.calls.map(({ resultExtensions: _evidence, ...call }) => call) }
152
+ : {}),
144
153
  ...(partial?.observations ? { observations: partial.observations } : {}),
145
154
  error
146
155
  };
@@ -0,0 +1,10 @@
1
+ /** Bounded line comparison for immutable, per-operation file evidence. */
2
+ export interface FileDiff {
3
+ text: string;
4
+ additions: number;
5
+ deletions: number;
6
+ }
7
+ export declare const MAX_FILE_DIFF_BYTES: number;
8
+ export declare class FileDiffLimitError extends Error {
9
+ }
10
+ export declare function createFileDiff(path: string, before?: Buffer, after?: Buffer): FileDiff | undefined;
@@ -0,0 +1,139 @@
1
+ const MAX_WORK = 2_000_000;
2
+ export const MAX_FILE_DIFF_BYTES = 16 * 1024 * 1024;
3
+ export class FileDiffLimitError extends Error {
4
+ }
5
+ export function createFileDiff(path, before, after) {
6
+ let oldText;
7
+ let newText;
8
+ try {
9
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
10
+ oldText = decoder.decode(before ?? Buffer.alloc(0));
11
+ newText = decoder.decode(after ?? Buffer.alloc(0));
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ if (oldText.includes('\0') || newText.includes('\0'))
17
+ return undefined;
18
+ const lines = (value) => value.match(/[^\n]*\n|[^\n]+$/gu) ?? [];
19
+ const a = lines(oldText);
20
+ const b = lines(newText);
21
+ if (a.length + b.length > 200_000)
22
+ throw new FileDiffLimitError();
23
+ const edits = compare(a, b);
24
+ if (!edits)
25
+ throw new FileDiffLimitError();
26
+ const output = [
27
+ `--- ${before === undefined ? '/dev/null' : JSON.stringify(`a/${path}`)}`,
28
+ `+++ ${after === undefined ? '/dev/null' : JSON.stringify(`b/${path}`)}`
29
+ ];
30
+ const positions = [];
31
+ const ranges = [];
32
+ let old = 1;
33
+ let next = 1;
34
+ let additions = 0;
35
+ let deletions = 0;
36
+ edits.forEach((edit, index) => {
37
+ positions.push({ old, next });
38
+ if (edit.kind !== '+')
39
+ old++;
40
+ if (edit.kind !== '-')
41
+ next++;
42
+ if (edit.kind === '+')
43
+ additions++;
44
+ if (edit.kind === '-')
45
+ deletions++;
46
+ if (edit.kind === ' ')
47
+ return;
48
+ const start = Math.max(0, index - 3);
49
+ const end = Math.min(edits.length, index + 4);
50
+ const last = ranges.at(-1);
51
+ if (last && start <= last.end)
52
+ last.end = end;
53
+ else
54
+ ranges.push({ start, end });
55
+ });
56
+ for (const range of ranges) {
57
+ const block = edits.slice(range.start, range.end);
58
+ const oldCount = block.filter((edit) => edit.kind !== '+').length;
59
+ const newCount = block.filter((edit) => edit.kind !== '-').length;
60
+ const position = positions[range.start];
61
+ output.push(`@@ -${position.old - (oldCount === 0 ? 1 : 0)},${oldCount} +${position.next - (newCount === 0 ? 1 : 0)},${newCount} @@`);
62
+ for (const edit of block) {
63
+ output.push(`${edit.kind}${edit.text.endsWith('\n') ? edit.text.slice(0, -1) : edit.text}`);
64
+ if (!edit.text.endsWith('\n'))
65
+ output.push('\');
66
+ }
67
+ }
68
+ const text = `${output.join('\n')}\n`;
69
+ if (Buffer.byteLength(text) > MAX_FILE_DIFF_BYTES)
70
+ throw new FileDiffLimitError();
71
+ return { text, additions, deletions };
72
+ }
73
+ function compare(a, b) {
74
+ let prefix = 0;
75
+ while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix])
76
+ prefix++;
77
+ let suffix = 0;
78
+ while (suffix < a.length - prefix &&
79
+ suffix < b.length - prefix &&
80
+ a[a.length - suffix - 1] === b[b.length - suffix - 1])
81
+ suffix++;
82
+ const left = a.slice(prefix, a.length - suffix);
83
+ const right = b.slice(prefix, b.length - suffix);
84
+ if (!left.length || !right.length)
85
+ return [
86
+ ...a.slice(0, prefix).map((text) => ({ kind: ' ', text })),
87
+ ...left.map((text) => ({ kind: '-', text })),
88
+ ...right.map((text) => ({ kind: '+', text })),
89
+ ...a.slice(a.length - suffix).map((text) => ({ kind: ' ', text }))
90
+ ];
91
+ const trace = [];
92
+ const frontier = new Map([[1, 0]]);
93
+ let work = 0;
94
+ for (let distance = 0; distance <= left.length + right.length; distance++) {
95
+ trace.push(new Map(frontier));
96
+ for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {
97
+ if (++work > MAX_WORK)
98
+ return undefined;
99
+ const down = diagonal === -distance ||
100
+ (diagonal !== distance &&
101
+ (frontier.get(diagonal - 1) ?? -1) < (frontier.get(diagonal + 1) ?? -1));
102
+ let x = down ? (frontier.get(diagonal + 1) ?? 0) : (frontier.get(diagonal - 1) ?? 0) + 1;
103
+ let y = x - diagonal;
104
+ while (x < left.length && y < right.length && left[x] === right[y]) {
105
+ if (++work > MAX_WORK)
106
+ return undefined;
107
+ x++;
108
+ y++;
109
+ }
110
+ frontier.set(diagonal, x);
111
+ if (x < left.length || y < right.length)
112
+ continue;
113
+ const reversed = [];
114
+ for (let d = distance; d >= 0; d--) {
115
+ const previous = trace[d];
116
+ const k = x - y;
117
+ const previousK = k === -d || (k !== d && (previous.get(k - 1) ?? -1) < (previous.get(k + 1) ?? -1))
118
+ ? k + 1
119
+ : k - 1;
120
+ const previousX = previous.get(previousK) ?? 0;
121
+ const previousY = previousX - previousK;
122
+ while (x > previousX && y > previousY)
123
+ (reversed.push({ kind: ' ', text: left[--x] }), y--);
124
+ if (d === 0)
125
+ break;
126
+ if (x === previousX)
127
+ reversed.push({ kind: '+', text: right[--y] });
128
+ else
129
+ reversed.push({ kind: '-', text: left[--x] });
130
+ }
131
+ return [
132
+ ...a.slice(0, prefix).map((text) => ({ kind: ' ', text })),
133
+ ...reversed.reverse(),
134
+ ...a.slice(a.length - suffix).map((text) => ({ kind: ' ', text }))
135
+ ];
136
+ }
137
+ }
138
+ return undefined;
139
+ }
@@ -4,6 +4,7 @@ import { dirname, isAbsolute, parse, resolve } from 'node:path';
4
4
  import { TextDecoder } from 'node:util';
5
5
  import { MarAgentError } from '../../error.js';
6
6
  import { TOOL_EXECUTION_LIMITS } from '../execution-limits.js';
7
+ import { captureFileChange, reserveFileChangePaths } from '../result-extensions.js';
7
8
  export const applyPatchToolDefinition = {
8
9
  name: 'apply_patch',
9
10
  allowedCallers: ['direct', 'programmatic'],
@@ -70,6 +71,9 @@ export class ApplyPatchTool {
70
71
  }
71
72
  async execute(patch) {
72
73
  const changes = parsePatch(patch);
74
+ reserveFileChangePaths(changes.flatMap((change) => change.kind === 'update' && change.targetPath
75
+ ? [change.path, change.targetPath]
76
+ : [change.path]));
73
77
  const resolvedChanges = await this.#resolveChanges(changes);
74
78
  const results = [];
75
79
  let totalBytes = 0;
@@ -149,7 +153,7 @@ export class ApplyPatchTool {
149
153
  const targetStat = await optionalLstat(resolvedChange.target);
150
154
  if (targetStat && !targetStat.isFile())
151
155
  invalid('Patch targets must be regular files.');
152
- const content = Buffer.from(`${change.lines.join('\n')}\n`);
156
+ const content = Buffer.from(change.lines.length ? `${change.lines.join('\n')}\n` : '');
153
157
  const totalBytes = enforceContentLimits(content, currentTotalBytes);
154
158
  return {
155
159
  mutations: [
@@ -158,7 +162,8 @@ export class ApplyPatchTool {
158
162
  target: resolvedChange.target,
159
163
  content,
160
164
  ...(targetStat ? { mode: targetStat.mode & 0o7777 } : {}),
161
- expectedExisting: targetStat !== undefined
165
+ expectedExisting: targetStat !== undefined,
166
+ ...(targetStat ? { original: await readBoundedFile(resolvedChange.target) } : {})
162
167
  }
163
168
  ],
164
169
  result: {
@@ -178,7 +183,9 @@ export class ApplyPatchTool {
178
183
  if (resolvedChange.kind === 'delete') {
179
184
  const { change } = resolvedChange;
180
185
  return {
181
- mutations: [{ path: change.path, target: resolvedChange.source, expectedExisting: true }],
186
+ mutations: [
187
+ { path: change.path, target: resolvedChange.source, expectedExisting: true, original }
188
+ ],
182
189
  result: {
183
190
  path: change.path,
184
191
  operation: 'deleted',
@@ -204,13 +211,14 @@ export class ApplyPatchTool {
204
211
  invalid('Patch targets must be regular files.');
205
212
  return {
206
213
  mutations: [
207
- { path: change.path, target: resolvedChange.source, expectedExisting: true },
214
+ { path: change.path, target: resolvedChange.source, expectedExisting: true, original },
208
215
  {
209
216
  path: moveChange.targetPath,
210
217
  target: resolvedChange.target,
211
218
  content,
212
219
  mode: (targetStat ?? sourceStat).mode & 0o7777,
213
- expectedExisting: targetStat !== undefined
220
+ expectedExisting: targetStat !== undefined,
221
+ ...(targetStat ? { original: await readBoundedFile(resolvedChange.target) } : {})
214
222
  }
215
223
  ],
216
224
  result: {
@@ -230,7 +238,8 @@ export class ApplyPatchTool {
230
238
  target: resolvedChange.source,
231
239
  content,
232
240
  mode: sourceStat.mode & 0o7777,
233
- expectedExisting: true
241
+ expectedExisting: true,
242
+ original
234
243
  }
235
244
  ],
236
245
  result: {
@@ -267,6 +276,8 @@ export class ApplyPatchTool {
267
276
  const backup = `${mutation.target}.mar-patch-${randomUUID()}.bak`;
268
277
  await this.#fileOperations.rename(mutation.target, backup);
269
278
  backups.push({ target: mutation.target, backup });
279
+ if (mutation.original && !(await readBoundedFile(backup)).equals(mutation.original))
280
+ throw new MarAgentError('MAR_AGENT_PATCH_CONFLICT', 'A patch target changed after preflight.');
270
281
  }
271
282
  for (const item of staged) {
272
283
  await this.#fileOperations.rename(item.temporary, item.target);
@@ -291,6 +302,8 @@ export class ApplyPatchTool {
291
302
  throw error;
292
303
  }
293
304
  await Promise.all(backups.map((item) => this.#fileOperations.rm(item.backup, { force: true })));
305
+ for (const mutation of mutations)
306
+ await captureFileChange(mutation.path, mutation.original, mutation.content);
294
307
  }
295
308
  }
296
309
  function parsePatch(patch) {
@@ -331,8 +344,6 @@ function parsePatch(patch) {
331
344
  invalid('Added file lines must start with +.');
332
345
  content.push(line.slice(1));
333
346
  }
334
- if (!content.length)
335
- invalid('Added files must contain at least one patch line.');
336
347
  changes.push({ kind: 'add', path, lines: content });
337
348
  }
338
349
  else if (header.startsWith('*** Delete File: ')) {