praxis-agent 0.55.2 → 0.56.0

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.
@@ -12,6 +12,8 @@ import { countLineChanges } from './line-changes.js';
12
12
  import { editNotebook, formatNotebookForRead } from './notebook.js';
13
13
  import { openPdf } from './pdf.js';
14
14
  import { validateBashPathSafety } from '../permissions/bash-path-safety.js';
15
+ import { APPLY_PATCH_DEFINITION, APPLY_PATCH_MAX_FILES, parseApplyPatchInput, planApplyPatch, } from './apply-patch.js';
16
+ import { writeFileAtomically } from '../platform/atomic-write.js';
15
17
  import { protectedWritePathReason } from '../permissions/bypass-immune-paths.js';
16
18
  import { effectiveAdditionalDirectories, shellInputIsReadOnly, } from '../permissions/permission-updates.js';
17
19
  const REPORT_FINDINGS_DEFINITION = {
@@ -160,6 +162,7 @@ const TOOL_DEFINITIONS = [
160
162
  additionalProperties: false,
161
163
  },
162
164
  },
165
+ APPLY_PATCH_DEFINITION,
163
166
  {
164
167
  name: 'NotebookEdit',
165
168
  description: 'Replace, insert, or delete one cell in a Jupyter notebook after reading it.',
@@ -627,7 +630,15 @@ export class LocalToolRegistry {
627
630
  this.additionalReadDirectories.length === 0) {
628
631
  return definitions;
629
632
  }
630
- return definitions.map((definition) => ['Read', 'Write', 'Edit', 'NotebookEdit', 'Glob', 'Grep'].includes(definition.name)
633
+ return definitions.map((definition) => [
634
+ 'Read',
635
+ 'Write',
636
+ 'Edit',
637
+ 'ApplyPatch',
638
+ 'NotebookEdit',
639
+ 'Glob',
640
+ 'Grep',
641
+ ].includes(definition.name)
631
642
  ? {
632
643
  ...definition,
633
644
  description: `${definition.description}${this.additionalDirectories.length === 0
@@ -731,6 +742,26 @@ export class LocalToolRegistry {
731
742
  this.mutationTargetExisted.set(prepared, targetExisted);
732
743
  return prepared;
733
744
  }
745
+ case 'ApplyPatch': {
746
+ const edits = parseApplyPatchInput(call.input);
747
+ const preparedEdits = [];
748
+ for (const edit of edits) {
749
+ const filePath = await this.filePath(edit.file_path, false, false, context);
750
+ this.assertProtectedWritePath(filePath);
751
+ if (!(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
752
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
753
+ }
754
+ preparedEdits.push({
755
+ file_path: filePath,
756
+ old_string: edit.old_string,
757
+ new_string: edit.new_string,
758
+ });
759
+ }
760
+ if (new Set(preparedEdits.map((edit) => edit.file_path)).size >
761
+ APPLY_PATCH_MAX_FILES)
762
+ throw new Error('ApplyPatch may touch at most 8 files');
763
+ return { ...call, input: { edits: preparedEdits } };
764
+ }
734
765
  case 'NotebookEdit': {
735
766
  const requestedPath = stringInput(call.input, 'notebook_path');
736
767
  if (!isAbsolute(requestedPath)) {
@@ -839,6 +870,8 @@ export class LocalToolRegistry {
839
870
  return this.write(prepared, context, targetExisted);
840
871
  case 'Edit':
841
872
  return this.edit(prepared, targetExisted);
873
+ case 'ApplyPatch':
874
+ return this.applyPatch(prepared, context);
842
875
  case 'NotebookEdit':
843
876
  return this.notebookEdit(prepared);
844
877
  case 'Glob':
@@ -1222,6 +1255,74 @@ export class LocalToolRegistry {
1222
1255
  await handle.close();
1223
1256
  }
1224
1257
  }
1258
+ async applyPatch(call, context) {
1259
+ const edits = parseApplyPatchInput(call.input);
1260
+ const sources = new Map();
1261
+ const modes = new Map();
1262
+ const identities = new Map();
1263
+ const paths = [];
1264
+ for (const edit of edits) {
1265
+ const filePath = edit.file_path;
1266
+ if (sources.has(filePath))
1267
+ continue;
1268
+ paths.push(filePath);
1269
+ const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
1270
+ try {
1271
+ await this.assertStablePath(filePath);
1272
+ const metadata = await handle.stat();
1273
+ if (!metadata.isFile())
1274
+ throw new Error(`Not a file: ${filePath}`);
1275
+ if (metadata.size > this.maxFileBytes)
1276
+ throw new Error(`File exceeds ${this.maxFileBytes} byte edit limit`);
1277
+ sources.set(filePath, await handle.readFile('utf8'));
1278
+ identities.set(filePath, { dev: metadata.dev, ino: metadata.ino });
1279
+ modes.set(filePath, metadata.mode & 0o7777);
1280
+ }
1281
+ finally {
1282
+ await handle.close();
1283
+ }
1284
+ }
1285
+ const plan = planApplyPatch(edits, sources, this.maxFileBytes);
1286
+ for (const file of plan.files) {
1287
+ if (context.signal?.aborted)
1288
+ throw abortError();
1289
+ const mode = modes.get(file.filePath);
1290
+ const committed = await writeFileAtomically(file.filePath, file.after, {
1291
+ ...(mode === undefined ? {} : { mode }),
1292
+ beforeCommit: async () => {
1293
+ let handle;
1294
+ try {
1295
+ handle = await open(file.filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
1296
+ await this.assertStablePath(file.filePath);
1297
+ const metadata = await handle.stat();
1298
+ if (!metadata.isFile())
1299
+ return false;
1300
+ const identity = identities.get(file.filePath);
1301
+ if (!identity ||
1302
+ metadata.dev !== identity.dev ||
1303
+ metadata.ino !== identity.ino)
1304
+ return false;
1305
+ return (await handle.readFile('utf8')) === file.before;
1306
+ }
1307
+ catch {
1308
+ return false;
1309
+ }
1310
+ finally {
1311
+ await handle?.close();
1312
+ }
1313
+ },
1314
+ });
1315
+ if (!committed)
1316
+ throw new Error('Tool input changed after permission approval');
1317
+ }
1318
+ return {
1319
+ content: `Applied ${edits.length} replacement(s) to ${paths.length} file(s)`,
1320
+ isError: false,
1321
+ linesAdded: plan.linesAdded,
1322
+ linesRemoved: plan.linesRemoved,
1323
+ accessedPaths: paths,
1324
+ };
1325
+ }
1225
1326
  async edit(call, preparedTargetExisted) {
1226
1327
  const filePath = stringInput(call.input, 'file_path');
1227
1328
  this.assertProtectedWritePath(filePath);
@@ -5,8 +5,9 @@ import { analyzeBashStructure, normalizeBashWrapperArgv, } from '../permissions/
5
5
  import { validateBashPathSafety } from '../permissions/bash-path-safety.js';
6
6
  import { shellInputIsReadOnly } from '../permissions/permission-updates.js';
7
7
  import { isPathWithin } from '../platform/path-containment.js';
8
+ import { parseApplyPatchInput } from './apply-patch.js';
8
9
  const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'Bash']);
9
- const WRITE_TOOLS = new Set([...READ_ONLY_TOOLS, 'Write', 'Edit']);
10
+ const WRITE_TOOLS = new Set([...READ_ONLY_TOOLS, 'Write', 'Edit', 'ApplyPatch']);
10
11
  const SAFE_GIT_COMMANDS = new Set([
11
12
  'status',
12
13
  'diff',
@@ -88,6 +89,9 @@ function within(root, candidate) {
88
89
  function toolInputPaths(call) {
89
90
  if (call.name === 'Read' || call.name === 'Write' || call.name === 'Edit')
90
91
  return [call.input.file_path];
92
+ if (call.name === 'ApplyPatch') {
93
+ return parseApplyPatchInput(call.input).map((edit) => edit.file_path);
94
+ }
91
95
  if (call.name === 'Glob' || call.name === 'Grep')
92
96
  return call.input.path === undefined ? [] : [call.input.path];
93
97
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.55.2",
3
+ "version": "0.56.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",
@@ -62,7 +62,7 @@
62
62
  "verify:ci-coverage": "node scripts/verify-ci-coverage.mjs",
63
63
  "verify:fixture-contracts": "node scripts/verify-fixture-contracts.mjs",
64
64
  "test:fixtures": "node scripts/run-fixture-contracts.mjs",
65
- "test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts"
65
+ "test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts src/evals/apply-patch-admission.test.ts"
66
66
  },
67
67
  "engines": {
68
68
  "node": ">=24"