DEV Community

Akash Pal
Akash Pal

Posted on

Part 4: The Raw ReAct Loop: ~100 Lines, No Framework

Part 4 of a series building a support-ticket agent with no framework. Previous: Part 3 (the eval set). Repo: github.com/akash-pal/agent-from-scratch

This is the part everyone reaches for a framework to skip. Here's the argument for not doing that, at least the first time: if you can't explain what your agent loop does in plain English, no framework is going to fix that — it's just going to make the loop harder to see.

Here's src/agent.ts, trimmed to the actual loop:

export async function runAgent(
  ticket: Ticket,
  customer: Customer | null,
  approvalFn: ApprovalFn,
  maxSteps = 8,
): Promise<AgentResult> {
  const state = initState(ticket, customer);

  // Guardrail check happens BEFORE any model call — see Part 5.
  const escalatePattern = matchAutoEscalate(`${ticket.subject} ${ticket.body}`);
  if (escalatePattern) {
    return { outcome: "escalated", finalText: `ESCALATED: auto-escalated — "${escalatePattern}"`, state };
  }

  const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
  const contents: Content[] = [{ role: "user", parts: [{ text: ticketToUserMessage(ticket) }] }];

  for (let step = 0; step < maxSteps; step++) {
    const response = await withRetry(() =>
      ai.models.generateContent({
        model: MODEL,
        contents,
        config: { systemInstruction: buildSystemPrompt(COMPANY), tools: [{ functionDeclarations }] },
      }),
    );

    const calls = response.functionCalls ?? [];
    if (calls.length === 0) {
      // No tool call — the model produced a final answer. Done.
      const text = (response.text ?? "").trim();
      return { ...enforceOutcomeIntegrity(parseOutcome(text), state), state };
    }

    // Otherwise: execute the requested tool(s), feed results back, loop again.
    contents.push({ role: "model", parts: response.candidates?.[0]?.content?.parts ?? [] });
    const responseParts = [];
    for (const call of calls) {
      const result = await executeToolWithGuardrails(call, state, approvalFn); // Part 5
      responseParts.push({ functionResponse: { name: call.name, response: result } });
    }
    contents.push({ role: "user", parts: responseParts });
  }

  return { outcome: "escalated", finalText: `ESCALATED: max_steps_exceeded (${maxSteps} steps)`, state };
}
Enter fullscreen mode Exit fullscreen mode

(The real file inlines tool execution, approval gating, and trace logging directly in the loop rather than a separate function — shown split out here for readability. Full file on GitHub.)

That's the whole thing: call the model, check if it wants a tool, if yes run it and loop, if no return the final answer. No state machine, no graph, no separate orchestrator abstraction — a for loop and an if.

The six-element system prompt

The system prompt is the other half of the loop, and it's treated as a spec with required parts, not free-form prose. From src/systemPrompt.ts:

You are the support agent for {company}. You are not a human agent — say so if asked.

GOAL: resolve or correctly escalate every ticket in as few steps as possible.

TOOLS: order_lookup (read order state) · refund_eligibility (check BEFORE
proposing a refund) · issue_refund (gated) · kb_search (use before any factual
answer) · send_email (gated, delivers the resolution).

POLICY: never call issue_refund without a prior refund_eligibility=true result.
Never fabricate an answer kb_search did not return. Never state REFUND_PROPOSED
unless issue_refund actually succeeded in this run.

UNCERTAINTY: if you are not confident, say so explicitly and escalate — do not guess.

DONE: emit a final response only once resolved, a refund is proposed, or the
ticket is escalated. Always start with RESOLVED:, REFUND_PROPOSED:, or
ESCALATED: so the outcome can be parsed.
Enter fullscreen mode Exit fullscreen mode

Six required elements: identity (who it is / who it serves), one-sentence goal, tool list + when to use each, explicit policy (what it can't do), how to signal uncertainty, how to signal done. Skip any one of these and you'll find it in your eval failures — a prompt missing an explicit "how to signal done" section is, empirically, the single biggest source of unparseable final responses.

Notice that POLICY line about REFUND_PROPOSED"never state REFUND_PROPOSED unless issue_refund actually succeeded." That sentence exists because, without it, the model did exactly that: described a refund as proposed without ever calling the tool. The full story — and why the fix required more than just this prompt line — is Part 5.

Memory: one state object, not four systems

"Memory" sounds like it should mean a vector database. Here it's a single object, updated by a reducer — src/memory.ts:

export interface AgentState {
  goal: string;
  working: { ticket: Ticket };                          // this run only
  conversation: { trajectory: TrajectoryStep[] };         // rolling
  artifacts: Record<string, unknown>;                      // exact, structured
  long_term: Pick<Customer, "preferred_channel" | ...> | null; // stable, mock in this repo
  policy: typeof policy;
}
Enter fullscreen mode Exit fullscreen mode

Four layers, ordered by increasing persistence and complexity: working (this run only — the current ticket, ephemeral), conversation (the trajectory so far), artifacts (exact structured facts that need precise recall later, like a refund confirmation ID — never fuzzy semantic search for something like this), long-term (stable facts across sessions — read-only mock data here, since this repo has no real persistence layer).

The decision rule worth internalizing: reach for the cheapest layer first. In-context conversation memory is sufficient for most single-session agents. An external structured store is for facts that must survive across runs. Vector/semantic memory is the most expensive, highest-maintenance option, and should be justified, not defaulted to — critically, never for anything with financial or access-control consequences, where "approximately right" retrieval is the wrong guarantee. A refund confirmation ID goes in artifacts as an exact string, not embedded and semantically searched for later.

What's next

Part 5: Guardrails That Live in Code, Not the Prompt → covers the guardrail layer this loop leans on for every tool call: the policy-as-code approval gate, and the real bug it was added specifically to catch.

Repo: github.com/akash-pal/agent-from-scratch

Top comments (0)