Making Agents Handle Follow-ups for Real

Agent ArchitectureFollow-upsMulti-turn AgentsReActSoftware Architecture

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:

SourceWhat it suggests for follow-up architecture
ReActReasoning and acting should alternate. Observations change the next action. A user follow-up is another observation.
ReflexionLanguage feedback and memory can improve later attempts. Session memory should not be treated as a raw chat transcript.
ToolformerThe 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 AgentsObservation, memory, reflection, and planning support long-running behavior. Separating session and run is the engineering version of that idea.
Kimi K2/K2.5Agentic 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 typeUser intentSystem behavior
ExplanationExplain an existing result, source, or processRead prior artifacts and answer directly
ExpansionGo deeper on one section, number, or claimUse previous context and create incremental work
RevisionChange scope, format, metric, or constraintsCreate a new DAG version from the old run
Missing inputAnswer a clarification question from the systemFill required arguments and resume the original task

Treating all of these as chat causes problems quickly:

  1. Prior artifacts are only pasted into prompts instead of being referenced structurally.
  2. When tools need to run again, there is no task version or dependency model.
  3. 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.”

CaseOld behavior
Plain chat run is completedCan create another chat task
Agent run is waiting for missing inputCan fill arguments and continue compiling
Agent run is completed and the user follows upCan store the message, but cannot really continue
User sends two follow-ups quicklyNo 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:

  1. Store the user message.
  2. Insert a user_follow_up observation event.
  3. Add a short assistant acknowledgement so the UI responds immediately.
  4. 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:

  1. “Expand the third point.”
  2. “Add the risks too.”
  3. “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.

DimensionSpecialized agentGeneral-purpose agent
Follow-up scopeAround a fixed deliverable or workflowMay shift to a new goal, tool, or format
Context structureStrong schema and clear fieldsWeak schema, relies more on session memory
Missing inputForms and structured fieldsNatural language plus extraction
Re-executionOften reruns a known stageOften creates an incremental DAG
Suggested questionsCan be templatedShould come from final answer or reflection
RiskOver-constraining the userContext 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

  1. OpenAI, Introducing deep research, 2025-02-02.
  2. OpenAI, Agents SDK documentation.
  3. Manus, Manus: Hands On AI.
  4. Shunyu Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models, 2022.
  5. Noah Shinn et al., Reflexion: Language Agents with Verbal Reinforcement Learning, 2023.
  6. Timo Schick et al., Toolformer: Language Models Can Teach Themselves to Use Tools, 2023.
  7. Joon Sung Park et al., Generative Agents: Interactive Simulacra of Human Behavior, 2023.
  8. Kimi Team, Kimi K2: Open Agentic Intelligence, 2025.
  9. Kimi Team, Kimi K2.5: Visual Agentic Intelligence, 2026.

Comments

Comments are powered by GitHub Issues. A GitHub account is required.