Commit Diff


commit - 83f2c983cb279c2381c039c3947997ebde1ce2e1
commit + 5ba13c5d7937caa643a04594bd9be7ec3f26a4bc
blob - 8b519d83481110bc2d8732230afc0ad6207089ef
blob + 2ff2caa01bbf79cd0afb7e9e32e39d4fd23b33a5
--- regress/compaction/compaction_test.c
+++ regress/compaction/compaction_test.c
@@ -416,6 +416,226 @@ test_document_bound(void)
 	msglist_free(&messages);
 }
 
+static void
+test_file_collection_and_rendering(void)
+{
+	static const char expected[] =
+	    "model continuation notes\n\n"
+	    "<read-files>\n"
+	    "a.c\n"
+	    "</read-files>\n\n"
+	    "<modified-files>\n"
+	    "m.c\n"
+	    "z.c\n"
+	    "</modified-files>";
+	struct compaction_files *files;
+	struct msglist	 messages;
+	struct msg	*message;
+	struct buf	 out;
+	const void	*path;
+	size_t		 pathlen;
+
+	TAILQ_INIT(&messages);
+	message = msg_new(ROLE_ASSISTANT);
+	msg_add_tool_use(message, "1", "read", "{\"path\":\"z.c\"}",
+	    strlen("{\"path\":\"z.c\"}"));
+	msg_add_tool_use(message, "2", "read", "{\"path\":\"a.c\"}",
+	    strlen("{\"path\":\"a.c\"}"));
+	msg_add_tool_use(message, "3", "read", "{\"path\":\"z.c\"}",
+	    strlen("{\"path\":\"z.c\"}"));
+	msg_add_tool_use(message, "4", "edit", "{\"path\":\"z.c\"}",
+	    strlen("{\"path\":\"z.c\"}"));
+	msg_add_tool_use(message, "5", "write", "{\"path\":\"m.c\"}",
+	    strlen("{\"path\":\"m.c\"}"));
+	msg_add_tool_use(message, "6", "multi_edit",
+	    "{\"path\":\"m.c\"}", strlen("{\"path\":\"m.c\"}"));
+	msg_add_tool_use(message, "7", "shell",
+	    "{\"path\":\"never.c\"}", strlen("{\"path\":\"never.c\"}"));
+	append_message(&messages, message);
+	message = msg_new(ROLE_USER);
+	msg_add_tool_result(message, "5", "write failed", 12, 1);
+	append_message(&messages, message);
+
+	files = compaction_files_new();
+	CHECK(compaction_files_collect(files, &messages) == 0);
+	CHECK(compaction_files_count(files, COMPACTION_FILE_READ) == 1);
+	CHECK(compaction_files_count(files, COMPACTION_FILE_MODIFIED) == 2);
+	CHECK(compaction_files_get(files, COMPACTION_FILE_READ, 0, &path,
+	    &pathlen) == 0);
+	CHECK(pathlen == 3 && memcmp(path, "a.c", 3) == 0);
+	CHECK(compaction_files_get(files, COMPACTION_FILE_MODIFIED, 1, &path,
+	    &pathlen) == 0);
+	CHECK(pathlen == 3 && memcmp(path, "z.c", 3) == 0);
+
+	buf_init(&out);
+	buf_addstr(&out, "model continuation notes");
+	CHECK(compaction_files_append(&out, files, SIZE_MAX) == 0);
+	CHECK(buf_is(&out, expected, sizeof(expected) - 1));
+
+	buf_free(&out);
+	compaction_files_free(files);
+	msglist_free(&messages);
+}
+
+static void
+test_file_cumulative_merge(void)
+{
+	static const char expected[] =
+	    "<read-files>\n"
+	    "new.c\n"
+	    "old.c\n"
+	    "</read-files>\n\n"
+	    "<modified-files>\n"
+	    "prior-mod.c\n"
+	    "promote.c\n"
+	    "</modified-files>";
+	struct compaction_files *current, *previous;
+	struct buf	 out;
+
+	previous = compaction_files_new();
+	CHECK(compaction_files_add(previous, COMPACTION_FILE_READ,
+	    "old.c", 5) == 0);
+	CHECK(compaction_files_add(previous, COMPACTION_FILE_READ,
+	    "promote.c", 9) == 0);
+	CHECK(compaction_files_add(previous, COMPACTION_FILE_MODIFIED,
+	    "prior-mod.c", 11) == 0);
+	current = compaction_files_new();
+	CHECK(compaction_files_add(current, COMPACTION_FILE_READ,
+	    "new.c", 5) == 0);
+	CHECK(compaction_files_add(current, COMPACTION_FILE_READ,
+	    "prior-mod.c", 11) == 0);
+	CHECK(compaction_files_add(current, COMPACTION_FILE_MODIFIED,
+	    "promote.c", 9) == 0);
+
+	CHECK(compaction_files_merge(current, previous) == 0);
+	CHECK(compaction_files_count(current, COMPACTION_FILE_READ) == 2);
+	CHECK(compaction_files_count(current, COMPACTION_FILE_MODIFIED) == 2);
+	/* Merge never mutates the prior fugu-generated details. */
+	CHECK(compaction_files_count(previous, COMPACTION_FILE_READ) == 2);
+	CHECK(compaction_files_count(previous, COMPACTION_FILE_MODIFIED) == 1);
+
+	buf_init(&out);
+	CHECK(compaction_files_append(&out, current, SIZE_MAX) == 0);
+	CHECK(buf_is(&out, expected, sizeof(expected) - 1));
+
+	buf_free(&out);
+	compaction_files_free(current);
+	compaction_files_free(previous);
+}
+
+static void
+test_file_safety_and_transactions(void)
+{
+	static const char expected[] =
+	    "notes\n\n"
+	    "<read-files>\n"
+	    "keep.c\n"
+	    "</read-files>\n\n"
+	    "<modified-files>\n"
+	    "a&lt;&amp;&gt;.c\n"
+	    "</modified-files>";
+	static const char empty_expected[] =
+	    "<read-files>\n"
+	    "</read-files>\n\n"
+	    "<modified-files>\n"
+	    "</modified-files>";
+	static const char malformed_utf8[] = {
+	    '{', '"', 'p', 'a', 't', 'h', '"', ':', '"', 'x',
+	    (char)0xc0, (char)0x80, '"', '}'
+	};
+	static const char nul_path[] = { 'x', '\0', 'y' };
+	static const char control_path[] = { 'x', 0x1f, 'y' };
+	static const char bad_utf8_path[] = {
+	    'x', (char)0xc0, (char)0x80, 'y'
+	};
+	struct compaction_files *empty, *files;
+	struct msglist	 messages;
+	struct msg	*message;
+	struct buf	 out;
+	char		*long_path;
+
+	files = compaction_files_new();
+	CHECK(compaction_files_add(files, COMPACTION_FILE_READ,
+	    "keep.c", 6) == 0);
+
+	/* Malformed, nested, non-string, and inexact tool calls are ignored. */
+	TAILQ_INIT(&messages);
+	message = msg_new(ROLE_ASSISTANT);
+	msg_add_tool_use(message, "1", "read", "{\"path\"}",
+	    strlen("{\"path\"}"));
+	msg_add_tool_use(message, "2", "read", "{\"path\":7}",
+	    strlen("{\"path\":7}"));
+	msg_add_tool_use(message, "3", "read",
+	    "{\"nested\":{\"path\":\"nested.c\"}}",
+	    strlen("{\"nested\":{\"path\":\"nested.c\"}}"));
+	msg_add_tool_use(message, "4", "reader", "{\"path\":\"reader.c\"}",
+	    strlen("{\"path\":\"reader.c\"}"));
+	msg_add_tool_use(message, "5", "shell", "{\"path\":\"shell.c\"}",
+	    strlen("{\"path\":\"shell.c\"}"));
+	msg_add_tool_use(message, "6", "read", malformed_utf8,
+	    sizeof(malformed_utf8));
+	append_message(&messages, message);
+	CHECK(compaction_files_collect(files, &messages) == 0);
+	CHECK(compaction_files_count(files, COMPACTION_FILE_READ) == 1);
+	CHECK(compaction_files_count(files, COMPACTION_FILE_MODIFIED) == 0);
+	msglist_free(&messages);
+
+	/* One rejected path rolls back earlier valid calls in the same span. */
+	TAILQ_INIT(&messages);
+	message = msg_new(ROLE_ASSISTANT);
+	msg_add_tool_use(message, "7", "read", "{\"path\":\"candidate.c\"}",
+	    strlen("{\"path\":\"candidate.c\"}"));
+	msg_add_tool_use(message, "8", "edit",
+	    "{\"path\":\"bad\\n</modified-files>\"}",
+	    strlen("{\"path\":\"bad\\n</modified-files>\"}"));
+	append_message(&messages, message);
+	CHECK(compaction_files_collect(files, &messages) == -1);
+	CHECK(compaction_files_count(files, COMPACTION_FILE_READ) == 1);
+	CHECK(compaction_files_count(files, COMPACTION_FILE_MODIFIED) == 0);
+	msglist_free(&messages);
+
+	CHECK(compaction_files_add(files, COMPACTION_FILE_READ, nul_path,
+	    sizeof(nul_path)) == -1);
+	CHECK(compaction_files_add(files, COMPACTION_FILE_READ, "", 0) == -1);
+	CHECK(compaction_files_add(files, COMPACTION_FILE_READ, control_path,
+	    sizeof(control_path)) == -1);
+	CHECK(compaction_files_add(files, COMPACTION_FILE_READ, bad_utf8_path,
+	    sizeof(bad_utf8_path)) == -1);
+	long_path = malloc(COMPACTION_FILE_PATH_MAX + 1);
+	CHECK(long_path != NULL);
+	if (long_path != NULL) {
+		memset(long_path, 'x', COMPACTION_FILE_PATH_MAX + 1);
+		CHECK(compaction_files_add(files, COMPACTION_FILE_READ, long_path,
+		    COMPACTION_FILE_PATH_MAX + 1) == -1);
+		free(long_path);
+	}
+	CHECK(compaction_files_count(files, COMPACTION_FILE_READ) == 1);
+	CHECK(compaction_files_add(files, COMPACTION_FILE_MODIFIED,
+	    "a<&>.c", 6) == 0);
+
+	/* A tight output bound cannot partially append generated structure. */
+	buf_init(&out);
+	buf_addstr(&out, "notes");
+	CHECK(compaction_files_append(&out, files, sizeof(expected) - 2) == -1);
+	CHECK(buf_is(&out, "notes", 5));
+	CHECK(compaction_files_append(&out, files, sizeof(expected) - 1) == 0);
+	CHECK(buf_is(&out, expected, sizeof(expected) - 1));
+	buf_free(&out);
+
+	/* Every generated summary gets both blocks, including empty sets. */
+	empty = compaction_files_new();
+	buf_init(&out);
+	CHECK(compaction_files_append(&out, empty,
+	    sizeof(empty_expected) - 2) == -1);
+	CHECK(out.len == 0);
+	CHECK(compaction_files_append(&out, empty,
+	    sizeof(empty_expected) - 1) == 0);
+	CHECK(buf_is(&out, empty_expected, sizeof(empty_expected) - 1));
+	buf_free(&out);
+	compaction_files_free(empty);
+	compaction_files_free(files);
+}
+
 int
 main(void)
 {
@@ -429,6 +649,9 @@ main(void)
 	test_result_boundaries();
 	test_previous_summary();
 	test_document_bound();
+	test_file_collection_and_rendering();
+	test_file_cumulative_merge();
+	test_file_safety_and_transactions();
 
 	REGRESS_END();
 }
blob - 055f699049af7bf78409f053534a3cf087ad2171
blob + ef442a546e46c57af45ba8512f9ea51db50de6a0
--- src/common/compaction.c
+++ src/common/compaction.c
@@ -26,6 +26,7 @@
 #include "json.h"
 #include "msg.h"
 #include "utf8.h"
+#include "xmalloc.h"
 #include "compaction.h"
 
 const char compaction_system_prompt[] =
@@ -50,7 +51,377 @@ struct compact_writer {
 	int		 records;
 };
 
+struct compaction_file {
+	u_char	*path;
+	size_t	 len;
+};
+
+struct compaction_file_set {
+	struct compaction_file	*files;
+	size_t			 len;
+	size_t			 cap;
+};
+
+struct compaction_files {
+	struct compaction_file_set	 read;
+	struct compaction_file_set	 modified;
+	size_t				 bytes;
+};
+
+static void
+file_set_free(struct compaction_file_set *set)
+{
+	size_t	i;
+
+	for (i = 0; i < set->len; i++)
+		free(set->files[i].path);
+	free(set->files);
+	memset(set, 0, sizeof(*set));
+}
+
+struct compaction_files *
+compaction_files_new(void)
+{
+	return (xcalloc(1, sizeof(struct compaction_files)));
+}
+
+void
+compaction_files_free(struct compaction_files *files)
+{
+	if (files == NULL)
+		return;
+	file_set_free(&files->read);
+	file_set_free(&files->modified);
+	free(files);
+}
+
 static int
+path_cmp(const void *ap, size_t alen, const void *bp, size_t blen)
+{
+	size_t	len;
+	int	cmp;
+
+	len = alen < blen ? alen : blen;
+	cmp = len == 0 ? 0 : memcmp(ap, bp, len);
+	if (cmp != 0)
+		return (cmp);
+	if (alen < blen)
+		return (-1);
+	if (alen > blen)
+		return (1);
+	return (0);
+}
+
+static int
+file_set_find(const struct compaction_file_set *set, const void *path,
+    size_t pathlen, size_t *at)
+{
+	size_t	lo, hi, mid;
+	int	cmp;
+
+	lo = 0;
+	hi = set->len;
+	while (lo < hi) {
+		mid = lo + (hi - lo) / 2;
+		cmp = path_cmp(path, pathlen, set->files[mid].path,
+		    set->files[mid].len);
+		if (cmp == 0) {
+			*at = mid;
+			return (1);
+		}
+		if (cmp < 0)
+			hi = mid;
+		else
+			lo = mid + 1;
+	}
+	*at = lo;
+	return (0);
+}
+
+static void
+file_set_insert(struct compaction_file_set *set, size_t at,
+    const void *path, size_t pathlen)
+{
+	u_char	*copy;
+	size_t	 cap;
+
+	copy = xmalloc(pathlen + 1);
+	if (pathlen > 0)
+		memcpy(copy, path, pathlen);
+	copy[pathlen] = '\0';
+	if (set->len == set->cap) {
+		cap = set->cap == 0 ? 16 : set->cap * 2;
+		if (cap > COMPACTION_FILE_COUNT_MAX)
+			cap = COMPACTION_FILE_COUNT_MAX;
+		set->files = xreallocarray(set->files, cap,
+		    sizeof(*set->files));
+		set->cap = cap;
+	}
+	if (at < set->len)
+		memmove(&set->files[at + 1], &set->files[at],
+		    (set->len - at) * sizeof(*set->files));
+	set->files[at].path = copy;
+	set->files[at].len = pathlen;
+	set->len++;
+}
+
+static void
+file_set_remove(struct compaction_file_set *set, size_t at)
+{
+	free(set->files[at].path);
+	if (at + 1 < set->len)
+		memmove(&set->files[at], &set->files[at + 1],
+		    (set->len - at - 1) * sizeof(*set->files));
+	set->len--;
+}
+
+static int
+path_valid(const void *path, size_t pathlen)
+{
+	const u_char	*p = path;
+	uint32_t	 cp;
+	size_t		 off, used;
+
+	if (pathlen == 0 || pathlen > COMPACTION_FILE_PATH_MAX || path == NULL)
+		return (0);
+	for (off = 0; off < pathlen; off += used) {
+		used = utf8_decode(p + off, pathlen - off, &cp);
+		if ((cp == UTF8_REPLACEMENT && used == 1) || cp < 0x20 ||
+		    (cp >= 0x7f && cp <= 0x9f) || cp == 0x2028 ||
+		    cp == 0x2029)
+			return (0);
+	}
+	return (1);
+}
+
+static size_t
+files_count(const struct compaction_files *files)
+{
+	return (files->read.len + files->modified.len);
+}
+
+static int
+files_add(struct compaction_files *files, enum compaction_file_kind kind,
+    const void *path, size_t pathlen)
+{
+	size_t	at, read_at;
+
+	if (!path_valid(path, pathlen))
+		return (-1);
+	if (file_set_find(&files->modified, path, pathlen, &at))
+		return (0);
+	if (kind == COMPACTION_FILE_READ) {
+		if (file_set_find(&files->read, path, pathlen, &at))
+			return (0);
+		if (files_count(files) == COMPACTION_FILE_COUNT_MAX ||
+		    pathlen > COMPACTION_FILE_BYTES_MAX - files->bytes)
+			return (-1);
+		file_set_insert(&files->read, at, path, pathlen);
+		files->bytes += pathlen;
+		return (0);
+	}
+	if (kind != COMPACTION_FILE_MODIFIED)
+		return (-1);
+	if (file_set_find(&files->read, path, pathlen, &read_at)) {
+		file_set_remove(&files->read, read_at);
+		file_set_find(&files->modified, path, pathlen, &at);
+		file_set_insert(&files->modified, at, path, pathlen);
+		return (0);
+	}
+	if (files_count(files) == COMPACTION_FILE_COUNT_MAX ||
+	    pathlen > COMPACTION_FILE_BYTES_MAX - files->bytes)
+		return (-1);
+	file_set_insert(&files->modified, at, path, pathlen);
+	files->bytes += pathlen;
+	return (0);
+}
+
+static struct compaction_files *
+files_clone(const struct compaction_files *src)
+{
+	struct compaction_files	*copy;
+	size_t			 i;
+
+	copy = compaction_files_new();
+	for (i = 0; i < src->read.len; i++)
+		file_set_insert(&copy->read, copy->read.len,
+		    src->read.files[i].path, src->read.files[i].len);
+	for (i = 0; i < src->modified.len; i++)
+		file_set_insert(&copy->modified, copy->modified.len,
+		    src->modified.files[i].path, src->modified.files[i].len);
+	copy->bytes = src->bytes;
+	return (copy);
+}
+
+static void
+files_publish(struct compaction_files *dst, struct compaction_files *src)
+{
+	struct compaction_files old;
+
+	old = *dst;
+	*dst = *src;
+	*src = old;
+	compaction_files_free(src);
+}
+
+int
+compaction_files_add(struct compaction_files *files,
+    enum compaction_file_kind kind, const void *path, size_t pathlen)
+{
+	struct compaction_files	*candidate;
+
+	if (files == NULL)
+		return (-1);
+	candidate = files_clone(files);
+	if (files_add(candidate, kind, path, pathlen) == -1) {
+		compaction_files_free(candidate);
+		return (-1);
+	}
+	files_publish(files, candidate);
+	return (0);
+}
+
+static int
+tool_file_kind(const char *name, enum compaction_file_kind *kind)
+{
+	if (name == NULL)
+		return (0);
+	if (strcmp(name, "read") == 0) {
+		*kind = COMPACTION_FILE_READ;
+		return (1);
+	}
+	if (strcmp(name, "write") == 0 || strcmp(name, "edit") == 0 ||
+	    strcmp(name, "multi_edit") == 0) {
+		*kind = COMPACTION_FILE_MODIFIED;
+		return (1);
+	}
+	return (0);
+}
+
+static int
+collect_block(struct compaction_files *files, const struct block *block)
+{
+	struct json		 json;
+	enum compaction_file_kind kind;
+	char			*path;
+	size_t			 pathlen;
+	int			 path_token;
+
+	if (block->type != BLOCK_TOOL_USE ||
+	    !tool_file_kind(block->tool_name, &kind))
+		return (0);
+	if ((block->tool_input_len > 0 && block->tool_input == NULL) ||
+	    !json_valid_object(block->tool_input, block->tool_input_len) ||
+	    json_parse(&json, block->tool_input, block->tool_input_len, 0) == -1)
+		return (0);
+	path_token = json_obj_get(&json, json_root(&json), "path");
+	if (path_token == -1 || !json_is_string(&json, path_token)) {
+		json_done(&json);
+		return (0);
+	}
+	path = json_get_str(&json, path_token, &pathlen);
+	json_done(&json);
+	if (path == NULL)
+		return (-1);
+	if (files_add(files, kind, path, pathlen) == -1) {
+		free(path);
+		return (-1);
+	}
+	free(path);
+	return (0);
+}
+
+int
+compaction_files_collect(struct compaction_files *files,
+    const struct msglist *messages)
+{
+	const struct msg		*message;
+	const struct block	*block;
+	struct compaction_files	*candidate;
+
+	if (files == NULL || messages == NULL)
+		return (-1);
+	candidate = files_clone(files);
+	TAILQ_FOREACH(message, messages, entry) {
+		if (message->role != ROLE_ASSISTANT)
+			continue;
+		TAILQ_FOREACH(block, &message->blocks, entry) {
+			if (collect_block(candidate, block) == -1) {
+				compaction_files_free(candidate);
+				return (-1);
+			}
+		}
+	}
+	files_publish(files, candidate);
+	return (0);
+}
+
+int
+compaction_files_merge(struct compaction_files *files,
+    const struct compaction_files *previous)
+{
+	struct compaction_files	*candidate;
+	size_t			 i;
+
+	if (files == NULL || previous == NULL)
+		return (-1);
+	candidate = files_clone(files);
+	for (i = 0; i < previous->read.len; i++) {
+		if (files_add(candidate, COMPACTION_FILE_READ,
+		    previous->read.files[i].path,
+		    previous->read.files[i].len) == -1)
+			goto fail;
+	}
+	for (i = 0; i < previous->modified.len; i++) {
+		if (files_add(candidate, COMPACTION_FILE_MODIFIED,
+		    previous->modified.files[i].path,
+		    previous->modified.files[i].len) == -1)
+			goto fail;
+	}
+	files_publish(files, candidate);
+	return (0);
+
+fail:
+	compaction_files_free(candidate);
+	return (-1);
+}
+
+size_t
+compaction_files_count(const struct compaction_files *files,
+    enum compaction_file_kind kind)
+{
+	if (files == NULL)
+		return (0);
+	if (kind == COMPACTION_FILE_READ)
+		return (files->read.len);
+	if (kind == COMPACTION_FILE_MODIFIED)
+		return (files->modified.len);
+	return (0);
+}
+
+int
+compaction_files_get(const struct compaction_files *files,
+    enum compaction_file_kind kind, size_t index, const void **path,
+    size_t *pathlen)
+{
+	const struct compaction_file_set	*set;
+
+	if (files == NULL || path == NULL || pathlen == NULL)
+		return (-1);
+	if (kind == COMPACTION_FILE_READ)
+		set = &files->read;
+	else if (kind == COMPACTION_FILE_MODIFIED)
+		set = &files->modified;
+	else
+		return (-1);
+	if (index >= set->len)
+		return (-1);
+	*path = set->files[index].path;
+	*pathlen = set->files[index].len;
+	return (0);
+}
+
+static int
 writer_add(struct compact_writer *w, const void *data, size_t len)
 {
 	if (w->out->len > w->maxlen || len > w->maxlen - w->out->len)
@@ -78,6 +449,88 @@ record_begin(struct compact_writer *w, const char *lab
 }
 
 static int
+file_path_add(struct compact_writer *writer,
+    const struct compaction_file *file)
+{
+	size_t	i, start;
+
+	start = 0;
+	for (i = 0; i < file->len; i++) {
+		const char *escaped;
+
+		switch (file->path[i]) {
+		case '&':
+			escaped = "&amp;";
+			break;
+		case '<':
+			escaped = "&lt;";
+			break;
+		case '>':
+			escaped = "&gt;";
+			break;
+		default:
+			continue;
+		}
+		if (writer_add(writer, file->path + start, i - start) == -1 ||
+		    writer_addstr(writer, escaped) == -1)
+			return (-1);
+		start = i + 1;
+	}
+	return (writer_add(writer, file->path + start, file->len - start));
+}
+
+static int
+file_block_add(struct compact_writer *writer, const char *name,
+    const struct compaction_file_set *set)
+{
+	size_t	i;
+
+	if (writer_addstr(writer, "<") == -1 ||
+	    writer_addstr(writer, name) == -1 ||
+	    writer_addstr(writer, ">\n") == -1)
+		return (-1);
+	for (i = 0; i < set->len; i++) {
+		if (file_path_add(writer, &set->files[i]) == -1 ||
+		    writer_addstr(writer, "\n") == -1)
+			return (-1);
+	}
+	if (writer_addstr(writer, "</") == -1 ||
+	    writer_addstr(writer, name) == -1 ||
+	    writer_addstr(writer, ">") == -1)
+		return (-1);
+	return (0);
+}
+
+int
+compaction_files_append(struct buf *out, const struct compaction_files *files,
+    size_t maxlen)
+{
+	struct compact_writer	 writer;
+	struct buf		 rendered;
+
+	if (out == NULL || files == NULL || out->len > maxlen)
+		return (-1);
+	buf_init(&rendered);
+	memset(&writer, 0, sizeof(writer));
+	writer.out = &rendered;
+	writer.maxlen = SIZE_MAX;
+	if ((out->len > 0 && writer_addstr(&writer, "\n\n") == -1) ||
+	    file_block_add(&writer, "read-files", &files->read) == -1 ||
+	    writer_addstr(&writer, "\n\n") == -1 ||
+	    file_block_add(&writer, "modified-files", &files->modified) == -1)
+		goto fail;
+	if (rendered.len > maxlen - out->len)
+		goto fail;
+	buf_add(out, rendered.data, rendered.len);
+	buf_free(&rendered);
+	return (0);
+
+fail:
+	buf_free(&rendered);
+	return (-1);
+}
+
+static int
 tool_value_add(struct compact_writer *w, const struct json *json, int value)
 {
 	const u_char	*raw;
blob - b3ac9a218e357dcc1899f34c2a50993aadd2ee91
blob + 62c4147f7e26c32d7ff9b860a4bdabea7274fd7d
--- src/common/compaction.h
+++ src/common/compaction.h
@@ -20,10 +20,19 @@
 #include <sys/types.h>
 
 struct buf;
+struct compaction_files;
 struct msglist;
 
 #define COMPACTION_TOOL_RESULT_MAX	2000
+#define COMPACTION_FILE_PATH_MAX	4096
+#define COMPACTION_FILE_COUNT_MAX	4096
+#define COMPACTION_FILE_BYTES_MAX	(1024 * 1024)
 
+enum compaction_file_kind {
+	COMPACTION_FILE_READ,
+	COMPACTION_FILE_MODIFIED
+};
+
 extern const char compaction_system_prompt[];
 
 /*
@@ -36,4 +45,26 @@ extern const char compaction_system_prompt[];
 int	compaction_serialize(struct buf *, const struct msglist *, const void *,
 	    size_t, size_t);
 
+/*
+ * Structured file details carried across Compactions.  Paths are retained
+ * with explicit lengths, in bytewise order.  All mutating operations are
+ * transactional: validation or bound failure leaves the state unchanged.
+ */
+struct compaction_files *compaction_files_new(void);
+void	compaction_files_free(struct compaction_files *);
+int	compaction_files_add(struct compaction_files *,
+	    enum compaction_file_kind, const void *, size_t);
+int	compaction_files_collect(struct compaction_files *,
+	    const struct msglist *);
+int	compaction_files_merge(struct compaction_files *,
+	    const struct compaction_files *);
+size_t	compaction_files_count(const struct compaction_files *,
+	    enum compaction_file_kind);
+int	compaction_files_get(const struct compaction_files *,
+	    enum compaction_file_kind, size_t, const void **, size_t *);
+
+/* Append both generated XML-like blocks within maxlen. */
+int	compaction_files_append(struct buf *, const struct compaction_files *,
+	    size_t);
+
 #endif /* COMPACTION_H */