Making Agents Handle Follow-ups for Real
Many agent products start out as one-shot task systems. A user submits a request, the system plans, executes, generates a deliverable, and then the run ends.
That works for batch processing and for specialized workflows with stable inputs. It breaks down once the user reads the result and asks: “Why this conclusion?”, “Expand the third point”, “Use a different metric”, or “Turn this into an email.” At that moment the system has to answer a more basic architecture question: is this a new request, or a continuation of the previous task?
The hard part is not the input box. It is not storing one more message either. The hard part is moving from a one-shot run to a resumable session. This article is a design note from that kind of refactor. The examples are adapted from a real system, but the code is generalized and does not include business logic, domain tools, or project-specific prompts.
What Mainstream Agents Suggest
If you only look at one project, follow-up support looks like multi-turn chat. Looking at OpenAI Deep Research, Manus, Kimi-style agentic models, and the related papers makes the picture clearer: follow-ups are runtime inputs. They are new observations, new constraints, and sometimes new goals.
OpenAI Deep Research is not ordinary Q&A. It performs multi-step research on the web, reads sources, analyzes them, and adjusts direction when new information changes the route. OpenAI’s February 2026 update also describes real-time progress tracking and the ability to interrupt or refine a task with follow-up prompts or new sources. In that product shape, a follow-up is not a comment after the task. It is part of the research process.
Manus presents the same pressure from the product side. It puts web apps, slides, design work, games, browser operation, Wide Research, and API usage in one workspace. Users will not stay inside a fixed form. They may ask the agent to research something, then build a page, then turn the result into slides.
Kimi OK Computer-style products, where models use a computer to finish tasks, point in the same direction. Kimi K2 and Kimi K2.5 also emphasize agentic intelligence, environment interaction, tool use, software engineering tasks, multimodal agentic models, and Agent Swarm. Complex work does not fit into one prompt. User additions, tool observations, subtasks, and concurrent execution all end up as runtime concerns.
The papers give useful anchors:
| Source | What it suggests for follow-up architecture |
|---|---|
| ReAct | Reasoning and acting should alternate. Observations change the next action. A user follow-up is another observation. |
| Reflexion | Language feedback and memory can improve later attempts. Session memory should not be treated as a raw chat transcript. |
| Toolformer | The system has to decide when to call tools, which arguments to pass, and how to use tool output. Follow-up routing is part of that decision. |
| Generative Agents | Observation, memory, reflection, and planning support long-running behavior. Separating session and run is the engineering version of that idea. |
| Kimi K2/K2.5 | Agentic systems increasingly need real or synthetic environment interaction, multi-tool work, and multi-agent coordination. |
I would not file follow-ups under “messaging.” They belong closer to the agent control plane.
Follow-ups Are Not Plain Chat
A common first implementation is simple chat: append the user’s message, send the history to the model, return text.
That handles the easy case: “explain what you just said.” Agent follow-ups usually fall into more than one bucket:
| Follow-up type | User intent | System behavior |
|---|---|---|
| Explanation | Explain an existing result, source, or process | Read prior artifacts and answer directly |
| Expansion | Go deeper on one section, number, or claim | Use previous context and create incremental work |
| Revision | Change scope, format, metric, or constraints | Create a new DAG version from the old run |
| Missing input | Answer a clarification question from the system | Fill required arguments and resume the original task |
Treating all of these as chat causes problems quickly:
- Prior artifacts are only pasted into prompts instead of being referenced structurally.
- When tools need to run again, there is no task version or dependency model.
- Rapid follow-ups can interleave old tasks, new tasks, and missing-input recovery.
Follow-ups need to enter the runtime. They cannot stop at the UI layer.
In product terms, Deep Research-style work needs to accept new sources and constraints during research. Manus-style work needs to continue across browser actions, files, web pages, and deliverables. Kimi-style agentic models put tool interaction and environment feedback into the capability itself. The engineering question is plain: the user sent one more message. What exactly should the runtime do with it?
The Old Run Ended Too Early
In the early design, run was the main entity. One user task created one run. A run contained tasks, events, artifacts, and messages.
flowchart LR
U[User Input] --> R[Run]
R --> P[Plan / Compile]
P --> T[Tasks]
T --> A[Artifacts]
A --> F[Final Answer]
The structure was fine. The problem was the assumption behind it: when the run ended, the lifecycle ended too.
The old message handler looked roughly like this:
async function postRunMessage(runId: string, message: UserMessage) {
const run = await loadRun(runId);
await insertMessage(runId, "user", message);
if (isTerminal(run.status)) {
if (run.inputs.chatMode) {
return appendChatTurn(run, message);
}
await insertMessage(runId, "assistant", {
text: "This task is complete. You can submit a new request and the system will continue.",
});
return { ok: true, skipped: true };
}
if (await hasPendingNeedInput(runId)) {
return submitNeedInput(run, message);
}
return recompileFromMessages(run);
}
This was not a bug. The model simply did not include “continue after completion.”
| Case | Old behavior |
|---|---|
| Plain chat run is completed | Can create another chat task |
| Agent run is waiting for missing input | Can fill arguments and continue compiling |
| Agent run is completed and the user follows up | Can store the message, but cannot really continue |
| User sends two follow-ups quickly | No revision versioning or concurrency protection |
The system could handle “additional input while executing.” It could not handle “I read the result, now keep working from there.”
Session Holds the Long Context
The first change was to make session a first-class entity.
session holds the long-running context between the user and the agent. run is one execution turn inside that session. task is an executable node inside a run.
flowchart TD
S[Agent Session] --> R1[Run: turn 1]
S --> R2[Run: turn 2]
S --> R3[Run: turn 3]
R1 --> T1[Tasks]
R1 --> A1[Artifacts]
R2 --> T2[Incremental Tasks]
R2 --> A2[New Artifacts]
S --> M[Session Memory]
A simplified data model looks like this:
type AgentSession = {
id: string;
ownerId: string;
title: string;
status: "active" | "archived";
workspaceKey: string;
lastRunId?: string;
memorySummary?: string;
memoryVersion: number;
lastActiveAt: number;
};
type AgentRun = {
id: string;
sessionId: string;
parentRunId?: string;
turnIndex: number;
status: "planned" | "queued" | "running" | "completed" | "failed" | "canceled";
inputText: string;
dagVersion: number;
generalInputs: Record<string, unknown>;
};
type SessionMemory = {
id: string;
sessionId: string;
sourceRunId: string;
kind: "summary" | "user_preference" | "delivery";
content: { text: string };
importance: number;
createdAt: number;
};
Creating a run now starts by preparing the session:
async function prepareAgentSession(params: {
requestedSessionId?: string;
runId: string;
input: string;
ownerId: string;
previousRunId?: string;
}) {
const existing = params.requestedSessionId
? await findSession(params.requestedSessionId, params.ownerId)
: null;
const sessionId = existing?.id ?? params.requestedSessionId ?? params.runId;
const workspaceKey = existing?.workspaceKey ?? sessionId;
if (!existing) {
await insertSession({
id: sessionId,
ownerId: params.ownerId,
title: params.input.slice(0, 120),
status: "active",
workspaceKey,
lastRunId: params.runId,
});
}
const turnIndex = await nextTurnIndex(sessionId);
return {
sessionId,
workspaceKey,
turnIndex,
parentRunId: params.previousRunId ?? existing?.lastRunId ?? null,
};
}
That looks like one extra table. It changes the boundary of the system. A follow-up no longer revives a completed run. It starts a new turn in the same session, or schedules a controlled revision of the current run.
This matches the memory ideas in Generative Agents and Reflexion: long-running behavior needs memory, but memory should not be a full chat transcript pasted back into the model. The runtime should compress prior delivery, user preferences, failure reasons, and intermediate conclusions into session memory, then retrieve only what matters.
A memory write can stay simple:
async function appendSessionMemoryFromRun(params: {
runId: string;
sessionId: string;
text: string;
kind?: "summary" | "delivery" | "preference" | "error";
importance?: number;
}) {
const clipped = params.text.length > 3000
? `${params.text.slice(0, 3000)}...`
: params.text;
await insertSessionMemory({
id: newId(),
sessionId: params.sessionId,
sourceRunId: params.runId,
kind: params.kind ?? "summary",
content: { text: clipped },
confidence: 1,
importance: params.importance ?? 0.7,
});
await updateSession(params.sessionId, {
memorySummary: clipped,
lastRunId: params.runId,
memoryVersionIncrement: 1,
});
}
The important part is the write boundary. Do not write every token into memory. Write what a future follow-up can use: final conclusions, confirmed user preferences, tool failure reasons, source summaries, and artifact indexes.
Follow-up Entry Records an Observation
For a ReAct-style agent, a user follow-up is a new observation.
The backend should not rush to generate an answer synchronously. It should first do the boring but important work:
- Store the user message.
- Insert a
user_follow_upobservation event. - Add a short assistant acknowledgement so the UI responds immediately.
- Move the run back into a schedulable state and trigger revision asynchronously.
The handler can look like this:
async function acceptAgentFollowUp(runId: string, body: PostMessageBody) {
const run = await requireRunAccess(runId);
const userText = textFromMessage(body.content);
const ts = Date.now();
await insertMessage(runId, "user", { text: userText });
await insertRunEvent({
runId,
type: "react_observation",
payload: {
kind: "user_follow_up",
message: userText,
sessionId: run.generalInputs.sessionId ?? runId,
receivedAt: ts,
},
createdAt: ts,
});
await insertMessage(runId, "assistant", {
text: "Follow-up received. The system will continue using the previous result and current context.",
});
await insertRunEvent({
runId,
type: "progress",
payload: {
phase: "react_revision",
message: "Updating the execution plan with the new input.",
sessionId: run.generalInputs.sessionId ?? runId,
},
createdAt: ts + 1,
});
await updateRunStatus(runId, "planned");
scheduleReactRunRevision({
runId,
modelConfig: body.modelConfig,
});
return { ok: true, accepted: true, revision: "scheduled" };
}
The separation matters. The follow-up does not go straight into the final-answer model. It enters the runtime as an event. The runtime can then decide whether to answer directly, retrieve evidence, call tools, rebuild a DAG, or produce a new deliverable.
This is why ReAct fits follow-ups better than one-shot planning. ReAct is not about exposing a model’s private reasoning. It is about using a loop of reasoning, action, and observation. Tool output, user follow-ups, and runtime recovery can all enter that loop.
Intent Decides the Execution Path
Once the follow-up enters the runtime, the first question is whether tools need to run.
I usually route it into three paths:
flowchart TD
Q[Follow-up Question] --> C{Intent Classifier}
C -->|Explain existing result| D[Direct Answer]
C -->|Fill missing input| N[Need Input Resume]
C -->|Need new evidence or artifact| R[ReAct Revision]
R --> G[Generate Incremental DAG]
G --> E[Execute New Tasks]
E --> F[Update Delivery]
The classifier can combine rules and an LLM. Rules handle high-confidence cases. The LLM handles open-ended language.
type FollowUpIntent =
| { type: "direct_answer"; reason: string }
| { type: "resume_need_input"; answers: Record<string, unknown> }
| { type: "revise_run"; objective: string; requiresTools: boolean };
async function classifyFollowUp(params: {
question: string;
runSummary: string;
latestDelivery: string;
pendingQuestions: NeedInputQuestion[];
}): Promise<FollowUpIntent> {
if (params.pendingQuestions.length > 0) {
const answers = matchNeedInputAnswers(params.question, params.pendingQuestions);
if (Object.keys(answers).length > 0) {
return { type: "resume_need_input", answers };
}
}
if (/^(explain|why|source|what does this mean)/i.test(params.question)) {
return { type: "direct_answer", reason: "question targets existing result" };
}
const decision = await llmJson({
system: "Decide whether the follow-up needs tools or a new deliverable. Return JSON only.",
user: {
question: params.question,
runSummary: params.runSummary,
latestDelivery: params.latestDelivery.slice(0, 4000),
},
schema: followUpIntentSchema,
});
return decision;
}
Do not let the classifier also write the final answer. It should route. When one prompt tries to classify, execute, and deliver, it becomes difficult to debug.
This layer is the engineering version of Toolformer-style tool-use decisions. The dangerous failure is not a short answer. It is when the system should have searched, recalculated, or rerun a step, but instead gives a smooth explanation from stale context.
Incremental DAGs Should Not Overwrite Old Tasks
Follow-ups often trigger new work: another search, a recalculation, a revised document, or an export in a different format.
The tempting mistake is to edit old tasks in place. That breaks auditability and makes it hard for the UI to tell which result came from the original run and which came from the follow-up.
A better option is dagVersion:
type DagNode = {
stepId: string;
title: string;
cellId: string;
args: Record<string, unknown>;
dependsOn: string[];
dagVersion: number;
};
async function insertRevisionDag(params: {
runId: string;
previousDagVersion: number;
nodes: DagNode[];
}) {
const nextDagVersion = params.previousDagVersion + 1;
const baseOrder = await countTasks(params.runId);
for (const [index, node] of params.nodes.entries()) {
await insertTask({
id: newId(),
runId: params.runId,
orderIndex: baseOrder + index,
title: node.title,
cellId: node.cellId,
status: "pending",
payload: {
type: "cell",
stepId: node.stepId,
cellId: node.cellId,
args: node.args,
dependsOn: node.dependsOn,
dagVersion: nextDagVersion,
},
});
}
await updateRun(params.runId, {
status: "queued",
dagVersion: nextDagVersion,
});
}
The runner only executes tasks for the current version:
def task_dag_version(task):
payload = task.get("payload") or {}
value = payload.get("dagVersion")
return int(value) if value else None
def tasks_for_dag_version(tasks, dag_version):
if dag_version is None:
return tasks
return [task for task in tasks if task_dag_version(task) == dag_version]
This keeps old artifacts available for audit and context, while allowing the follow-up to execute only the incremental work it needs.
Concurrency Matters When Users Keep Typing
Real users do not wait politely for the agent to finish. They may send:
- “Expand the third point.”
- “Add the risks too.”
- “Actually keep it short and make it a table.”
Without concurrency control, the system can insert three task sets, overwrite state, and leave the runner with duplicate stepIds or mixed DAG versions.
One practical runner-side fallback is to keep the latest task row for each stepId:
def collapse_duplicate_dag_steps(tasks):
latest_by_step = {}
passthrough = []
for task in tasks:
payload = task.get("payload")
if not isinstance(payload, dict):
passthrough.append(task)
continue
step_id = str(payload.get("stepId") or "").strip()
if not step_id:
passthrough.append(task)
continue
latest_by_step[step_id] = task
collapsed = [*passthrough, *latest_by_step.values()]
return sorted(collapsed, key=lambda task: int(task.get("orderIndex") or 0))
That is a fallback, not the full answer. The API layer should also serialize revisions:
async function scheduleRevisionSafely(runId: string, buildRevision: () => Promise<void>) {
const lockKey = `run:${runId}:revision`;
const acquired = await tryAcquireLock(lockKey, { ttlMs: 30_000 });
if (!acquired) {
await markRunHasPendingFollowUp(runId);
return { scheduled: false, reason: "revision_in_progress" };
}
try {
await buildRevision();
} finally {
await releaseLock(lockKey);
}
if (await consumePendingFollowUpFlag(runId)) {
return scheduleRevisionSafely(runId, buildRevision);
}
return { scheduled: true };
}
The UI looks like chat. The backend is serializing revisions to a versioned execution plan.
Suggested Questions Belong in Metadata
Many agents append “suggested follow-up questions” to the end of the final answer. The UX is useful. The implementation is messy if those questions only live inside Markdown text.
A workable compromise: allow the model to write a section in the draft answer, then extract it into structured data and remove it from the final text.
function extractNextQuestionsFromText(text: string): {
text: string;
nextQuestions: string[];
} {
const lines = text.split("\n");
const sectionIndex = lines.findIndex((line) =>
/^#{0,6}\s*(?:\d+\s*[.、]\s*)?Suggested follow-up questions\s*$/i.test(line.trim()),
);
if (sectionIndex < 0) {
return { text, nextQuestions: [] };
}
const before = lines.slice(0, sectionIndex).join("\n").trim();
const section = lines.slice(sectionIndex + 1).join("\n");
const seen = new Set<string>();
const nextQuestions: string[] = [];
const pushQuestion = (value: string) => {
const clean = value
.replace(/^[-*]\s*/, "")
.replace(/^["“”]+|["“”]+$/g, "")
.trim();
if (!clean || clean.length < 8 || seen.has(clean)) return;
seen.add(clean);
nextQuestions.push(clean);
};
for (const rawLine of section.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const quoted = line.match(/[“"]([^”"]{8,})[”"]/);
if (quoted?.[1]) {
pushQuestion(quoted[1]);
continue;
}
const afterColon = line.match(/^[^:]{2,30}:\s*(.+)$/)?.[1];
if (afterColon) {
pushQuestion(afterColon);
continue;
}
if (/[?]$/.test(line)) {
pushQuestion(line);
}
}
return {
text: before || text,
nextQuestions: nextQuestions.slice(0, 6),
};
}
The final artifact should store questions as data:
type FinalDeliveryArtifact = {
kind: "final_delivery";
data: {
text: string;
nextQuestions?: string[];
};
};
The frontend reads the structured field first:
function artifactNextQuestions(artifact: ArtifactRecord, fallbackText: string) {
const value = artifact.data?.nextQuestions;
if (Array.isArray(value)) {
return value.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 6);
}
return extractNextQuestionsFromText(fallbackText).nextQuestions;
}
Clicking a suggested question should fill the input, not submit immediately:
<MessageBubble
message={message}
artifacts={artifacts}
onFollowUpQuestion={(question) =>
setFollowUpDraft({ text: question, version: Date.now() })
}
/>
<PromptInput
placeholder="Send a message to the agent"
draftOverride={followUpDraft ?? undefined}
disabled={runIsExecuting}
/>
That small detail matters. A suggestion is not consent. The user should still be able to edit it.
Specialized and General Agents Need Different Follow-up Policies
Follow-ups do not behave the same way in specialized and general-purpose agents.
| Dimension | Specialized agent | General-purpose agent |
|---|---|---|
| Follow-up scope | Around a fixed deliverable or workflow | May shift to a new goal, tool, or format |
| Context structure | Strong schema and clear fields | Weak schema, relies more on session memory |
| Missing input | Forms and structured fields | Natural language plus extraction |
| Re-execution | Often reruns a known stage | Often creates an incremental DAG |
| Suggested questions | Can be templated | Should come from final answer or reflection |
| Risk | Over-constraining the user | Context drift and task boundary sprawl |
For specialized agents, I prefer follow-ups as operations on the deliverable: explain, expand, change format, recalculate a field, add a section. This is stable and testable.
For general-purpose agents, I treat follow-ups as new observations in a session. The system should not assume the user is only editing a report. They may ask the agent to open a page, read a file, write code, generate an image, or compare alternatives.
Both can share the same session model. They should not share the same follow-up policy.
Deep Research is closer to a research agent. Its follow-ups often mean new sources, new evidence, new metrics, or report revision. Manus is closer to a general-purpose workspace agent. Its follow-ups are more likely to cross tools. Kimi K2/K2.5 points at the model-training side: agentic capability is becoming part of the model itself. None of these systems are satisfied with one-shot answers.
Design Rules I Would Keep
First, follow-ups should enter the runtime, not just the prompt.
If a follow-up only exists as chat context, the system cannot manage tool calls, task versions, auditability, or recovery well. At minimum, record it as an event.
Second, split session and run.
A run is one execution. A session is long-lived context. Follow-up support needs the session.
Third, do not overwrite old artifacts.
A follow-up should create a new artifact or a new DAG version. Overwriting old output removes the comparison point and damages the causal chain.
Fourth, structure suggested questions.
Extract suggested follow-up questions into metadata. The frontend becomes easier to build, and the formal deliverable does not carry a chat-like tail.
Fifth, design for rapid follow-ups.
Users do not send messages at perfect intervals. Revision locks, DAG versions, duplicate-step collapsing, and pending-follow-up flags are not polish. They become necessary once the feature is used.
Closing
Follow-up support looks like a UX feature. In the runtime, it is a shift from task executor to session-based agent.
If it is only an input box, it becomes chat. If it is wired into session, memory, events, DAG versions, artifact metadata, and scheduling, it becomes an agent capability.
The useful mental model is this: the user is not merely asking one more question after the task. They are injecting a new observation, constraint, or goal into the same working context. Whether the system can absorb that change is the difference between a one-shot tool and an agent that can keep working with the user.
References
- OpenAI, Introducing deep research, 2025-02-02.
- OpenAI, Agents SDK documentation.
- Manus, Manus: Hands On AI.
- Shunyu Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models, 2022.
- Noah Shinn et al., Reflexion: Language Agents with Verbal Reinforcement Learning, 2023.
- Timo Schick et al., Toolformer: Language Models Can Teach Themselves to Use Tools, 2023.
- Joon Sung Park et al., Generative Agents: Interactive Simulacra of Human Behavior, 2023.
- Kimi Team, Kimi K2: Open Agentic Intelligence, 2025.
- Kimi Team, Kimi K2.5: Visual Agentic Intelligence, 2026.
Comments
Comments are powered by GitHub Issues. A GitHub account is required.