The Action Boundary
The previous chapter ended with a division of responsibility:
The model proposes. The runtime decides what may execute. The environment provides evidence about what actually happened.
This chapter takes the first clause seriously. What does it actually mean for a model to propose an action?
Suppose the model emits the sentence “Search the documentation for the latest PyTorch optimizer API.” A human reads that and understands the intention immediately. A runtime cannot act on it at all, because it needs answers to six questions the sentence does not contain: which action, which arguments, whether those arguments are well formed, whether their values are meaningful, whether this action is permitted in this run, and whether its execution preconditions currently hold.
Only when all six have answers should anything in the environment change. So the mechanism this chapter adds is a boundary that asks them in order:
flowchart TD
M[model output] --> P[parse representation]
P --> S[validate schema]
S --> V[validate semantics]
V --> A[authorize]
A --> C[check preconditions]
C --> X[execute]
X --> O[observation]
P -.rejected.-> R[rejection<br/>tagged with stage]
S -.rejected.-> R
V -.rejected.-> R
A -.rejected.-> R
C -.rejected.-> R
R --> O
Note the dotted edges. Every stage can refuse, and every refusal ends up in the same place as a success โ as an observation the next decision can use. That is the whole design, and it rests on one idea:
A model output can propose an action without having authority to execute it.
That distinction is what turns tool use from a prompt trick into a software interface.
1. Start with the failure
Imagine an agent with two capabilities, search(query) and calculate(expression), and a naive implementation that asks for a command in natural language:
def decide(task: str) -> str:
return llm(
f"""
Available tools:
- search(query)
- calculate(expression)
Task:
{task}
Tell me which tool to call.
"""
)
Sometimes this produces exactly what we hoped for. Often it produces something else, and the something-elses are not variations of one problem:
| Model output | What went wrong |
|---|---|
SearchWeb("PyTorch optimizer state") |
invented a capability that does not exist |
calculate(expression="2 +") |
real action, unusable argument |
I should probably search the docs first. |
described an intention rather than encoding an action |
{"tool": "search", "query": 17} |
structured, but wrong shape and wrong type |
These fail at four different places and want four different responses. Prompt engineering cannot collapse them into one category, because they are not one category. What the runtime needs is an explicit contract it can check.
2. Move from language to a finite action space
The fix begins by changing the question. Instead of what do you want to do?, ask: choose one action from this set and supply its required arguments.
With an action space of {search, calculate, final}, a proposal becomes a data structure:
{
"action": "search",
"arguments": {
"query": "PyTorch optimizer state"
}
}
JSON is convenient here, but JSON is not the principle. A native function-call object, a typed Python object, a protocol buffer, a JSON Schema or a grammar-constrained representation would all serve. What matters is the shift from free-form intention to a finite action vocabulary with explicit arguments โ because that is what makes a proposal inspectable by software rather than only by a reader.
Tool-use research treats function selection and argument construction as distinct capabilities for exactly this reason. Models choose the wrong function, invent APIs, and supply incorrect arguments even when the surrounding natural-language answer sounds entirely plausible.[1][4]
3. Parsing is not validation, and the ladder has five rungs
Suppose the model returns this, and it parses perfectly:
{ "action": "launch_missiles", "arguments": {} }
The parser’s success tells us precisely one thing: the representation can be decoded. It says nothing about whether the action belongs to our system. Decoding and validating are different jobs, and collapsing them is how launch_missiles reaches a dispatch table.
Treating validation as a single Boolean loses even more. A proposal can pass one check and fail the next, and which check it failed is the most useful debugging signal the boundary produces. So the boundary has five distinct rungs:
| Rung | Question | Example failure |
|---|---|---|
| 1. Representation | Can the output be decoded at all? | truncated JSON, prose instead of an object |
| 2. Schema | Does it have the right structure? | unknown action, missing field, wrong primitive type |
| 3. Semantics | Are the values meaningful for this action? | empty query, max_results of 10,000,000 |
| 4. Authorization | Is this allowed for this run, user and context? | valid publish in a read-only run |
| 5. Preconditions | Can it sensibly execute right now? | file missing, budget exhausted, no connection |
We are going to make those five rungs a real type rather than a diagram, because the difference between naming a concept and implementing it is most of this book:
from enum import StrEnum
class Stage(StrEnum):
REPRESENTATION = "representation"
SCHEMA = "schema"
SEMANTICS = "semantics"
AUTHORIZATION = "authorization"
PRECONDITION = "precondition"
EXECUTION = "execution"
Everything the rest of the chapter builds reports failure in these terms.
4. Validated data should become a different type
There is a design move here worth stating on its own, because it does more work than it looks like it does.
Do not let arbitrary dictionaries flow to execution. After validation, convert the proposal into a typed action:
from dataclasses import dataclass
from typing import Literal
@dataclass(frozen=True)
class SearchAction:
kind: Literal["search"]
query: str
max_results: int
@dataclass(frozen=True)
class CalculateAction:
kind: Literal["calculate"]
expression: str
@dataclass(frozen=True)
class FinalAction:
kind: Literal["final"]
answer: str
Action = SearchAction | CalculateAction | FinalAction
The executor’s signature now says Action, not dict. That makes the boundary visible in the program’s type structure: static analysis can distinguish accepted actions from untrusted decoded data, and the runtime can be organized
so that only the acceptance boundary constructs an Action. Python type hints do not enforce this boundary by themselves. The enforcement comes from the control flow: raw output is decoded and validated first, and the executor is only called with the accepted object produced by that path.
5. Validate the schema exactly
Let us build the conversion by hand so the mechanism stays visible. First, two exception types, so that schema failures and semantic failures stay distinguishable all the way up:
class SchemaError(ValueError):
"""Structure, keys or primitive types are wrong."""
class SemanticError(ValueError):
"""Structure is correct; the values are not usable."""
def require_exact_keys(data: dict, expected: set[str], *, where: str) -> None:
actual = set(data)
missing = expected - actual
extra = actual - expected
if missing:
raise SchemaError(f"Missing fields in {where}: {sorted(missing)}")
if extra:
raise SchemaError(f"Unexpected fields in {where}: {sorted(extra)}")
Rejecting extra keys matters as much as requiring missing ones. An unexpected field usually means the model has invented a parameter, and silently discarding it hides a decision the model believed it was making.
Now the outer proposal, which dispatches to a per-action validator:
def validate_proposal(data: object) -> Action:
if not isinstance(data, dict):
raise SchemaError("Action proposal must be an object")
require_exact_keys(data, {"action", "arguments"}, where="action proposal")
name, arguments = data["action"], data["arguments"]
if not isinstance(name, str):
raise SchemaError("action must be a string")
if not isinstance(arguments, dict):
raise SchemaError("arguments must be an object")
validators = {
"search": validate_search,
"calculate": validate_calculate,
"final": validate_final,
}
if name not in validators:
raise SchemaError(f"Unknown action: {name}")
return validators[name](arguments)
An unknown action now fails here and never reaches a tool implementation.
6. Schema validity is not semantic validity
Consider a proposal whose types are all correct:
{
"action": "search",
"arguments": { "query": "", "max_results": 0 }
}
query is a string. max_results is an integer. A type-only schema could therefore accept the proposal even though the search is useless. Richer schemas can encode constraints such as minimums, maximums, string lengths, patterns and enumerated values. Where a stable invariant fits cleanly
in the schema, encode it there. But not every semantic rule is merely structural. The application may still need to decide whether a value is meaningful in the current domain and
context. So each validator does both jobs, and says which one failed:
def validate_search(args: dict) -> SearchAction:
require_exact_keys(args, {"query", "max_results"}, where="search arguments")
query, max_results = args["query"], args["max_results"]
if not isinstance(query, str):
raise SchemaError("query must be a string")
if type(max_results) is not int:
raise SchemaError("max_results must be an integer")
query = query.strip()
if not query:
raise SemanticError("query cannot be empty")
if not 1 <= max_results <= 20:
raise SemanticError("max_results must be between 1 and 20")
return SearchAction("search", query, max_results)
validate_calculate and validate_final follow the same shape: exact keys, then types as SchemaError, then values as SemanticError. The pattern is deliberately boring, and the boringness is the point โ this is the layer where we want no cleverness at all.
7. Authorization is a third question again
Suppose the action space later grows to include read_file, write_file, send_message and publish. A publish proposal can be perfectly formed, entirely meaningful, and still not permitted in the current run.
That is not a schema failure and not a semantic one. It is an authorization decision, and it depends on context the proposal itself cannot contain:
@dataclass(frozen=True)
class RunPolicy:
allowed_actions: frozenset[str]
def authorize(action: Action, policy: RunPolicy) -> None:
if action.kind not in policy.allowed_actions:
raise PermissionError(f"Action not allowed in this run: {action.kind}")
Validation asks does this describe a legitimate action? Authorization asks may this run perform it? Keeping them apart matters enormously once tools have side effects, because they change on different schedules and for different reasons โ the action contract is a property of the system, while the policy is a property of a particular run.
The model can propose more than the runtime permits. The runtime owns authority.
8. Constrained generation helps, and does not replace the boundary
Many production systems no longer rely only on a prompt that says please return valid JSON. They may use native function calling, JSON Schema constrained output, formal grammars, or constrained decoding. This is a genuine improvement and can eliminate entire classes of representation and schema failure. Depending on the constraint language, it can also enforce some value restrictions. It does not eliminate the runtime boundary. Authorization and execution preconditions remain external to constrained generation, and application-level semantic invariants may still depend on context the decoder does not own.
This is a genuine improvement, and it eliminates entire classes of representation error. It does not eliminate the boundary, because two of our five rungs are untouched by it. Constrained decoding will happily produce {"action": "search", "arguments": {"query": "", "max_results": 20}} โ structurally flawless, semantically empty. It will just as happily produce a perfectly formed publish in a run with no publishing rights.
The three mechanisms answer three different questions:
| Mechanism | Question it answers | Lives where |
|---|---|---|
| Constrained generation | Can the output be shaped correctly? | inside the model call |
| Runtime validation | Do the values satisfy application invariants? | at the boundary |
| Authorization | Does this run have the authority? | at the boundary, against policy |
Use all three. Expect none of them to cover for another.
9. Repair representation, never invent intent
Suppose a model wraps otherwise valid JSON in a Markdown fence. Stripping a known fence is deterministic representation normalisation, and it is fine:
def strip_json_fence(text: str) -> str:
text = text.strip()
if text.startswith("```json"):
text = text[len("```json"):]
elif text.startswith("```"):
text = text[len("```"):]
if text.endswith("```"):
text = text[:-3]
return text.strip()
Now compare it with this, which looks superficially similar:
if "query" not in arguments:
arguments["query"] = "something useful"
The first recovers a value the model unambiguously intended. The second fabricates a decision the model never made, and then hides the fabrication behind a successful validation. Every downstream log will show a well-formed search that the agent never chose.
Repair representation when the intended value is already unambiguous. Never manufacture semantic intent merely to make validation pass.
When semantic intent is missing or ambiguous, a visible rejection is better than silently inventing a value. The rejection preserves the fact that the agent did not make the required decision; a substitution erases it.
10. Rejections become observations
An invalid proposal does not have to end the run. It is information, and the previous chapter gave us somewhere to put information.
This is where the Stage enum earns its place. We need an outcome type that carries not just failed but failed where:
@dataclass(frozen=True)
class Accepted:
action: Action
@dataclass(frozen=True)
class Rejected:
stage: Stage
message: str
Decision = Accepted | Rejected
A rejection converts straight into the observation format from the previous chapter:
observation = {
"kind": "invalid_action",
"stage": rejection.stage,
"error": rejection.message,
}
And the policy can retry with the exact complaint rather than a vague instruction to try harder:
def correction_prompt(previous_output: str, error: str) -> str:
return f"""
Your previous action proposal was rejected.
Previous proposal:
{previous_output}
Validation error:
{error}
Return one corrected action proposal.
"""
The causal structure โ proposal, rejection, observation, new proposal โ is the loop from the previous chapter becoming concrete for the first time. Notice what the validator did and did not do. It did not make the agent smarter. It made failure observable, which is the only thing that makes improvement possible.
11. Correction needs a budget
A correction loop must be bounded, and the bound must be visible:
MAX_REPAIRS = 2
REPAIRABLE_STAGES = {
Stage.REPRESENTATION,
Stage.SCHEMA,
Stage.SEMANTICS,
}
def get_valid_action(prompt: str, llm, policy: RunPolicy, context) -> Decision:
raw = llm(prompt)
for attempt in range(MAX_REPAIRS + 1):
decision = prepare_action(raw, policy, context)
if isinstance(decision, Accepted):
return decision
if decision.stage not in REPAIRABLE_STAGES:
return decision
if attempt == MAX_REPAIRS:
return decision
raw = llm(correction_prompt(raw, decision.message))
raise RuntimeError("unreachable")
Representation, schema and semantic failures may justify correction of the proposal itself. Authorization and precondition failures normally belong back in the main agent loop, because changing the wording of the same proposal should not change what the runtime permits or what the environment currently makes possible.
A model that fails the same contract three times in a row is telling us something worth knowing โ most often that the action schema is ambiguous, or that the task cannot be expressed in the action space we defined. An unbounded retry loop destroys that signal by eventually succeeding through sheer resampling.
Loop budgets and termination get proper treatment later, when we build runtime state and progress. Here we need only one commitment:
A validation repair policy must have an explicit failure state.
12. Validate completely before any side effect
The boundary should be atomic from the validator’s point of view: parse the complete proposal, validate it completely, authorize it completely, check preconditions, and only then execute once.
This is ordinary software engineering, and stochastic model output raises the stakes. A half-interpreted proposal that has already begun writing files leaves the system in a state no rung of the ladder ever approved. The executor should only ever operate on something the runtime has already accepted in full:
def execute(action: Action, tools):
if isinstance(action, SearchAction):
return tools.search(query=action.query, max_results=action.max_results)
if isinstance(action, CalculateAction):
return tools.calculate(action.expression)
if isinstance(action, FinalAction):
return action.answer
raise TypeError(f"Unsupported validated action: {type(action)}")
The model never calls a tool. It never has a reference to one.
13. Execution is another boundary
Passing validation does not guarantee that execution succeeds. A search service can be down, a referenced file can vanish, an API can reject a request that was legal when it was proposed. The environment is free to change between the decision and the act.
So execution returns an observation rather than silently becoming truth:
from typing import Any
@dataclass(frozen=True)
class Observation:
ok: bool
kind: str
data: Any
def execute_as_observation(action: Action, tools) -> Observation:
try:
value = execute(action, tools)
except Exception as exc:
return Observation(ok=False, kind="execution_error", data=repr(exc))
return Observation(ok=True, kind="action_result", data=value)
This is the sixth Stage, and it is the one the runtime cannot prevent โ only observe and report.
14. Assemble the boundary
Everything above composes into one function, and it is worth reading closely because it is the chapter compressed:
import json
def prepare_action(raw: str, policy: RunPolicy, context) -> Decision:
try:
data = json.loads(strip_json_fence(raw))
except json.JSONDecodeError as exc:
return Rejected(Stage.REPRESENTATION, str(exc))
try:
action = validate_proposal(data)
except SchemaError as exc:
return Rejected(Stage.SCHEMA, str(exc))
except SemanticError as exc:
return Rejected(Stage.SEMANTICS, str(exc))
try:
authorize(action, policy)
except PermissionError as exc:
return Rejected(Stage.AUTHORIZATION, str(exc))
try:
check_preconditions(action, context)
except PreconditionError as exc:
return Rejected(Stage.PRECONDITION, str(exc))
return Accepted(action)
The five pre-execution rungs from ยง3 are now something the program can execute rather than something the reader has to remember. Every rejection is labelled with the rung it fell from.
The property that matters here is architectural rather than syntactic: raw output cannot reach execute() without crossing this function. A framework may hide the same responsibility behind decorators, generated schemas or provider-native function calling. The responsibility does not go away; it only becomes someone else’s code.
The full implementation, including validate_calculate, validate_final and the precondition rung, is in the companion repository.
15. Test the boundary, then measure it
The action boundary is ordinary software and deserves ordinary tests. Because rejections now carry a stage, we can assert on where a proposal failed rather than merely that it did:
| Proposal | Expected stage |
|---|---|
| malformed representation | REPRESENTATION |
| unknown action | SCHEMA |
| missing argument | SCHEMA |
| unexpected argument | SCHEMA |
| wrong primitive type | SCHEMA |
| empty or out-of-range value | SEMANTICS |
| valid but disallowed action | AUTHORIZATION |
| valid and allowed action | reaches the executor |
| executor raises | EXECUTION, returned as an observation |
| valid action with unmet runtime requirement | PRECONDITION |
def test_unknown_action_is_rejected_at_the_schema_rung():
raw = json.dumps({"action": "teleport", "arguments": {}})
policy = RunPolicy(allowed_actions=frozenset({"search", "calculate", "final"}))
decision = prepare_action(raw, policy)
assert isinstance(decision, Rejected)
assert decision.stage is Stage.SCHEMA
def test_forbidden_action_is_rejected_at_the_authorization_rung():
raw = json.dumps({
"action": "search",
"arguments": {"query": "PyTorch", "max_results": 5},
})
policy = RunPolicy(allowed_actions=frozenset({"final"}))
decision = prepare_action(raw, policy)
assert isinstance(decision, Rejected)
assert decision.stage is Stage.AUTHORIZATION
Those two tests fail for different reasons, and the assertions say so. If every rejection collapses into bad tool call, we lose the only diagnostic this mechanism produces.
The same structure gives us measurement for free โ count rejections by stage rather than reporting a single success rate. A number like agent success rate = 72% tells us nothing about this chapter’s mechanism, and it can move in misleading directions. Suppose a schema change causes silent wrong executions to fall while visible validation failures rise. The aggregate looks worse. The system has become safer and considerably easier to debug.
This is also why function-calling benchmarks such as BFCL evaluate function selection, argument matching, relevance and multi-turn behaviour separately: “can call a tool” is not one indivisible capability.[4] Full observability comes later in the book. For now:
Record the rung at which a proposed action failed.
16. When the boundary earns its cost
Not every model call needs this. The test is whether the output crosses into the world:
flowchart TD
Q{Does model output<br/>trigger external behaviour?} -->|no| T[plain output]
Q -->|yes| A[define action space]
A --> B[structured proposal]
B --> C[validate]
C --> D[authorize]
D --> E[execute]
Rewrite this paragraph stays on the left branch. Choose one of these executable capabilities and supply its arguments belongs on the right.
There is a useful diagnostic hiding in the right-hand branch. If a capability cannot be described precisely enough to validate, that is a design signal rather than a validation problem. It often means the action interface is too broad. A capability such as run_anything(command) can still be constrained by sandboxing, allowlists, path policies and resource limits, but its semantic contract is much coarser than a capability such as read_file(path) or run_test(test_name). The harder it is to state what a valid invocation means, the more work the runtime must do to constrain and reason about that capability.
17. What we earned
The previous chapter gave us adaptive control. This chapter made the act step of that loop explicit, and the important addition was never JSON. It was the separation between probabilistic proposal and deterministic runtime authority, which lets us state the contract precisely:
The model may propose any output it can generate. Only the runtime can turn an accepted proposal into an environmental action.
That is the first mechanism added to the agent we began with, and it immediately exposes the next problem.
A proposal can be well formed, semantically valid, authorized and executable โ and still be a poor choice. Our boundary is good at rejecting proposals that are malformed, invalid, unauthorized or currently unexecutable. It has nothing whatsoever to say about a proposal that passes every one of those checks and is still a poor choice. Validity and quality are different properties. So how do we improve the decision without changing the model underneath it? One answer costs nothing but compute: stop accepting the first proposal. Generate several, then compare them.
Research roots
This chapter derives the action boundary from ordinary software-engineering principles, but several research threads make the motivation concrete.
-
Patil et al. โ Gorilla: Large Language Model Connected with Massive APIs (2023). API and tool use exposes failures such as incorrect API selection and arguments, motivating explicit interfaces and evaluation of tool-use behaviour.
https://arxiv.org/abs/2305.15334 -
Geng et al. โ Grammar-Constrained Decoding for Structured NLP Tasks without Finetuning (EMNLP 2023). Shows how formal grammars can constrain generation to valid structures rather than relying only on prompt compliance.
https://arxiv.org/abs/2305.13971 -
Geng et al. โ Generating Structured Outputs from Language Models: Benchmark and Studies (2025). Introduces JSONSchemaBench and studies constrained decoding across real-world JSON schemas, including efficiency, coverage and quality.
https://arxiv.org/abs/2501.10868 -
Patil et al. โ The Berkeley Function Calling Leaderboard (BFCL): From Tool Use to Agentic Evaluation of Large Language Models (ICML 2025). Evaluates function selection, argument construction, relevance, multi-turn tool use and related failure modes across models.
https://gorilla.cs.berkeley.edu/leaderboard
Next: Candidate Generation and Selection
We have a runtime that reliably rejects bad proposals and reliably accepts good ones. It accepts the first valid proposal it is given, which means the quality of the whole agent is still the quality of one sample from one model call.
The next chapter keeps the boundary exactly as it is and changes what flows into it. Instead of one proposal we generate several, then select among them โ which splits agent quality into two measurable halves: whether a good candidate was produced at all, and whether we managed to pick it.