Agentic AI · L-ReAct
How to sample tokens (softmax, temperature, top-$k$/$p$), the ReAct loop that turns a chat model into an agent, message types, and the system prompt / chat template.
After this week you can:
UserMessage / AssistantMessage / ToolResultMessage.Recall (→ L-Intro): the model outputs a distribution over the vocabulary $P(W_t\mid W_{1:t-1})$. This week we open it up — logits → softmax → sample — and then loop with tools.
An LLM never emits a word directly: the network produces scores (one per vocabulary token), softmax turns them into a distribution, and we sample. The one-step pipeline:
$$ \underbrace{W_{1:t-1}}_{\text{previous tokens}}\;\xrightarrow{\text{Transformer}}\; \underbrace{\mathbf z\in\mathbb R^{|V|}}_{\text{logits: one score per token}}\; \xrightarrow{\text{softmax}}\; \underbrace{P(W_t\mid W_{1:t-1})}_{\text{distribution over }V}\; \xrightarrow{\text{sample}}\; w_t $$
Properties: every entry is positive and they sum to $1$ — a valid distribution. (Why? You prove it in the exercises.)
The softmax gives a distribution $P(\cdot)$ over the vocabulary $V$. To emit a token:
In code, that weighted draw is one line:
w_t = np.random.choice(len(p), p=p) # draw a token, weighted by the probabilities p
Then repeat autoregressively (chain rule, one step at a time): append $w_t$, feed
$w_{1:t}$ back in, get the next distribution $P$ (resp. p), sample again — until an end-of-sequence token.
The decoding strategy (temperature, top-$k$, top-$p$ — next slides) reshapes $P$ before this draw, trading determinism for diversity (see below).
Rescale the logits by temperature $T$ before softmax, changing how peaked the distribution is:
$$ P_T(W_t=i \mid W_{1:t-1} )=\frac{e^{z_i/T}}{\sum_j e^{z_j/T}}, \qquad T>0 $$
temperature=0.0 internally bypasses the softmax sampling and executes argmax(logits) directly to prevent a divide-by-zero error.)Why agents care:
How you then choose among many sampled sequence candidates — beam search vs. sampling, best-of-$N$, self-consistency — is the subject of tree search.
Sampling from the full distribution occasionally draws a very unlikely token from the long tail, which can derail the text. Both methods below zero out the tail, renormalise, then sample:
Example — probabilities $(0.6,\,0.3,\,0.05,\,0.03,\,0.02)$:
In practice these are often combined (e.g. top-$p$ + temperature — temperature reshapes the distribution before truncation). Note the special case greedy = top-$k$ with $k{=}1$ (equivalently $T\to 0$).
Setting them in practice: all these knobs are parametrizable —
temperatureandtop_pvia the OpenAI client against Ollama;top_k/min_pvia the nativeollamalibrary. You try them in lab.
While sampling controls output diversity through token probabilities, constrained decoding alters the generation pipeline by enforcing structural validity. This technique is essential when generating outputs in formal target languages, such as JSON, regular expressions, or context-free grammars (CFGs) where structural syntax must be strictly guaranteed.
During generation, a parser tracks the state $s_t$ of the target grammar. At each decoding step $t$, the parser defines the valid token subset $A(s_t) \subseteq V$. The unmasked probabilities are then renormalised over the legal subset, so illegal tokens receive probability zero:
$$ P'(W_t{=}i \mid W_{1:t-1}) \;=\; \frac{\mathbb{1}[\,i \in A(s_t)\,]\; P(W_t{=}i \mid W_{1:t-1})}{\sum_{j\in A(s_t)} P(W_t{=}j \mid W_{1:t-1})} $$
Prompting is advice; a mask is enforcement. Prompts asking for a specific format (e.g., "Answer only in JSON") merely adjust probability mass without eliminating malformed output paths. Constrained decoding makes invalid outputs structurally unreachable.
This mechanism ensures dependable native tool calling: generated action sequences conform to the expected JSON schema because invalid syntax paths are entirely disabled during sampling.
Same model, different temperature (and top_p) per job — pick for "how many acceptable
answers are there?":
| Use case | temperature |
top_p |
Why |
|---|---|---|---|
| Tool calls, structured / JSON output, extraction, tests, linting | 0.0 (greedy) | — | one valid answer; must be repeatable |
| Factual Q&A, summarization, precise code edits | ~0.2–0.3 | ~0.9 | mostly deterministic, a little slack |
| General writing, drafting, explanation | ~0.7 | ~0.9–0.95 | natural variation without derailing |
| Brainstorming, creative, Tree-of-Thoughts candidates (→ L-ToT) | ~1.0+ | ~0.95 | diversity is the goal |
temperature=0.0 (and a fixed seed if
the API exposes one) so runs are comparable.top_p ≈ 0.9–0.95 and adjust temperature; cranking both at
once makes output hard to predict.Two dials, not one. This is the parameter-level dial. The prompt-level dial — clarity, examples, a role, structure (Anthropic, 2026) — is the other, and usually the stronger one. Reach for the prompt first, the temperature second.
Every knob above changes the distribution you sample from: temperature rescales it, top-$k$ and top-$p$ truncate it. Different setting, different text.
A second family makes generation faster while sampling from the same distribution — speculative decoding: a small, cheap model drafts the next few tokens, the real model verifies them all in one pass, and a modified rejection-sampling rule guarantees the emitted tokens are distributed exactly as the real model's (Leviathan et al., 2023; Chen et al., 2023). Reported: 2–3× less wall-clock time, identical outputs.
| changes the output? | what you buy | |
|---|---|---|
| temperature / top-$k$ / top-$p$ | yes — that is the point | determinism ↔ diversity |
| quantization | yes — approximates the weights | fits in less memory |
| constrained decoding | yes — invalid tokens get probability 0 | a guaranteed format |
| speculative decoding | no — provably the same distribution | pure wall-clock win |
import numpy as np
def softmax(z, T=1.0):
z = np.asarray(z, float) / T
z = z - z.max() # numerical stability
e = np.exp(z)
return e / e.sum()
logits = np.array([2.0, 1.0, 0.2, -0.5])
for T in (0.5, 1.0, 2.0):
print(f"T={T}: {np.round(softmax(logits, T), 3)}")
def top_p(p, thresh=0.9):
sorted_indices = np.argsort(p)[::-1]
sorted_probs = p[sorted_indices]
cum_probs = np.cumsum(sorted_probs)
# Shift cumulative sum to include the first token that crosses the threshold
cutoff_mask = cum_probs - sorted_probs < thresh
keep_indices = sorted_indices[cutoff_mask]
masked_p = np.zeros_like(p)
masked_p[keep_indices] = p[keep_indices]
return masked_p / masked_p.sum()
p = softmax(logits, 1.0)
print("full :", np.round(p, 3))
print("top-p:", np.round(top_p(p, 0.9), 3))
So far we have a fluent text generator and know how to sample from it. An agent must also reason before aswering or acting.
Chain-of-Thought (CoT; Wei et al., 2022): prompt the model to emit intermediate reasoning steps before the final answer ("think step by step"). CoT turns a single monolithic prediction into an explicit, multi-step trajectory.
At first glance CoT is a prompting trick. Under the hood it changes how much computation the Transformer can spend on a problem:
An explanation is plausible if it is coherent and fits the answer; it is faithful only if it reflects what actually drove that answer (Jacovi & Goldberg, 2020). The two come apart: plant a bias in the input and accuracy moves by up to 36 %, while the chain names that bias in 1 of 426 explanations (Turpin et al., 2023). Whether that makes the chain false or merely incomplete is disputed — a chain that stays silent about the bias still measurably carries it into the answer (Zaman & Srivastava, 2026).
Either way the working rule is the same: debug with the chain, conclude from the artifact — the tool result, the test, the recomputation. Returns in (critics), (audit), → L-Eval (evaluation).
CoT improves the reasoning, but the model still commits to the entire chain before anything happens in the world. The control-theory terms name the difference exactly:
Classic CoT is open-loop: the model reasons, but it cannot act on the outside world, gather new data, or notice that step 3 rests on a false premise from step 2. Every fact it uses comes strictly from its parametric memory (training data), so an early error is carried, undetected, to the end.
Acting changes the requirement. The moment a step has an effect — a query, a file write, an email — its outcome is information the model did not have, and the next step ought to depend on it. Feeding that outcome back is the closed loop, and that is the step from CoT to ReAct.
ReAct = Reasoning + Acting (Yao et al., 2023): Extends Chain-of-Thought (CoT) into a closed-loop feedback cycle. Instead of just reasoning upfront or acting blindly, the framework interleaves CoT-style Thoughts $t$ with concrete Actions $a$ (tool executions) and environment Observations $o$ (tool outputs):
$$\dots \longrightarrow\; t_k \;\longrightarrow\; a_k \;\longrightarrow\; o_k \;\longrightarrow\; t_{k+1} \; \longrightarrow\; \dots$$
| Paradigm | Reasons (CoT) | Acts (Tools) | Grounds on Feedback | Control Type |
|---|---|---|---|---|
| Chain-of-Thought (CoT) | ✅ | ❌ | ❌ | Open-loop (Static) |
| Act (the paper's own baseline) | ❌ | ✅ | ✅ | Closed-loop (Dynamic) |
| ReAct | ✅ | ✅ | ✅ | Closed-loop (Dynamic) |
ReAct grounds intermediate thoughts in real-world feedback—preventing hallucination drift and enabling self-correction when a tool call fails.
The same three paradigms drawn, because the shape is the argument (redrawn after Yao et al., 2023):
Read the arrows, not the boxes. ReAct is not a third mechanism — it is the two loops on the same
model at the same time. And the left-hand loop is the whole idea: a reasoning trace is an arrow that
leaves the LM and comes back to it without touching Env, so it produces no observation. That is
what the next slide makes formal.
The loop above is the behaviour. The definition in Yao et al. (2023), §2, is one line, and it is worth having exactly.
An agent at step $k$ receives an observation $o_k \in \mathcal{O}$ and takes an action $a_k \in \mathcal{A}$ (under a policy $\pi(a_k \mid c_k)$), where the context is the whole history $$c_k = (o_1, a_1, \dots, o_{k-1}, a_{k-1}, o_k).$$
ReAct augments the action space Formally, ReAct simply expands the agent's available action space to include free-form language:
$$\hat{\mathcal{A}} = \mathcal{A} \cup \mathcal{L}$$
An element $\hat a_k \in \hat{\mathcal{A}}$ is either an environment action ($a_k \in \mathcal{A}$) or a thought ($t_k \in \mathcal{L}$). Note: This is in contrast to Yao et al. (2023) where $\hat a_k$ is a thought.
Under ReAct, a thought ($t_k \in \mathcal{L}$) is treated as an action. Its defining feature is what it doesn't do:
A thought does not touch the external environment and generates no output observation. Its only effect is appending text to the agent's context $c_k$ — everything the model conditions on at loop step $k$ — so $c_{k+1} = (c_k, t_k)$.
That is the entire idea. Thinking is not a phase before acting — it is an action whose only effect is to write to the context. Everything else this week does (the message list, statelessness, the context budget) follows from taking that literally.
While the original ReAct paper (Yao et al., 2023) focused heavily on prompting foundation models via in-context learning, relying solely on few-shot prompts in production proved expensive, context-heavy, and fragile—especially for smaller or open-weights models. Consequently, the field transitioned toward Trajectory Tuning, fine-tuning models directly on multi-turn ReAct execution traces. By internalizing reasoning patterns and tool-use mechanics directly into the model's weights, this approach drastically reduces context overhead while boosting reliability. Furthermore, it enables agents to seamlessly transition between internal thought processing and tool execution without depending on rigid, hand-crafted prompt templates.
Because thinking consumes context and execution time, how often an agent should generate a thought is a design trade-off:
$$ \begin{array}{l} \textbf{Prompt-ToolLoop}(\textit{user\_input}) \\ \hline \textbf{Input: } \textit{user\_input} \text{; the tool set; a step cap } \text{MAX\_STEPS} \text{; the session } \textit{history} \\ \textbf{Output: } \text{the assistant's final text} \\ \hline 1: \quad \textit{history}.\text{append}(\textit{user\_input}) \\ 2: \quad \textbf{for } \textit{step} = 1 \dots \text{MAX\_STEPS} \textbf{ do} \\ 3: \quad \qquad \textit{response} \gets \text{LLM}(\textit{history}, \textit{tools}) \\ 4: \quad \qquad \textit{history}.\text{append}(\textit{response}) \\ 5: \quad \qquad \textbf{if } \textit{response}.\text{tool\_calls} = \emptyset \textbf{ then return } \textit{response}.\text{text} \qquad \text{// a text-only turn IS the answer} \\ 6: \quad \qquad \textbf{for each } \textit{call} \in \textit{response}.\text{tool\_calls} \textbf{ do} \\ 7: \quad \qquad\quad \textbf{try } \textit{result} \gets \text{execute}(\textit{call}) \; \textbf{catch } e: \textit{result} \gets \text{"ERROR: "} + \text{message}(e) \\ 8: \quad \qquad\quad \textit{history}.\text{append}(\text{tool\_result}(\textit{call}.\text{id}, \textit{result})) \\ 9: \quad \textbf{return } \text{"stopped: step budget exhausted"} \\ \hline \end{array} $$
The loop terminates when the model's response contains no tool calls. This serves as an implicit signal that the agent has finished reasoning and is presenting its final answer. In contrast, the original ReAct paper relied on an explicit Finish action (or IsFinal signal) to exit the loop.
The model has no built-in knowledge of available tools — the same base model powers many agents, each with a different toolset, so every call must tell it which tools exist right now (it can't call what it doesn't know).
To let the model use a tool you advertise a schema — name, description, and a JSON-Schema of its parameters — via the $\textit{tools}$ argument of the model call:
{ "type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"] } } }
What is fixed, and what is yours.
parameters follow the JSON Schema
standard.{id, name, arguments}
(arguments is a JSON string).tool_call_id, which pairs result ↔ request — needed when a turn issues several calls.Because the response is just a raw string generated by an LLM sampler, it must pass three mechanical checks before any tool code executes:
jsonschema).Note on Constrained Decoding: Even if your provider uses structured outputs or tool-masking, keep these checks active—do not rely solely on constrained decoding to prevent ill-formed inputs.
A validation failure should never throw a user-facing exception. Instead:
Validation vs. Policy: Passing structural validation only proves the payload is well-formed, not that it is permitted. Checking runtime permissions—such as path access, domain whitelists, or transactional limits—is a policy question (enforced separately at security boundaries).
Native tool calling refers to an API-level mechanism where a fine-tuned model accepts a structured
$\textit{tools}$ schema parameter and directly produces a structured response object (e.g.
$\textit{response}.\text{tool\_calls}$) rather than raw text. Providers may back this with constrained decoding, making schema-invalid arguments unreachable during sampling (OpenAI's strict mode, Ollama's
format); where they do not, the schema is advice — and a malformed call is still possible.
The tool comes in two styles:
get_weather(city), send_email(to, body). One clear
capability each; easy to validate and keep safe.run_python(code) whose argument is code. Far more expressive (loop, compose several steps,
use variables within one action) — but it runs arbitrary code, so it needs strong
sandboxing (→ L-Gateway) and is harder to constrain.Both are the same JSON tool call; the code tool just carries code in arguments. Same ReAct
loop — only the tool differs.
Definition. MCP is an open protocol / contract by which a server exposes tools, data (resources), and prompt-templates, and an agent's runtime discovers and communicates with them — decoupling tool providers from agents so a tool written once works with any MCP-capable runtime.
So far each tool is hand-wired into our runtime. MCP (Model Context Protocol) is an open standard (2024) that decouples tools from the agent: tools, data, and prompts are exposed by an MCP server, and any MCP-capable client (the agent runtime) can discover and call them — without bespoke glue per tool.
tool_calls schema from the Function Calling slide).Takeaway. The model knows nothing about MCP — the runtime does. The model just sees a list of tools and emits an ordinary tool call; the runtime handles discovery, transport (JSON-RPC), routing, and security.
Definition. The Agent–Computer Interface (ACI) is the interface a tool presents to a model: the tool's name, its description, its argument names and types, and the shape of the result handed back. It is the agent-side counterpart of a human user interface (Schluntz & Zhang, 2024).
These are the fields the Function Calling slide attributed to the developer. The call's JSON envelope belongs to the endpoint and is fixed; the content of those fields is a design problem, and it is the whole of the ACI.
A human UI is judged by whether a person can operate it without a manual; an ACI is judged by whether a model can operate it without guessing. Anthropic report investing as much effort in the ACI as in a human UI — on SWE-bench, tuning the tools took more effort than tuning the prompt. They give design rules for the ACI, see Schluntz & Zhang, 2024.
The conversation was introduced in as a list of role-tagged messages — system, user,
assistant, tool. Those role strings are what goes on the wire. In code you want a type per
role, because each carries different fields — this is what the lab's $\textit{history}$ holds:
| Wire role | Type (this week's lab) | Holds |
|---|---|---|
user |
UserMessage |
the user's text |
assistant |
AssistantMessage |
text and/or tool_calls (id, name, arguments) |
tool |
ToolResultMessage |
tool_call_id (links to the call) + the tool's output |
Same three things, named for code rather than for JSON. The new part this week is the pairing:
an AssistantMessage may carry several tool_calls, and each needs its own
ToolResultMessage carrying the matching tool_call_id.
User asks "What's the weather in Berlin?", with a get_weather tool available.
history: [ system, User("What's the weather in Berlin?") ]
turn 1 LLM(history) → Assistant(tool_calls=[get_weather(city="Berlin")]) # no text yet
runtime runs get_weather("Berlin") → "12°C, rainy"
history += Assistant(tool_call) , ToolResult("12°C, rainy")
turn 2 LLM(history) → Assistant("It's 12°C and rainy in Berlin.") # no tool call → STOP
As typed messages (previous slide), $\textit{history}$ grew to:
UserMessage("What's the weather in Berlin?")AssistantMessage(tool_calls=[{name:"get_weather", arguments:{city:"Berlin"}}])ToolResultMessage(tool_call_id=…, content="12°C, rainy")AssistantMessage("It's 12°C and rainy in Berlin.") ← returnedTwo turns, one tool call — and every turn re-sent the whole history.
The next cell runs this same round-trip against a real model.
tool_call_id¶In a single turn, the model may issue multiple tool calls in parallel (e.g., get_weather("Berlin") AND get_weather("Paris")). The runtime returns multiple ToolResultMessage objects. The model uses tool_call_id to match each result back to its original request.
# The SAME round-trip as the trace above, in real code (one loop iteration).
import json
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen2.5:7b"
def get_weather(city): # the actual tool (runtime side)
return f"12°C, rainy in {city}"
tools = [{"type": "function", "function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]}}}]
msgs = [{"role": "user", "content": "What's the weather in Berlin?"}]
# turn 1: model requests a tool
r = client.chat.completions.create(model=MODEL, messages=msgs, tools=tools)
msg = r.choices[0].message
if not msg.tool_calls:
# a small model sometimes answers in text instead of calling: there is nothing to feed back,
# so the second turn must not happen at all
print("Model responded with text instead of calling a tool:", msg.content)
else:
call = msg.tool_calls[0]
args = json.loads(call.function.arguments)
result = get_weather(**args) # the runtime executes it
# feed the result back; turn 2: model gives the final answer
msgs.append(msg) # assistant tool-call msg
msgs.append({"role": "tool", "tool_call_id": call.id, "content": result})
final = client.chat.completions.create(model=MODEL, messages=msgs, tools=tools)
print(final.choices[0].message.content)
The model has no memory: the entire conversation is re-sent every turn.
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
...<|im_end|>
<|im_start|>assistant
This format is ChatML (<|im_start|> … <|im_end|>), used by e.g. Qwen — our lab
model qwen2.5 — and originally OpenAI. Other families use different delimiters (Llama,
Mistral).
Tool use adds a tool role for results, and the model's tool request is carried in the assistant turn — the exact tokens are model-specific.
Each LLM call sends more than the conversation. The loop maintains $\textit{history}$ — the running conversation (system prompt + all prior turns + this turn's tool calls/results). Around it, the runtime assembles extra context on every call:
context sent to the LLM =
history # system prompt + all turns + this turn's tool calls/results
+ tool schemas # the advertised tools
+ retrieved documents # RAG results for this query
+ long-term memory # relevant remembered facts
This is context engineering: deciding what to assemble into the context each call — and, under a finite budget, what to retrieve or compact — is the discipline itself.
$\textit{history}$ is a construction, not a transcript. The runtime chooses what to send: it can prune, merge, or summarise turns (drop a redundant reply, elide a consumed tool output) — as long as it keeps a paired
tool_call/tool_resulttogether. The messages you send ≠ the raw conversation.
The context window is a fixed token allowance, and every part of the context spends from it: more tool schemas or retrieved documents ⇒ less room for $\textit{history}$, and vice-versa.
Because the model is stateless, the whole budget is re-assembled and re-sent on every call — so each token costs latency and money every turn, not once.
Deciding what to include, retrieve, or compact to stay within this budget is a central agent-design problem — hence embeddings / retrieval (→ L-Embed / → L-RAG) and compaction (→ L-Memory). It is context engineering in practice.
The same reasoning governs procedural instructions. Rather than keeping every how-to resident
in the system prompt, an agent can file them as skills — directories whose one-line
description is all that occupies the context until a task matches it, at which point the full
instructions load (→ L-Memory). Tool schemas, retrieval and skills are three instances of one move:
index first, load on demand.
get_weather. A while loop, a growing message list, a json.loads.lecture-02-ReAct-exercises.ipynb), three parts:run_agent() from scratch (no framework)
— the loop at the heart of any agent runtime — and the prompt-based fallback for models
without native tool calling.ollama serve running; tool calling gets unreliable below ~3B.tool_calls; the
runtime executes and feeds results back. MCP standardises exposing tools.UserMessage / AssistantMessage / ToolResultMessage in a persistent,
growing history.Next week: breaking a task apart — task decomposition & a coding/web agent.
stop_reason values: finished is reported, not inferred from the absence of a tool call. https://platform.claude.com/docs/en/api/handling-stop-reasonsfinish[answer] as the only exit, think[…] as a no-op action, and stop sequences as turn boundaries.Further reading — tool use. Gulli, A. (2025). Agentic Design Patterns, Ch. 5 — Tool Use (Function Calling) (Springer): the tool-use process, agents as tools, code execution, and extensions vs. function calling.
Further reading — MCP. Gulli, A. (2025). Agentic Design Patterns, Ch. 10 — Model Context Protocol (Springer): MCP vs. function calling, client/server architecture, tools · resources · prompts, transport (STDIO / HTTP+SSE), and the agent-friendly-API caveat.