@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.
- package/dist/agent-core/extensions/compaction.d.ts +1 -0
- package/dist/agent-core/extensions/model-request.js +1 -1
- package/dist/agent-core/extensions/session-messages.d.ts +1 -1
- package/dist/agent-core/host/contracts.d.ts +2 -0
- package/dist/agent-core/host/local-agent-host.d.ts +2 -0
- package/dist/agent-core/host/local-agent-host.js +2 -0
- package/dist/agent-core/model/anthropic-messages.js +10 -2
- package/dist/agent-core/model/contracts.d.ts +8 -0
- package/dist/agent-core/model/openai-responses.js +8 -2
- package/dist/agent-core/model/usage-observer.d.ts +31 -0
- package/dist/agent-core/model/usage-observer.js +82 -0
- package/dist/agent-core/runtime/events.d.ts +2 -0
- package/dist/agent-core/sdk/agent.js +9 -1
- package/dist/agent-core/sdk/extension-models.d.ts +1 -1
- package/dist/agent-core/sdk/extension-models.js +9 -1
- package/dist/agent-core/sdk/session.js +31 -3
- package/dist/agent-core/session/catalog.d.ts +1 -0
- package/dist/agent-core/session/catalog.js +1 -0
- package/dist/agent-core/session/jsonl-store.d.ts +9 -0
- package/dist/agent-core/session/jsonl-store.js +24 -0
- package/dist/agent-core/session/tool-result-resources.d.ts +26 -0
- package/dist/agent-core/session/tool-result-resources.js +285 -0
- package/dist/agent-core/session/transaction-recovery.js +1 -1
- package/dist/agent-core/tools/code-mode/dispatch.js +5 -0
- package/dist/agent-core/tools/code-mode/index.js +7 -1
- package/dist/agent-core/tools/code-mode/observations.d.ts +1 -0
- package/dist/agent-core/tools/contracts.d.ts +2 -0
- package/dist/agent-core/tools/execution.js +11 -2
- package/dist/agent-core/tools/file-change-diff.d.ts +10 -0
- package/dist/agent-core/tools/file-change-diff.js +139 -0
- package/dist/agent-core/tools/local/apply-patch.js +19 -8
- package/dist/agent-core/tools/result-extensions.d.ts +46 -0
- package/dist/agent-core/tools/result-extensions.js +134 -0
- package/dist/agent-core/tools/runtime-context.d.ts +1 -0
- package/dist/agent-core/tools/runtime.js +6 -3
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1 -0
- package/dist/shared/auto-title.d.ts +2 -0
- package/dist/shared/auto-title.js +6 -0
- package/dist/shared/model-text.d.ts +2 -0
- package/dist/shared/model-text.js +6 -0
- 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
|
|
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, {
|
|
@@ -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
|
|
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
|
|
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: [
|
|
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: ')) {
|