Skip to content

Commit ee0464d

Browse files
kevin9327claude
andauthored
End an empty reply on a line the surface receives, not one only the graph sees (#292)
A reply with no text and no tool call ends the graph — the conditional edge sees no calls and stops. #289 tried to give that run a visible line by substituting a fallback AIMessage into the graph's state (withVisibleReply), so the CHANGELOG already promises "an empty reply ends on a visible line rather than in silence". It never reached the person. This service streams AG-UI by reading the run's framework events, and it emits text only from on_chat_model_stream (the model's own deltas) and tool events from the tools node. A message a graph node returns is never one of those, so a fallback placed in state is a fallback the surface never sees: an empty reply still ended on a bare RUN_STARTED/ RUN_FINISHED pair with nothing between them. Proof, driving the real reader over a real graph with a fake empty model: the graph's final state held the fallback, and the client received only ["RUN_FINISHED"]. The guard belongs on the wire, where the surface reads. streamRun now tracks whether anything a person can see reached it — a line of prose or a tool call — and ends a run that produced neither on the same fallback line. "Visible" is decided by textOfChunk, the one rule the streamed deltas already use, so a reply that is only a Responses-API reasoning summary (text the person is never shown) counts as empty here too — the case the state-based guard got wrong, since hasVisibleText read any `text` field as visible. The translation is extracted to stream.ts for the same reason history.ts and deltas.ts are: index.ts calls serve() at module scope, so importing runAgent to test it binds a port. stream.ts imports no runtime module beyond deltas, so its tests need no provider key and no network. Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fbbe2c6 commit ee0464d

3 files changed

Lines changed: 500 additions & 197 deletions

File tree

agent-langgraph/src/index.ts

Lines changed: 14 additions & 197 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import {
1212
import { ChatOpenAI } from "@langchain/openai";
1313
import { serve } from "bun";
1414
import { hasManagedAgentToken } from "../../shared/agent-authorisation";
15-
import { textOfChunk } from "./deltas";
1615
import { toLangChainMessages } from "./history";
1716
import { readReasoningEffort } from "./model-options";
17+
import { streamRun } from "./stream";
1818

1919
/**
2020
* The same Bot, on a framework.
@@ -332,9 +332,7 @@ function buildGraph(input: RunAgentInput) {
332332

333333
return new StateGraph(MessagesAnnotation)
334334
.addNode("answer", async (state) => ({
335-
messages: [
336-
withVisibleReply((await bound.invoke(state.messages)) as AIMessage),
337-
],
335+
messages: [await bound.invoke(state.messages)],
338336
}))
339337
.addNode("tools", async (state) => {
340338
const last = state.messages.at(-1) as AIMessage;
@@ -386,37 +384,6 @@ function buildGraph(input: RunAgentInput) {
386384
.compile();
387385
}
388386

389-
/**
390-
* A reply with nothing in it ends the run in silence, so give it a line to end on.
391-
*
392-
* When a model returns no text and no tool call, the conditional edge sees no calls and returns END,
393-
* and the person is left looking at a turn that produced no answer and no reason. Strict providers do
394-
* this on a run they will not answer. Re-asking tends to get the same empty reply, so rather than
395-
* loop, the run ends on a visible message saying what happened. Only a genuinely empty reply is
396-
* touched: a reply with any text, or any tool call, is returned exactly as the model produced it.
397-
*/
398-
function withVisibleReply(reply: AIMessage): AIMessage {
399-
const hasCall = (reply.tool_calls ?? []).length > 0;
400-
if (hasCall || hasVisibleText(reply.content)) return reply;
401-
return new AIMessage({ content: EMPTY_REPLY_FALLBACK });
402-
}
403-
404-
function hasVisibleText(content: AIMessage["content"]): boolean {
405-
if (typeof content === "string") return content.trim().length > 0;
406-
if (Array.isArray(content)) {
407-
return content.some((part) =>
408-
typeof part === "string"
409-
? part.trim().length > 0
410-
: typeof (part as { text?: unknown }).text === "string" &&
411-
(part as { text: string }).text.trim().length > 0,
412-
);
413-
}
414-
return false;
415-
}
416-
417-
const EMPTY_REPLY_FALLBACK =
418-
"The model returned an empty reply and the run ended without an answer. This can happen with a strict provider; try asking again.";
419-
420387
async function runAgent(input: RunAgentInput): Promise<Response> {
421388
const encoder = new EventEncoder();
422389
const stream = new ReadableStream<Uint8Array>({
@@ -431,169 +398,19 @@ async function runAgent(input: RunAgentInput): Promise<Response> {
431398
runId: input.runId,
432399
} as BaseEvent);
433400

434-
/*
435-
* One message id per stretch of prose.
436-
*
437-
* A run is several turns now: the Bot may speak, call a tool, read the result and speak
438-
* again. Reusing one id reopens a message the surface has already closed, and the second half
439-
* of the answer is dropped.
440-
*/
441-
let messageIndex = 0;
442-
let messageId = `msg_${input.runId}_0`;
443-
let textOpen = false;
444-
const closeText = () => {
445-
if (!textOpen) return;
446-
send({ type: "TEXT_MESSAGE_END", messageId } as BaseEvent);
447-
textOpen = false;
448-
messageIndex += 1;
449-
messageId = `msg_${input.runId}_${messageIndex}`;
450-
};
451-
452-
try {
453-
const graph = buildGraph(input);
454-
const events = await graph.streamEvents(
455-
{ messages: toLangChainMessages(input) },
456-
{ version: "v2" },
457-
);
458-
459-
// Accumulated rather than emitted per chunk, because a tool call's arguments arrive in
460-
// fragments and AG-UI wants one call. The framework hands back assembled `tool_calls` on the
461-
// final message, which is precisely the plumbing agent-bot does by hand.
462-
/** Calls seen on the way past, so a result can be paired with the arguments it answered. */
463-
const pending = new Map<
464-
string,
465-
{ name: string; args: Record<string, unknown> }
466-
>();
467-
468-
for await (const event of events) {
469-
if (event.event === "on_chat_model_stream") {
470-
const chunk = event.data?.chunk as
471-
| { content?: unknown }
472-
| undefined;
473-
/*
474-
* Both content shapes, because the API decides which one arrives.
475-
*
476-
* Chat completions streams a string. The Responses API streams content blocks, so
477-
* reading only the string shape dropped every delta and the run finished having said
478-
* nothing — the "no text at all on gpt-5.6-*" this repository documents in
479-
* `.env.example` and `docker-compose.yml`.
480-
*/
481-
const text = textOfChunk(chunk?.content);
482-
if (!text) continue;
483-
484-
if (!textOpen) {
485-
send({
486-
type: "TEXT_MESSAGE_START",
487-
messageId,
488-
role: "assistant",
489-
} as BaseEvent);
490-
textOpen = true;
491-
}
492-
send({
493-
type: "TEXT_MESSAGE_CONTENT",
494-
messageId,
495-
delta: text,
496-
} as BaseEvent);
497-
}
498-
499-
if (event.event === "on_chat_model_end") {
500-
const output = event.data?.output as AIMessage | undefined;
501-
if (output) {
502-
for (const call of output.tool_calls ?? []) {
503-
pending.set(call.id ?? call.name, {
504-
name: call.name,
505-
args: (call.args ?? {}) as Record<string, unknown>,
506-
});
507-
}
508-
}
509-
}
510-
511-
/*
512-
* The tools node finished. Reported here, in order, rather than collected for the end: the
513-
* surface draws a conversation, and a call arriving after the answer it informed reads as
514-
* though the Bot spoke first and did the work afterwards.
515-
*/
516-
if (event.event === "on_chain_end" && event.name === "tools") {
517-
const output = event.data?.output as
518-
| { messages?: { tool_call_id?: string; content?: unknown }[] }
519-
| undefined;
520-
// Prose and tool calls cannot interleave inside one message.
521-
closeText();
522-
for (const message of output?.messages ?? []) {
523-
const id = message.tool_call_id ?? "";
524-
const call = pending.get(id);
525-
if (!call) continue;
526-
send({
527-
type: "TOOL_CALL_START",
528-
toolCallId: id,
529-
toolCallName: call.name,
530-
} as BaseEvent);
531-
send({
532-
type: "TOOL_CALL_ARGS",
533-
toolCallId: id,
534-
delta: JSON.stringify(call.args),
535-
} as BaseEvent);
536-
send({ type: "TOOL_CALL_END", toolCallId: id } as BaseEvent);
537-
send({
538-
type: "TOOL_CALL_RESULT",
539-
messageId: `${id}-result`,
540-
toolCallId: id,
541-
content: String(message.content ?? ""),
542-
role: "tool",
543-
} as BaseEvent);
544-
pending.delete(id);
545-
}
546-
}
547-
}
548-
549-
closeText();
550-
551-
/*
552-
* Calls this process did not run, which is what a tool the surface owns looks like from here.
553-
*
554-
* The graph ends the run on one of those rather than inventing a result, so the `tools` node
555-
* never fires and the loop above never reports the call. Without this the run is a clean
556-
* RUN_STARTED/RUN_FINISHED pair carrying nothing at all: the person's message sits there with
557-
* no answer under it, the surface never learns there was a browser action to execute, and
558-
* because an empty run is not an error by the protocol, nothing says so. No result is sent
559-
* with them; producing it is the surface's half, and it begins the next run holding it.
560-
*/
561-
for (const [id, call] of pending) {
562-
send({
563-
type: "TOOL_CALL_START",
564-
toolCallId: id,
565-
toolCallName: call.name,
566-
} as BaseEvent);
567-
send({
568-
type: "TOOL_CALL_ARGS",
569-
toolCallId: id,
570-
delta: JSON.stringify(call.args),
571-
} as BaseEvent);
572-
send({ type: "TOOL_CALL_END", toolCallId: id } as BaseEvent);
573-
}
574-
pending.clear();
401+
// The graph is built and its event stream opened inside `streamRun`, so a failure doing either
402+
// is reported as RUN_ERROR through the same path as a failure mid-stream.
403+
await streamRun(
404+
async () =>
405+
buildGraph(input).streamEvents(
406+
{ messages: toLangChainMessages(input) },
407+
{ version: "v2" },
408+
),
409+
input,
410+
send,
411+
);
575412

576-
send({
577-
type: "RUN_FINISHED",
578-
threadId: input.threadId,
579-
runId: input.runId,
580-
} as BaseEvent);
581-
} catch (error) {
582-
// A text message left open would strand the surface mid-message, so it is closed before the
583-
// error is reported. agent-bot has the same hazard and the same ordering.
584-
if (textOpen) {
585-
send({ type: "TEXT_MESSAGE_END", messageId } as BaseEvent);
586-
}
587-
send({
588-
type: "RUN_ERROR",
589-
message:
590-
error instanceof Error
591-
? error.message
592-
: "The Bot could not answer.",
593-
} as BaseEvent);
594-
} finally {
595-
controller.close();
596-
}
413+
controller.close();
597414
},
598415
});
599416

0 commit comments

Comments
 (0)