diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 5fde54a7a..e91e22fa1 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -1250,10 +1250,12 @@ const tool_output_max_bytes: usize = 1 * 1024 * 1024; fn capToolOutput(allocator: std.mem.Allocator, output: []const u8) []const u8 { if (output.len <= tool_output_max_bytes) return output; - // Walk back to the start of the codepoint straddling the cap so - // providers don't see invalid UTF-8. + // Walk back at most 3 bytes (max UTF-8 sequence is 4); on malformed input + // fall back to the raw cap so we don't drop everything. var end: usize = tool_output_max_bytes; - while (end > 0 and (output[end] & 0b1100_0000) == 0b1000_0000) : (end -= 1) {} + const floor = end -| 3; + while (end > floor and (output[end] & 0b1100_0000) == 0b1000_0000) : (end -= 1) {} + if ((output[end] & 0b1100_0000) == 0b1000_0000) end = tool_output_max_bytes; const prefix = output[0..end]; var suffix_buf: [64]u8 = undefined; const suffix = std.fmt.bufPrint(&suffix_buf, "\n...[truncated, original {d} bytes]", .{output.len}) catch return prefix; @@ -1449,3 +1451,16 @@ test "capToolOutput: passes through when under cap" { const out = capToolOutput(ta, "short"); try std.testing.expectEqualStrings("short", out); } + +test "capToolOutput: malformed UTF-8 around cap falls back to raw boundary" { + const ta = std.testing.allocator; + const cap = tool_output_max_bytes; + const buf = try ta.alloc(u8, cap + 8); + defer ta.free(buf); + @memset(buf, 0x80); + + const out = capToolOutput(ta, buf); + defer if (out.ptr != buf.ptr) ta.free(out); + + try std.testing.expect(out.len > cap); +} diff --git a/src/script/Schema.zig b/src/script/Schema.zig index 413153bd6..05c27b35c 100644 --- a/src/script/Schema.zig +++ b/src/script/Schema.zig @@ -424,13 +424,19 @@ fn tokenize(arena: std.mem.Allocator, input: []const u8) ParseError![][]const u8 const close = std.mem.indexOfPos(u8, input, i + 3, triple_delim) orelse return error.UnterminatedQuote; i = close + 2; } else { - // Scan for the closer. `\` is rejected rather - // than decoded — choose the other quote style or a - // triple-quoted block instead. + // Odd run of `\` before the closer = escape attempt; even = literal. var j = i + 1; + var pending_bs: usize = 0; while (j < input.len) : (j += 1) { - if (input[j] == '\\' and j + 1 < input.len and input[j + 1] == ch) return error.UnsupportedEscape; - if (input[j] == ch) break; + if (input[j] == '\\') { + pending_bs += 1; + continue; + } + if (input[j] == ch) { + if (pending_bs % 2 == 1) return error.UnsupportedEscape; + break; + } + pending_bs = 0; } else return error.UnterminatedQuote; i = j; } @@ -790,6 +796,15 @@ test "tokenize: bare backslash inside quotes is allowed (e.g. Windows paths)" { try testing.expectString("value='C:\\Users\\bob'", tokens[0]); } +test "tokenize: even-count backslashes before close-quote are literal" { + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + // `"\\"` is two literal backslashes (even run before the closer), not an escape. + const tokens = try tokenize(arena.allocator(), "value=\"\\\\\""); + try testing.expectEqual(@as(usize, 1), tokens.len); + try testing.expectString("value=\"\\\\\"", tokens[0]); +} + test "hasUnclosedTripleQuote" { try testing.expect(!hasUnclosedTripleQuote("")); try testing.expect(!hasUnclosedTripleQuote("/goto https://x"));