Commit Diff


commit - 91e58933b7fe2e7ffb5f1943b2bdada616824b69
commit + 11cb7bb8d4c70c4973e2647be12cfd08cadbc8df
blob - 108c066fffece98abe222353b3520c7fbb28d169
blob + 401a79bf7f548c9d585d90ad873ad32d7b0357fe
--- regress/turn/agent.sh
+++ regress/turn/agent.sh
@@ -465,6 +465,61 @@ ok "pool: exactly eight requests back off and retry on
     [ "$nretry" -eq 8 ]; echo $?)
 stop_stub
 
+# A complete but non-object tool input invalidates the child's Generation.
+# It must fail before accepting the assistant or starting a tool/follow-up
+# Generation; the Lead receives the bounded typed failure directly.
+rm -rf "$HOME/.fugu"
+start_stub AGENTCALL BADARGS BADARGSSYNTH BADARGSUNUSED
+write_conf
+run_fugu "exercise malformed subagent tool arguments"
+ok "malformed arguments: lead turn completes" $([ "$rc" -eq 0 ]; echo $?)
+n=$(grep -c '^--- .* ---$' "$dir/reqlog")
+ok "malformed arguments: no child follow-up generation ran" \
+    $([ "$n" -eq 3 ]; echo $?)
+request_section BADARGSSYNTH "$dir/badargs-lead"
+grep -q 'subagent provider error' "$dir/badargs-lead" && \
+    grep -q 'malformed tool arguments' "$dir/badargs-lead" && \
+    grep -q '"is_error":true' "$dir/badargs-lead"
+ok "malformed arguments: invalid Generation returns a tool error" $?
+grep -q 'agent\[reader\]: tool read' "$dir/errout" 2>/dev/null
+ok "malformed arguments: rejected call is never executed" \
+    $([ $? -ne 0 ]; echo $?)
+stop_stub
+
+# A normal provider stop with neither text nor tools is not a successful
+# empty report.  It is an invalid Generation returned to the Lead as an error.
+rm -rf "$HOME/.fugu"
+start_stub AGENTCALL EMPTY EMPTYSYNTH
+write_conf
+run_fugu "exercise an empty normal subagent Generation"
+ok "empty Generation: lead turn completes" $([ "$rc" -eq 0 ]; echo $?)
+n=$(grep -c '^--- .* ---$' "$dir/reqlog")
+ok "empty Generation: exactly one child request ran" \
+    $([ "$n" -eq 3 ]; echo $?)
+request_section EMPTYSYNTH "$dir/empty-lead"
+grep -q 'subagent provider error' "$dir/empty-lead" && \
+    grep -q 'provider sent an empty reply' "$dir/empty-lead" && \
+    grep -q '"is_error":true' "$dir/empty-lead"
+ok "empty Generation: invalid reply returns a tool error" $?
+stop_stub
+
+# An absent no-argument input is canonicalized to an object before both the
+# assistant Projection and execution, preserving a structurally valid pair.
+rm -rf "$HOME/.fugu"
+start_stub AGENTCALL NOARGS NOARGSREPORT NOARGSSYNTH
+write_conf
+run_fugu "exercise an absent subagent tool input"
+ok "absent arguments: lead turn completes" $([ "$rc" -eq 0 ]; echo $?)
+request_section NOARGSREPORT "$dir/noargs-followup"
+grep -q '"id":"toolu_noargs"' "$dir/noargs-followup" && \
+    grep -q '"name":"ls"' "$dir/noargs-followup" && \
+    grep -q '"input":{}' "$dir/noargs-followup" && \
+    grep -q '"tool_use_id":"toolu_noargs"' "$dir/noargs-followup"
+ok "absent arguments: canonical object and result are paired" $?
+grep -q 'agent\[reader\]: tool ls' "$dir/errout"
+ok "absent arguments: canonical object is executable" $?
+stop_stub
+
 # A token-limited text reply is not mistaken for a completed report.  Its
 # partial text is preserved, followed by one tool-free conclusion request.
 rm -rf "$HOME/.fugu"
blob - 9697df6b559601e6b8fc3a9031bb897f6d7d91ec
blob + 66ec0c8fa2bdac78fb1dc2dd24b423fb2e70e562
--- regress/turn/stub.c
+++ regress/turn/stub.c
@@ -22,7 +22,9 @@
  * error event), HTTP500 (a non-2xx before any stream), HTTP401 (an
  * Anthropic-style JSON error body), RETRY503/RETRY429 (retryable
  * statuses, 429 carrying Retry-After: 2), EMPTY (a well-formed
- * stream with no content blocks), WEBFETCH (a brokered web_fetch),
+ * stream with no content blocks), BADARGS (a complete but malformed
+ * subagent tool input), NOARGS (a subagent tool call with absent input),
+ * WEBFETCH (a brokered web_fetch),
  * BGTOOL (shell_bg sleep 300), ERROR_BG (the provider failure after
  * that launch), JOBOUTPUT (shell_output job 1), DOUBLE (a hostile error event
  * appended after a complete OK stream), AGENTCALL (lead delegates to a
@@ -910,6 +912,43 @@ static const char sse_empty[] =
 "event: message_stop\n"
 "data: {\"type\":\"message_stop\"}\n\n";
 
+/* A complete tool call whose argument bytes are not a JSON object. */
+static const char sse_badargs[] =
+"event: message_start\n"
+"data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+    "{\"input_tokens\":11,\"output_tokens\":1}}}\n\n"
+"event: content_block_start\n"
+"data: {\"type\":\"content_block_start\",\"index\":0,"
+    "\"content_block\":{\"type\":\"tool_use\",\"id\":"
+    "\"toolu_badargs\",\"name\":\"read\"}}\n\n"
+"event: content_block_delta\n"
+"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+    "{\"type\":\"input_json_delta\",\"partial_json\":\"[1]\"}}\n\n"
+"event: content_block_stop\n"
+"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n"
+"event: message_delta\n"
+"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+    "\"tool_use\"},\"usage\":{\"output_tokens\":1}}\n\n"
+"event: message_stop\n"
+"data: {\"type\":\"message_stop\"}\n\n";
+
+/* An optional-argument tool call with no input_json_delta at all. */
+static const char sse_noargs[] =
+"event: message_start\n"
+"data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+    "{\"input_tokens\":11,\"output_tokens\":1}}}\n\n"
+"event: content_block_start\n"
+"data: {\"type\":\"content_block_start\",\"index\":0,"
+    "\"content_block\":{\"type\":\"tool_use\",\"id\":"
+    "\"toolu_noargs\",\"name\":\"ls\"}}\n\n"
+"event: content_block_stop\n"
+"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n"
+"event: message_delta\n"
+"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+    "\"tool_use\"},\"usage\":{\"output_tokens\":1}}\n\n"
+"event: message_stop\n"
+"data: {\"type\":\"message_stop\"}\n\n";
+
 static const char http500[] =
 "HTTP/1.1 500 Internal Server Error\r\n"
 "content-length: 21\r\n\r\n"
@@ -1361,6 +1400,16 @@ main(int argc, char *argv[])
 				write_chunk(conn, sse_agentreport,
 				    strlen(sse_agentreport));
 				(void)tls_write_all(conn, "0\r\n\r\n", 5);
+			} else if (strcmp(argv[i], "BADARGSSYNTH") == 0) {
+				send_text(conn, "BADARGS-SYNTHESIS-MARKER");
+			} else if (strcmp(argv[i], "BADARGSUNUSED") == 0) {
+				send_text(conn, "BADARGS-OLD-FOLLOWUP-MARKER");
+			} else if (strcmp(argv[i], "EMPTYSYNTH") == 0) {
+				send_text(conn, "EMPTY-SYNTHESIS-MARKER");
+			} else if (strcmp(argv[i], "NOARGSREPORT") == 0) {
+				send_text(conn, "NOARGS-CHILD-REPORT");
+			} else if (strcmp(argv[i], "NOARGSSYNTH") == 0) {
+				send_text(conn, "NOARGS-SYNTHESIS-MARKER");
 			} else if (strcmp(argv[i], "FUTUREREPORT") == 0) {
 				send_text(conn, "FUTURE-LITERAL-PROTECTED-REPORT");
 			} else if (strcmp(argv[i], "FUTURESYNTH") == 0) {
@@ -1644,6 +1693,18 @@ main(int argc, char *argv[])
 				write_chunk(conn, sse_empty,
 				    strlen(sse_empty));
 				(void)tls_write_all(conn, "0\r\n\r\n", 5);
+			} else if (strcmp(argv[i], "BADARGS") == 0) {
+				(void)tls_write_all(conn, body_ok,
+				    strlen(body_ok));
+				write_chunk(conn, sse_badargs,
+				    strlen(sse_badargs));
+				(void)tls_write_all(conn, "0\r\n\r\n", 5);
+			} else if (strcmp(argv[i], "NOARGS") == 0) {
+				(void)tls_write_all(conn, body_ok,
+				    strlen(body_ok));
+				write_chunk(conn, sse_noargs,
+				    strlen(sse_noargs));
+				(void)tls_write_all(conn, "0\r\n\r\n", 5);
 			} else if (strcmp(argv[i], "DOUBLE") == 0) {
 				/* a hostile terminal after the terminal */
 				(void)tls_write_all(conn, body_ok,
blob - 1a4fe85c811b6807de7951646229f2ad3e684147
blob + fb7083b3591be50cbf6ff1e8e15d8630a1c70563
--- src/fugu-tool/agent.c
+++ src/fugu-tool/agent.c
@@ -38,7 +38,7 @@
 #include "log.h"
 #include "xmalloc.h"
 #include "buf.h"
-#include "json.h"
+#include "generation.h"
 #include "msg.h"
 #include "anthropic.h"
 #include "openai.h"
@@ -48,37 +48,19 @@
 #include "agent.h"
 
 #define AGENT_ITER_MAX	25
-#define AGENT_CALL_MAX	64
 #define AGENT_PROGRESS_MAX 256
 
-static const char agent_limited_empty[] =
-    "Generation stopped before emitting content.";
-
 #define OBJ(props, req) "{\"type\":\"object\",\"properties\":" props \
 			    ",\"required\":" req "}"
 
-struct agent_tcall {
-	int		 index;
-	char		 id[256];
-	char		 name[FUGU_TOOL_NAME_MAX];
-	struct buf	 input;
-	size_t		 input_total;
-	int		 malformed;
-};
-
 struct agent_state {
 	struct worker		*w;
 	struct tool_ctx		*tools;
 	struct agent_boot	*boot;
 	struct msglist		 conv;
 	int			 prev_msgs;
-	struct buf		 text;
 	struct buf		 report;
-	struct agent_tcall	 calls[AGENT_CALL_MAX];
-	int			 ncalls;
-	char			 stop[FUGU_STOP_REASON_MAX];
 	char			 err[FUGU_ERROR_MAX];
-	size_t			 arg_total;
 	size_t			 data_total;
 	int			 data_exceeded;
 	int			 iterations;
@@ -363,28 +345,6 @@ agent_build_request(struct agent_state *a, struct buf 
 	}
 }
 
-static struct agent_tcall *
-agent_call_find(struct agent_state *a, int index)
-{
-	int i;
-
-	for (i = 0; i < a->ncalls; i++)
-		if (a->calls[i].index == index)
-			return (&a->calls[i]);
-	return (NULL);
-}
-
-static void
-agent_calls_reset(struct agent_state *a)
-{
-	int i;
-
-	for (i = 0; i < a->ncalls; i++)
-		buf_free(&a->calls[i].input);
-	a->ncalls = 0;
-	a->arg_total = 0;
-}
-
 static int
 agent_account(struct agent_state *a, size_t len)
 {
@@ -396,21 +356,78 @@ agent_account(struct agent_state *a, size_t len)
 	return (0);
 }
 
+static enum generation_kind
+agent_generation_kind(uint32_t type)
+{
+	switch (type) {
+	case FUGU_IMSG_A_TEXT:
+		return (GENERATION_TEXT);
+	case FUGU_IMSG_A_TOOL_BEGIN:
+		return (GENERATION_TOOL_BEGIN);
+	case FUGU_IMSG_A_TOOL_INPUT:
+		return (GENERATION_TOOL_INPUT);
+	case FUGU_IMSG_A_USAGE:
+		return (GENERATION_USAGE);
+	case FUGU_IMSG_A_RETRY:
+		return (GENERATION_RETRY);
+	case FUGU_IMSG_A_DONE:
+		return (GENERATION_DONE);
+	case FUGU_IMSG_A_ERROR:
+		return (GENERATION_ERROR);
+	default:
+		fatalx("subagent unexpected imsg %u", type);
+	}
+}
+
+static void
+agent_invalid_error(struct agent_state *a,
+    enum generation_invalid_cause cause)
+{
+	const char *s;
+
+	switch (cause) {
+	case GENERATION_INVALID_DATA_BOUND:
+		s = "subagent data bound exceeded";
+		break;
+	case GENERATION_INVALID_USAGE_BOUND:
+		s = "subagent usage bound exceeded";
+		break;
+	case GENERATION_INVALID_TOOL_CALL_BOUND:
+		s = "subagent tool-call bound exceeded";
+		break;
+	case GENERATION_INVALID_EMPTY_REPLY:
+		s = "provider sent an empty reply";
+		break;
+	case GENERATION_INVALID_TOOL_ARGUMENTS:
+		s = "provider sent malformed tool arguments";
+		break;
+	case GENERATION_INVALID_NONE:
+	default:
+		fatalx("subagent invalid Generation without a cause");
+	}
+	strlcpy(a->err, s, sizeof(a->err));
+}
+
 /* Send one projection and collect its already-decoded provider events. */
 static int
 agent_generation(struct agent_state *a, const struct tool_def *defs,
-    int ndefs)
+    int ndefs, struct generation_result *result)
 {
-	struct buf body;
-	struct imsg imsg;
-	struct imsgev_payload payload;
-	const u_char *p;
-	size_t len;
-	uint32_t type;
+	struct a_usage		 au;
+	struct buf		 body;
+	struct generation	*g;
+	struct generation_event ev;
+	struct generation_usage gu;
+	struct imsg		 imsg;
+	struct imsgev_payload	 payload;
+	const void		*feed_data;
+	const u_char		*p;
+	size_t			 feed_len, len;
+	uint32_t		 type;
+	enum generation_feed_status status;
+	enum generation_kind	 kind;
 
-	agent_calls_reset(a);
-	buf_reset(&a->text);
-	a->stop[0] = '\0';
+	memset(result, 0, sizeof(*result));
 	a->err[0] = '\0';
 	buf_init(&body);
 	agent_build_request(a, &body, defs, ndefs);
@@ -419,6 +436,7 @@ agent_generation(struct agent_state *a, const struct t
 		strlcpy(a->err, "subagent request exceeds 16 MiB", sizeof(a->err));
 		return (-1);
 	}
+	g = generation_new(FUGU_TURN_DATA_MAX - a->data_total);
 	agent_progress(a, "requesting %s", a->boot->model);
 	agent_send_bytes(a, FUGU_IMSG_AGENT_REQUEST, body.data, body.len);
 	agent_send(a, FUGU_IMSG_AGENT_REQUEST_END, NULL, 0);
@@ -431,103 +449,74 @@ agent_generation(struct agent_state *a, const struct t
 			fatal("get subagent provider payload");
 		p = payload.data;
 		len = payload.len;
-		switch (type) {
-		case FUGU_IMSG_A_TEXT:
-			if (len > FUGU_RESPONSE_MAX - a->text.len) {
-				strlcpy(a->err, "subagent response exceeds 16 MiB",
-				    sizeof(a->err));
-				imsg_free(&imsg);
-				return (-1);
-			}
-			if (p != NULL && len > 0 &&
-			    agent_account(a, len) == 0)
-				buf_add(&a->text, p, len);
-			break;
-		case FUGU_IMSG_A_TOOL_BEGIN: {
-			struct a_tool_begin h;
-			struct agent_tcall *tc;
-			const char *id, *name;
-			size_t il, nl;
-
-			if (p == NULL || len < sizeof(h) ||
-			    a->ncalls >= AGENT_CALL_MAX)
-				fatalx("subagent malformed tool begin");
-			memcpy(&h, p, sizeof(h));
-			id = (const char *)p + sizeof(h);
-			il = strnlen(id, len - sizeof(h));
-			if (il == 0 || il == len - sizeof(h) || il >= 256)
-				fatalx("subagent malformed tool id");
-			name = id + il + 1;
-			nl = len - sizeof(h) - il - 1;
-			if (h.index < 0 || agent_call_find(a, h.index) != NULL ||
-			    nl == 0 || nl >= FUGU_TOOL_NAME_MAX ||
-			    memchr(name, '\0', nl) != NULL)
-				fatalx("subagent malformed tool name");
-			tc = &a->calls[a->ncalls++];
-			memset(tc, 0, sizeof(*tc));
-			tc->index = h.index;
-			memcpy(tc->id, id, il + 1);
-			memcpy(tc->name, name, nl);
-			tc->name[nl] = '\0';
-			buf_init(&tc->input);
-			break;
+		if (type == FUGU_IMSG_SHUTDOWN) {
+			imsg_free(&imsg);
+			exit(0);
 		}
-		case FUGU_IMSG_A_TOOL_INPUT: {
-			struct agent_tcall *tc;
-			int idx;
+		kind = agent_generation_kind(type);
+		feed_data = p;
+		feed_len = len;
+		if (kind == GENERATION_USAGE && p != NULL &&
+		    len == sizeof(au)) {
+			memcpy(&au, p, sizeof(au));
+			memset(&gu, 0, sizeof(gu));
+			gu.input_tokens = au.input_tokens;
+			gu.output_tokens = au.output_tokens;
+			gu.cache_read = au.cache_read;
+			gu.cache_write = au.cache_write;
+			feed_data = &gu;
+			feed_len = sizeof(gu);
+		}
+		status = generation_feed(g, kind, feed_data, feed_len, &ev);
+		if (status == GENERATION_PROTOCOL) {
+			const char *why = generation_error(g);
 
-			if (p == NULL || len < sizeof(idx))
-				fatalx("subagent malformed tool input");
-			memcpy(&idx, p, sizeof(idx));
-			tc = agent_call_find(a, idx);
-			if (tc == NULL || len - sizeof(idx) >
-			    FUGU_TOOL_ARG_MAX - tc->input_total || len - sizeof(idx) >
-			    FUGU_TOOL_ARGS_TOTAL_MAX - a->arg_total)
-				fatalx("subagent excessive tool input");
-			len -= sizeof(idx);
-			if (agent_account(a, len) == 0)
-				buf_add(&tc->input, p + sizeof(idx), len);
-			tc->input_total += len;
-			a->arg_total += len;
-			break;
+			imsg_free(&imsg);
+			fatalx("subagent provider protocol: %s",
+			    why != NULL ? why : "invalid Generation event");
 		}
-		case FUGU_IMSG_A_USAGE:
-			if (p == NULL || len != sizeof(struct a_usage))
-				fatalx("subagent malformed usage");
-			agent_send(a, FUGU_IMSG_AGENT_USAGE, p, len);
-			break;
-		case FUGU_IMSG_A_RETRY:
+		if (status == GENERATION_MORE && kind == GENERATION_USAGE) {
+			memset(&au, 0, sizeof(au));
+			au.input_tokens = ev.usage.input_tokens;
+			au.output_tokens = ev.usage.output_tokens;
+			au.cache_read = ev.usage.cache_read;
+			au.cache_write = ev.usage.cache_write;
+			agent_send(a, FUGU_IMSG_AGENT_USAGE, &au, sizeof(au));
+		} else if (status == GENERATION_MORE &&
+		    kind == GENERATION_RETRY)
 			agent_progress(a, "provider retry");
-			break;
-		case FUGU_IMSG_A_DONE:
-			if (p == NULL || len == 0 || len >= sizeof(a->stop) ||
-			    memchr(p, '\0', len) != NULL)
-				fatalx("subagent malformed stop reason");
-			memcpy(a->stop, p, len);
-			a->stop[len] = '\0';
-			imsg_free(&imsg);
-			if (a->data_exceeded) {
-				strlcpy(a->err, "subagent data bound exceeded",
-				    sizeof(a->err));
-				return (-1);
-			}
+		imsg_free(&imsg);
+		if (status == GENERATION_MORE || status == GENERATION_DRAIN)
+			continue;
+		if (status != GENERATION_TERMINAL)
+			fatalx("subagent Generation returned an unknown feed status");
+		if (generation_take(g, result) == -1)
+			fatalx("subagent could not take terminal Generation");
+		if (result->data_used > FUGU_TURN_DATA_MAX - a->data_total)
+			fatalx("subagent Generation violated its data budget");
+		a->data_total += result->data_used;
+		if (result->outcome == GENERATION_NORMAL ||
+		    result->outcome == GENERATION_LENGTH) {
+			if (result->assistant == NULL ||
+			    result->invalid_cause != GENERATION_INVALID_NONE)
+				fatalx("successful subagent Generation has no assistant");
+			result->reason = NULL;
+			generation_free(g);
 			return (0);
-		case FUGU_IMSG_A_ERROR:
-			if (p == NULL || len == 0 || len >= sizeof(a->err) ||
-			    memchr(p, '\0', len) != NULL)
-				fatalx("subagent malformed provider error");
-			memcpy(a->err, p, len);
-			a->err[len] = '\0';
-			imsg_free(&imsg);
-			return (-1);
-		case FUGU_IMSG_SHUTDOWN:
-			imsg_free(&imsg);
-			exit(0);
-		default:
-			fatalx("subagent unexpected imsg %u", type);
 		}
-		imsg_free(&imsg);
+		if (result->assistant != NULL)
+			fatalx("failed subagent Generation returned an assistant");
+		if (result->outcome == GENERATION_PROVIDER_ERROR)
+			strlcpy(a->err, result->reason, sizeof(a->err));
+		else if (result->outcome == GENERATION_INVALID)
+			agent_invalid_error(a, result->invalid_cause);
+		else
+			fatalx("subagent Generation returned an unknown outcome");
+		result->reason = NULL;
+		generation_free(g);
+		return (-1);
 	}
+	generation_free(g);
 	strlcpy(a->err, "coordinator closed the subagent channel", sizeof(a->err));
 	return (-1);
 }
@@ -549,7 +538,7 @@ agent_local_allowed(const char *name)
 }
 
 static void
-agent_web(struct agent_state *a, struct agent_tcall *tc, struct buf *out,
+agent_web(struct agent_state *a, const struct block *call, struct buf *out,
     int *is_error)
 {
 	struct tool_req req;
@@ -560,10 +549,10 @@ agent_web(struct agent_state *a, struct agent_tcall *t
 	size_t len;
 
 	memset(&req, 0, sizeof(req));
-	strlcpy(req.name, tc->name, sizeof(req.name));
+	strlcpy(req.name, call->tool_name, sizeof(req.name));
 	agent_send(a, FUGU_IMSG_AGENT_WEB_REQ, &req, sizeof(req));
-	agent_send_bytes(a, FUGU_IMSG_AGENT_WEB_ARG, tc->input.data,
-	    tc->input.len);
+	agent_send_bytes(a, FUGU_IMSG_AGENT_WEB_ARG, call->tool_input,
+	    call->tool_input_len);
 	agent_send(a, FUGU_IMSG_AGENT_WEB_RUN, NULL, 0);
 	buf_reset(out);
 	*is_error = 1;
@@ -599,35 +588,6 @@ agent_web(struct agent_state *a, struct agent_tcall *t
 	exit(0);
 }
 
-static struct msg *
-agent_assistant_message(struct agent_state *a)
-{
-	struct msg *m;
-	int i;
-
-	m = msg_new(ROLE_ASSISTANT);
-	if (a->text.len > 0)
-		msg_add_text(m, (const char *)a->text.data, a->text.len);
-	else if (a->ncalls == 0 &&
-	    (strcmp(a->stop, "max_tokens") == 0 ||
-	    strcmp(a->stop, "length") == 0))
-		msg_add_text(m, agent_limited_empty,
-		    sizeof(agent_limited_empty) - 1);
-	for (i = 0; i < a->ncalls; i++) {
-		if (a->calls[i].input.len == 0)
-			buf_addstr(&a->calls[i].input, "{}");
-		else if (!json_valid_object(a->calls[i].input.data,
-		    a->calls[i].input.len)) {
-			buf_reset(&a->calls[i].input);
-			buf_addstr(&a->calls[i].input, "{}");
-			a->calls[i].malformed = 1;
-		}
-		msg_add_tool_use(m, a->calls[i].id, a->calls[i].name,
-		    (const char *)a->calls[i].input.data, a->calls[i].input.len);
-	}
-	return (m);
-}
-
 static void
 agent_account_result(struct agent_state *a, struct buf *out, int *is_error)
 {
@@ -652,89 +612,86 @@ agent_account_result(struct agent_state *a, struct buf
 }
 
 static struct msg *
-agent_execute_calls(struct agent_state *a)
+agent_execute_calls(struct agent_state *a, const struct msg *assistant)
 {
-	struct msg *m;
-	struct buf out;
-	int i, is_error;
+	struct block	*call;
+	struct msg	*m;
+	struct buf	 out;
+	int		 is_error;
 
 	m = msg_new(ROLE_USER);
 	buf_init(&out);
-	for (i = 0; i < a->ncalls; i++) {
-		struct agent_tcall *tc = &a->calls[i];
-
+	TAILQ_FOREACH(call, &assistant->blocks, entry) {
+		if (call->type != BLOCK_TOOL_USE)
+			continue;
 		if (a->data_exceeded) {
 			buf_reset(&out);
 			buf_addstr(&out, "subagent data bound exceeded");
 			is_error = 1;
 		}
-		if (!a->data_exceeded && tc->malformed) {
-			buf_reset(&out);
-			buf_addstr(&out, "subagent: malformed tool arguments");
-			is_error = 1;
-		} else if (!a->data_exceeded && agent_local_allowed(tc->name)) {
-			agent_progress(a, "tool %s", tc->name);
-			tool_exec(a->tools, tc->name, tc->input.data, tc->input.len,
-			    &out, &is_error);
-		} else if (!a->data_exceeded && agent_is_web(tc->name) &&
-		    ((strcmp(tc->name, "http_request") == 0 &&
+		if (!a->data_exceeded && agent_local_allowed(call->tool_name)) {
+			agent_progress(a, "tool %s", call->tool_name);
+			tool_exec(a->tools, call->tool_name, call->tool_input,
+			    call->tool_input_len, &out, &is_error);
+		} else if (!a->data_exceeded && agent_is_web(call->tool_name) &&
+		    ((strcmp(call->tool_name, "http_request") == 0 &&
 		    a->boot->start.http_request) ||
-		    (strcmp(tc->name, "http_request") != 0 &&
+		    (strcmp(call->tool_name, "http_request") != 0 &&
 		    a->boot->start.web_search))) {
-			agent_progress(a, "tool %s", tc->name);
-			agent_web(a, tc, &out, &is_error);
+			agent_progress(a, "tool %s", call->tool_name);
+			agent_web(a, call, &out, &is_error);
 		} else if (!a->data_exceeded) {
 			buf_reset(&out);
 			buf_addf(&out, "%s is unavailable to a read-only subagent",
-			    tc->name);
+			    call->tool_name);
 			is_error = 1;
 		}
 		agent_account_result(a, &out, &is_error);
-		msg_add_tool_result(m, tc->id, (const char *)out.data, out.len,
-		    is_error);
+		msg_add_tool_result(m, call->tool_id, (const char *)out.data,
+		    out.len, is_error);
 	}
 	buf_free(&out);
 	return (m);
 }
 
 static struct msg *
-agent_answer_dangling(struct agent_state *a)
+agent_answer_dangling(struct agent_state *a, const struct msg *assistant)
 {
 	static const char reason[] =
 	    "subagent tool call was truncated and was not executed";
-	struct msg *m;
-	struct buf out;
-	int i, is_error;
+	struct block	*call;
+	struct msg	*m;
+	struct buf	 out;
+	int		 is_error;
 
 	m = msg_new(ROLE_USER);
 	buf_init(&out);
-	for (i = 0; i < a->ncalls; i++) {
+	TAILQ_FOREACH(call, &assistant->blocks, entry) {
+		if (call->type != BLOCK_TOOL_USE)
+			continue;
 		buf_reset(&out);
 		buf_add(&out, reason, sizeof(reason) - 1);
 		is_error = 1;
 		agent_account_result(a, &out, &is_error);
-		msg_add_tool_result(m, a->calls[i].id,
+		msg_add_tool_result(m, call->tool_id,
 		    (const char *)out.data, out.len, is_error);
 	}
 	buf_free(&out);
 	return (m);
 }
 
-static int
-agent_stop_limited(const struct agent_state *a)
-{
-	return (strcmp(a->stop, "max_tokens") == 0 ||
-	    strcmp(a->stop, "length") == 0);
-}
-
 static void
-agent_report_text(struct agent_state *a)
+agent_report_text(struct agent_state *a, const struct msg *assistant)
 {
-	if (a->text.len == 0)
-		return;
-	if (a->report.len > 0)
-		buf_addstr(&a->report, "\n\n");
-	buf_add(&a->report, a->text.data, a->text.len);
+	struct block *text;
+
+	TAILQ_FOREACH(text, &assistant->blocks, entry) {
+		if (text->type != BLOCK_TEXT || text->textlen == 0)
+			continue;
+		if (a->report.len > 0)
+			buf_addstr(&a->report, "\n\n");
+		buf_add(&a->report, text->text, text->textlen);
+	}
 }
 
 static void
@@ -802,12 +759,14 @@ agent_conclude(struct agent_state *a, struct msg *m)
 	    "execution bound. Do not call tools. "
 	    "Conclude now with the most useful concise final report possible, "
 	    "including uncertainty and unfinished checks.";
+	struct generation_result result;
 
 	msg_add_text(m, conclude, sizeof(conclude) - 1);
 	agent_report_tail(a);
-	if (agent_generation(a, NULL, 0) == 0)
-		agent_report_text(a);
-	else if (a->err[0] != '\0') {
+	if (agent_generation(a, NULL, 0, &result) == 0) {
+		agent_report_text(a, result.assistant);
+		msg_free(result.assistant);
+	} else if (a->err[0] != '\0') {
 		if (a->report.len > 0)
 			buf_addstr(&a->report, "\n\n");
 		buf_addf(&a->report, "subagent conclusion failed: %s", a->err);
@@ -821,6 +780,7 @@ agent_run(struct worker *w, struct tool_ctx *tools, st
 {
 	struct agent_state a;
 	const struct tool_def *defs;
+	struct generation_result result;
 	struct msg *m;
 	int ndefs, round;
 
@@ -829,7 +789,6 @@ agent_run(struct worker *w, struct tool_ctx *tools, st
 	a.tools = tools;
 	a.boot = boot;
 	TAILQ_INIT(&a.conv);
-	buf_init(&a.text);
 	buf_init(&a.report);
 	a.data_total = boot->prompt.len;
 	m = msg_new(ROLE_USER);
@@ -839,34 +798,34 @@ agent_run(struct worker *w, struct tool_ctx *tools, st
 	agent_progress(&a, "started");
 
 	for (round = 1; round <= AGENT_ITER_MAX; round++) {
-		if (agent_generation(&a, defs, ndefs) == -1) {
+		if (agent_generation(&a, defs, ndefs, &result) == -1) {
 			agent_report_tail(&a);
 			if (a.report.len > 0)
 				buf_addstr(&a.report, "\n\n");
 			buf_addf(&a.report, "subagent provider error: %s", a.err);
 			agent_finish(&a, 1);
 		}
-		m = agent_assistant_message(&a);
+		m = result.assistant;
 		TAILQ_INSERT_TAIL(&a.conv, m, entry);
-		if (agent_stop_limited(&a)) {
-			agent_report_text(&a);
-			m = agent_answer_dangling(&a);
+		if (result.outcome == GENERATION_LENGTH) {
+			agent_report_text(&a, m);
+			m = agent_answer_dangling(&a, m);
 			TAILQ_INSERT_TAIL(&a.conv, m, entry);
 			agent_conclude(&a, m);
 		}
-		if (a.ncalls == 0) {
+		if (result.tool_calls == 0) {
 			/* A normal completion returns only its final assistant text;
 			 * interim prose is retained solely as a cap fallback. */
 			buf_reset(&a.report);
-			agent_report_text(&a);
+			agent_report_text(&a, m);
 			agent_finish(&a, 0);
 		}
 		/* Preserve tool-bearing prose so an iteration cap can still return
 		 * everything useful emitted before its one conclusion attempt. */
-		agent_report_text(&a);
+		agent_report_text(&a, m);
 
 		a.iterations++;
-		m = agent_execute_calls(&a);
+		m = agent_execute_calls(&a, m);
 		TAILQ_INSERT_TAIL(&a.conv, m, entry);
 		if (a.data_exceeded) {
 			agent_report_tail(&a);