Q6: feed messages that arrive mid-run into the next model turn

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 02:58:54 -04:00
1 parent 46dd77ddda
commit 0fb4ca9ae6
11 files changed
+161 -11

No files matched your search

+9 -1
View File
@@ -17,6 +17,8 @@ export interface AgentRunLoopInput {
initialUserTurn: string;
toolContext: AgentToolContext;
externalTools?: ToolDefinition[];
/** Messages that arrived mid-run, or null. */
pollNewMessages?: () => string | null;
}
export interface AgentRunLoopResult {
@@ -63,6 +65,11 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn
// completing the run would silently mark a no-op as done.
throw new Error("Provider returned an empty turn: no text and no tool calls.");
}
const lateMessages = input.pollNewMessages?.();
if (lateMessages) {
messages.push({ role: "assistant", text: turn.text, toolCalls: [] }, { role: "user", text: lateMessages });
continue;
}
return { finalText: turn.text, iterations: iteration, toolCallCount, lastRequestId };
}
@@ -95,7 +102,8 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn
}
results.push({ toolCallId: call.id, toolName: call.name, output });
}
messages.push({ role: "tool", results });
const followUp = input.pollNewMessages?.();
messages.push(followUp ? { role: "tool", results, followUp } : { role: "tool", results });
deps.onToolResults?.();
}
+29
View File
@@ -52,3 +52,32 @@ function formatMessage(m: ThreadMessageRow): string {
function utcMinute(iso: string): string {
return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
}
/** Polls the threads this run is part of for messages from others. */
export function createRunMessagePoller(
db: DatabaseSync,
input: { orgId: string; agentId: string; sourceThreadId: string | null; runStartedAt: string },
): () => string | null {
let watermark = input.runStartedAt;
return () => {
const rows = db.prepare(`
select m.body_md, m.created_at, m.edited_at, m.sender_type, m.sender_id,
coalesce(a.name, e.display_name, m.sender_type) as sender_name,
coalesce(a.role, e.role) as sender_role
from chat_messages m
left join agents a on a.id = m.sender_id and m.sender_type = 'agent'
left join employees e on e.id = m.sender_id and m.sender_type = 'employee'
where m.org_id = ? and m.created_at > ? and m.deleted_at is null
and not (m.sender_type = 'agent' and m.sender_id = ?)
and (m.thread_id = ? or m.thread_id in (
select own.thread_id from chat_messages own
where own.org_id = ? and own.sender_type = 'agent' and own.sender_id = ? and own.created_at >= ?
))
order by m.created_at asc
`).all(input.orgId, watermark, input.agentId, input.sourceThreadId, input.orgId, input.agentId, input.runStartedAt) as unknown as ThreadMessageRow[];
if (rows.length === 0) return null;
watermark = rows[rows.length - 1].created_at;
console.info(`[run-inbox] agent ${input.agentId}: ${rows.length} new message(s) injected mid-run`);
return ["Messages that arrived while you were working (they may change what you should do):", ...rows.map(formatMessage)].join("\n");
};
}
+1 -1
View File
@@ -24,7 +24,7 @@ export interface ToolCall {
export type AgentTurnMessage =
| { role: "user"; text: string }
| { role: "assistant"; text: string; toolCalls: ToolCall[] }
| { role: "tool"; results: Array<{ toolCallId: string; toolName: string; output: string }> };
| { role: "tool"; results: Array<{ toolCallId: string; toolName: string; output: string }>; followUp?: string };
export interface AgentTurnResult {
text: string;
@@ -17,7 +17,9 @@ export function toAnthropicMessages(messages: AgentTurnMessage[]): unknown[] {
for (const call of message.toolCalls) content.push({ type: "tool_use", id: call.id, name: call.name, input: call.arguments });
out.push({ role: "assistant", content });
} else {
out.push({ role: "user", content: message.results.map((result) => ({ type: "tool_result", tool_use_id: result.toolCallId, content: result.output })) });
const content: unknown[] = message.results.map((result) => ({ type: "tool_result", tool_use_id: result.toolCallId, content: result.output }));
if (message.followUp) content.push({ type: "text", text: message.followUp });
out.push({ role: "user", content });
}
}
return out;
@@ -20,7 +20,9 @@ export function toBedrockMessages(messages: AgentTurnMessage[]): JsonRecord[] {
for (const call of message.toolCalls) content.push({ toolUse: { toolUseId: call.id, name: call.name, input: call.arguments } });
out.push({ role: "assistant", content });
} else {
out.push({ role: "user", content: message.results.map((result) => ({ toolResult: { toolUseId: result.toolCallId, content: [{ text: result.output }] } })) });
const content: unknown[] = message.results.map((result) => ({ toolResult: { toolUseId: result.toolCallId, content: [{ text: result.output }] } }));
if (message.followUp) content.push({ text: message.followUp });
out.push({ role: "user", content });
}
}
return out;
@@ -22,6 +22,7 @@ export function toChatMessages(instructions: string | null, messages: AgentTurnM
for (const result of message.results) {
out.push({ role: "tool", tool_call_id: result.toolCallId, content: result.output });
}
if (message.followUp) out.push({ role: "user", content: message.followUp });
}
}
return out;
@@ -43,9 +43,10 @@ function toGeminiContents(model: string, messages: AgentTurnMessage[]): JsonReco
}
if (parts.length > 0) contents.push({ role: "model", parts });
} else {
const responses = message.results.map((result) => ({
const responses: unknown[] = message.results.map((result) => ({
functionResponse: { name: result.toolName, response: { output: result.output }, ...(withId ? { id: result.toolCallId } : {}) },
}));
if (message.followUp) responses.push({ text: message.followUp });
const last = contents[contents.length - 1];
if (last && last.role === "user" && Array.isArray(last.parts) && last.parts.some((part) => isRecord(part) && "functionResponse" in part)) {
(last.parts as unknown[]).push(...responses);
@@ -35,6 +35,7 @@ export function toResponsesInput(messages: AgentTurnMessage[]): unknown[] {
for (const result of message.results) {
items.push({ type: "function_call_output", call_id: result.toolCallId, output: result.output });
}
if (message.followUp) items.push({ role: "user", content: [{ type: "input_text", text: message.followUp }] });
}
}
return items;
+4 -4
View File
@@ -24,7 +24,7 @@ import {
type WorkspaceRow,
} from "./task-prompts";
import { renderTaskHandoff } from "./agent-task-handoff";
import { loadChatWakeContext } from "./chat-wake-context";
import { createRunMessagePoller, loadChatWakeContext } from "./chat-wake-context";
import { loadComposioTools } from "./composio/composio-tool-bridge";
import { connectorDisplayName } from "./composio/connector-brands";
import { ConnectorGrantsService } from "./composio/connector-grants";
@@ -134,6 +134,8 @@ export class TaskExecutionService {
let skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null = null;
try {
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId);
// Watermark for reply delivery and mid-run message polling.
const runStartedAt = new Date().toISOString();
const chatContext = loadChatWakeContext(this.db, orgId, agent.id, isolation.secretScopeJson);
const toolContext: AgentToolContext = {
orgId,
@@ -153,9 +155,6 @@ export class TaskExecutionService {
.map(connectorDisplayName);
const language = this.appLanguage();
const externalTools = await loadComposioTools(this.db, this.providers.integrationKeys, orgId, agent.id);
// Watermark for the reply-delivery check below: anything the agent posts
// into the source thread from here on counts as its reply.
const runStartedAt = new Date().toISOString();
const result = await runAgentLoop(
{
providers: this.providers,
@@ -185,6 +184,7 @@ export class TaskExecutionService {
}),
toolContext,
externalTools,
pollNewMessages: createRunMessagePoller(this.db, { orgId, agentId: agent.id, sourceThreadId: chatContext?.threadId ?? null, runStartedAt }),
},
);
providerRequestId = result.lastRequestId;
+76 -2
View File
@@ -19,10 +19,15 @@ const testVault = {
async function founderLine() {
const wakePrompts: string[] = [];
const wakeInputs: ProviderToolTurnInput["messages"][] = [];
let onWake: (turnIndex: number) => void = () => undefined;
const turn = async (input: ProviderToolTurnInput): Promise<AgentTurnResult> => {
const first = input.messages.find((message) => message.role === "user");
const prompt = first && first.role === "user" ? first.text : "";
if (/thread_id="/.test(prompt) && input.messages.length === 1) wakePrompts.push(prompt);
if (!/thread_id="/.test(prompt)) return { text: "Kickoff noted.", toolCalls: [], rawResponseJson: "{}" };
if (input.messages.length === 1) wakePrompts.push(prompt);
wakeInputs.push(input.messages);
onWake(wakeInputs.length);
return { text: `Reply ${wakePrompts.length}.`, toolCalls: [], rawResponseJson: "{}" };
};
const connection = new DatabaseConnection(join(tmpdir(), `ac-chatctx-${randomUUID()}.db`));
@@ -60,7 +65,8 @@ async function founderLine() {
const row = connection.db.prepare("select id from chat_messages where body_md = ?").get(bodyMd) as { id: string };
chat.editMessage({ orgId, messageId: row.id, bodyMd: newBody, actorType: "employee", actorId: founderId });
};
return { connection, wakePrompts, say, runWake, edit };
const onWakeTurn = (hook: (turnIndex: number) => void) => { onWake = hook; };
return { connection, wakePrompts, wakeInputs, say, runWake, edit, onWakeTurn };
}
describe("chat wake context", () => {
@@ -99,4 +105,72 @@ describe("chat wake context", () => {
expect(fresh).not.toContain("Reply 1.");
h.connection.close();
});
test("a message that arrives mid-run reaches the next model turn", async () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-midrun-${randomUUID()}.db`));
const chat = new ChatService(connection.db);
const seenFollowUps: string[] = [];
let founderId = "";
let orgId = "";
let spaceId = "";
let threadId = "";
let wakeTurns = 0;
const turn = async (input: ProviderToolTurnInput): Promise<AgentTurnResult> => {
const first = input.messages.find((message) => message.role === "user");
if (!(first && first.role === "user" && /thread_id="/.test(first.text))) return { text: "Kickoff noted.", toolCalls: [], rawResponseJson: "{}" };
wakeTurns += 1;
const last = input.messages[input.messages.length - 1];
if (last.role === "tool" && last.followUp) seenFollowUps.push(last.followUp);
if (wakeTurns === 1) {
chat.sendMessage({ orgId, spaceId, threadId, senderType: "employee", senderId: founderId, bodyMd: "Stop — use Postgres instead.", attachmentFileIds: [] });
return { text: "Looking.", toolCalls: [{ id: "c1", name: "read_messages", arguments: {} }], rawResponseJson: "{}" };
}
return { text: "Switching to Postgres.", toolCalls: [], rawResponseJson: "{}" };
};
const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, turn);
const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
const execution = new TaskExecutionService(connection.db, providers);
let ceoId = "";
connection.transaction(() => {
const summary = onboarding.bootstrap({
companyName: "FlowPilot", companyDescription: "CSM analytics.", employeeName: "Yang", workspacePath: "",
executionMode: "ai_ceo", autoApproveHires: true, reviewDeliverables: false,
goalTitle: "Ship the MVP.", goalDescription: "A working product.",
providerKey: "openai", providerSecret: "sk-test-key", providerModel: "gpt-5.4",
});
orgId = summary.orgId;
ceoId = summary.ceoAgentId;
});
founderId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
spaceId = ensureCeoDirectSpace(connection.db, orgId)!;
const kickoff = claimNextQueuedRun(connection);
await execution.executeRun(kickoff!.org_id, kickoff!.id);
const firstId = chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "employee", senderId: founderId, bodyMd: "Use MySQL.", attachmentFileIds: [] });
threadId = (connection.db.prepare("select thread_id from chat_messages where id = ?").get(firstId) as { thread_id: string }).thread_id;
const wake = connection.db.prepare(
"select id from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention' and state = 'queued'",
).get(orgId, ceoId) as { id: string };
await execution.executeRun(orgId, wake.id);
expect(seenFollowUps).toHaveLength(1);
expect(seenFollowUps[0]).toContain("Messages that arrived while you were working");
expect(seenFollowUps[0]).toContain("Stop — use Postgres instead.");
expect(seenFollowUps[0]).not.toContain("Use MySQL.");
connection.close();
});
test("a correction that lands while the model answers gets one more turn", async () => {
const h = await founderLine();
const threadId = h.say("Use MySQL.", null);
h.onWakeTurn((turnIndex) => {
if (turnIndex === 1) h.say("Stop — use Postgres instead.", threadId);
});
await h.runWake();
const second = h.wakeInputs[1];
expect(second.at(-2)).toMatchObject({ role: "assistant", text: "Reply 1." });
const last = second.at(-1)!;
expect(last.role).toBe("user");
expect(last.role === "user" && last.text).toContain("Stop — use Postgres instead.");
h.connection.close();
});
});
+32
View File
@@ -281,3 +281,35 @@ describe("responses tool definitions", () => {
expect(responsesToolDefs(tools, true)[0]).toMatchObject({ strict: true });
});
});
describe("mid-run follow-up text rides on the tool-result turn", () => {
const convo: AgentTurnMessage[] = [
{ role: "user", text: "go" },
{ role: "assistant", text: "", toolCalls: [{ id: "c1", name: "f", arguments: {} }] },
{ role: "tool", results: [{ toolCallId: "c1", toolName: "f", output: "ok" }], followUp: "New: stop" },
];
test("each wire format keeps the result and appends the text in order", () => {
expect(toResponsesInput(convo).slice(-2)).toEqual([
{ type: "function_call_output", call_id: "c1", output: "ok" },
{ role: "user", content: [{ type: "input_text", text: "New: stop" }] },
]);
expect(toChatMessages(null, convo).slice(-2)).toEqual([
{ role: "tool", tool_call_id: "c1", content: "ok" },
{ role: "user", content: "New: stop" },
]);
expect(toAnthropicMessages(convo).at(-1)).toEqual({
role: "user",
content: [{ type: "tool_result", tool_use_id: "c1", content: "ok" }, { type: "text", text: "New: stop" }],
});
expect(toBedrockMessages(convo).at(-1)).toEqual({
role: "user",
content: [{ toolResult: { toolUseId: "c1", content: [{ text: "ok" }] } }, { text: "New: stop" }],
});
const gemini = vertexRequestBody({ model: "gemini-3-pro", instructions: null, messages: convo, tools: [] }) as { contents: Array<{ role: string; parts: unknown[] }> };
expect(gemini.contents.at(-1)).toEqual({
role: "user",
parts: [{ functionResponse: { name: "f", response: { output: "ok" } } }, { text: "New: stop" }],
});
});
});