diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java index 9193cac801..42b73d7244 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystem.java @@ -224,32 +224,43 @@ public EditResult edit( Base64.getEncoder() .encodeToString(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + // Use real newlines inside the python3 -c program. Java "\\n" would emit a + // literal backslash-n, which POSIX shells pass through and Python rejects + // as a SyntaxError (see #2571). String cmd = - "python3 -c \"import sys, os, base64, json\\n" + "python3 -c \"import sys, os, base64, json\n" + "payload =" - + " json.loads(base64.b64decode(sys.stdin.read().strip()).decode('utf-8'))\\n" - + "path, old, new = payload['path'], payload['old'], payload['new']\\n" - + "replace_all = payload.get('replace_all', False)\\n" - + "if not os.path.isfile(path):\\n" - + " print(json.dumps({'error': 'file_not_found'}))\\n" - + " sys.exit(0)\\n" - + "with open(path, 'rb') as f: text = f.read().decode('utf-8')\\n" - + "count = text.count(old)\\n" - + "if count == 0:\\n" - + " print(json.dumps({'error': 'string_not_found'}))\\n" - + " sys.exit(0)\\n" - + "if count > 1 and not replace_all:\\n" - + " print(json.dumps({'error': 'multiple_occurrences', 'count': count}))\\n" - + " sys.exit(0)\\n" + + " json.loads(base64.b64decode(sys.stdin.read().strip()).decode('utf-8'))\n" + + "path, old, new = payload['path'], payload['old'], payload['new']\n" + + "replace_all = payload.get('replace_all', False)\n" + + "if not os.path.isfile(path):\n" + + " print(json.dumps({'error': 'file_not_found'}))\n" + + " sys.exit(0)\n" + + "with open(path, 'rb') as f: text = f.read().decode('utf-8')\n" + + "count = text.count(old)\n" + + "if count == 0:\n" + + " print(json.dumps({'error': 'string_not_found'}))\n" + + " sys.exit(0)\n" + + "if count > 1 and not replace_all:\n" + + " print(json.dumps({'error': 'multiple_occurrences', 'count': count}))\n" + + " sys.exit(0)\n" + "result = text.replace(old, new) if replace_all else text.replace(old, new," - + " 1)\\n" - + "with open(path, 'wb') as f: f.write(result.encode('utf-8'))\\n" - + "print(json.dumps({'count': count}))\\n" + + " 1)\n" + + "with open(path, 'wb') as f: f.write(result.encode('utf-8'))\n" + + "print(json.dumps({'count': count}))\n" + "\" 2>&1 <<'__EDIT_EOF__'\n" + payloadB64 + "\n__EDIT_EOF__\n"; ExecuteResponse result = execute(runtimeContext, cmd, null); + if (result.exitCode() != null && result.exitCode() != 0) { + String err = result.output() != null ? result.output().strip() : ""; + return EditResult.fail( + "Error editing file '" + + filePath + + "': " + + err.substring(0, Math.min(200, err.length()))); + } String output = result.output() != null ? result.output().strip() : ""; if (output.contains("\"error\"")) { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystemTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystemTest.java index d206fe70fe..39b3d6b11a 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystemTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/sandbox/BaseSandboxFilesystemTest.java @@ -130,6 +130,72 @@ void ls_reportsDirModifiedAt() { assertTrue(dir.isDirectory()); assertFalse(dir.modifiedAt().isEmpty(), "dir modifiedAt should be populated"); } + + @Test + void edit_pythonCommand_usesRealNewlinesNotLiteralBackslashN() { + FakeSandboxFilesystem filesystem = new FakeSandboxFilesystem(); + filesystem.nextEditResponse = new ExecuteResponse("{\"count\": 1}", 0, false); + + var result = filesystem.edit(RT, "/tmp/example.txt", "old", "new", false); + + assertTrue(result.isSuccess()); + // Real LF between "json" and "payload" — not the two-char sequence '\' + 'n'. + assertTrue( + filesystem.lastCommand.contains("json\npayload"), + "python -c program must contain real newlines"); + assertFalse( + filesystem.lastCommand.contains("json" + "\\" + "npayload"), + "must not emit literal backslash-n (POSIX shell / Python SyntaxError)"); + } + + @Test + void edit_nonzeroExitCode_returnsFailureWithOutput() { + FakeSandboxFilesystem filesystem = new FakeSandboxFilesystem(); + filesystem.nextEditResponse = + new ExecuteResponse("SyntaxError: unexpected character", 1, false); + + var result = filesystem.edit(RT, "/tmp/example.txt", "old", "new", false); + + assertFalse(result.isSuccess()); + assertTrue(result.error().contains("SyntaxError")); + } + + @Test + void edit_nonzeroExitCode_nullOutput_returnsFailure() { + FakeSandboxFilesystem filesystem = new FakeSandboxFilesystem(); + // Covers: exitCode != 0 && output == null -> err = "" + filesystem.nextEditResponse = new ExecuteResponse(null, 1, false); + + var result = filesystem.edit(RT, "/tmp/example.txt", "old", "new", false); + + assertFalse(result.isSuccess()); + assertTrue(result.error().contains("Error editing file")); + } + + @Test + void edit_nullExitCode_withCount_stillSucceeds() { + FakeSandboxFilesystem filesystem = new FakeSandboxFilesystem(); + // Covers: exitCode == null -> skip nonzero-fail branch + filesystem.nextEditResponse = new ExecuteResponse("{\"count\": 2}", null, false); + + var result = filesystem.edit(RT, "/tmp/example.txt", "old", "new", false); + + assertTrue(result.isSuccess()); + assertEquals(2, result.occurrences()); + } + + @Test + void edit_nonzeroExitCode_longOutput_isTruncatedTo200() { + FakeSandboxFilesystem filesystem = new FakeSandboxFilesystem(); + String longErr = "E".repeat(250); + filesystem.nextEditResponse = new ExecuteResponse(longErr, 2, false); + + var result = filesystem.edit(RT, "/tmp/example.txt", "old", "new", false); + + assertFalse(result.isSuccess()); + assertTrue(result.error().endsWith("E".repeat(200))); + assertFalse(result.error().contains("E".repeat(201))); + } } // ================================================================ @@ -215,6 +281,7 @@ void glob_emptyResultWhenNoMatch() { private static final class FakeSandboxFilesystem extends BaseSandboxFilesystem { String lastCommand; + ExecuteResponse nextEditResponse; @Override public String id() { @@ -225,6 +292,9 @@ public String id() { public ExecuteResponse execute( RuntimeContext runtimeContext, String command, Integer timeoutSeconds) { lastCommand = command; + if (command.startsWith("python3 -c") && nextEditResponse != null) { + return nextEditResponse; + } if (command.startsWith("for f in ") && command.contains("stat -c")) { return new ExecuteResponse( "DIR:/workspace/docs\t1719300000\n"