Lesson 14 — bounded agent behaviour¶
What you will build¶
The thing this path has been walking towards, stated plainly: a loop that calls a model, lets it ask for
tools, and stops. There is no agent loop in the runtime. What exists is chat_with_tools — a bounded
tool loop with a round limit — and the workflow engine, which supplies the deadline and the escalation
path. An agent here is those two things composed, plus a budget someone chose.
What you need¶
Nothing new.
"""Lesson 14 — bounded agent behaviour: a loop, a budget, and an escalation.
Run it with:
python -m learn_ai.ai14_agent
"""
from __future__ import annotations
import asyncio
from collections.abc import Mapping
from dataclasses import dataclass
from xyberos import StepContext, Workflow, seconds
from xyberos.ai import (
AIFacade,
ChatRequest,
FakeModelProvider,
Message,
ModelReply,
Tool,
ToolCall,
ToolSet,
ToolSpec,
)
from xyberos.errors import AIError
TICKETS = {"T-100": "printer is on fire"}
LOOKUP_SCHEMA = {
"type": "object",
"properties": {"ticket": {"type": "string"}},
"required": ["ticket"],
"additionalProperties": False,
}
@dataclass(frozen=True)
class Budget:
"""What one run of the agent may spend. Every number here is a bound, not a target."""
rounds: int = 2
deadline: float = 5.0
def lookup_ticket() -> Tool:
"""The only capability the agent has: reading one ticket."""
async def handler(arguments: Mapping[str, object], context: object) -> dict[str, object]:
return {"title": TICKETS.get(str(arguments["ticket"]), "no such ticket")}
return Tool(
spec=ToolSpec(
name="lookup_ticket",
description="Look up one ticket's title",
parameters=LOOKUP_SCHEMA,
),
handler=handler,
)
def agent_step(assistant: AIFacade, tools: ToolSet, budget: Budget) -> Workflow:
"""One step that runs a bounded tool loop, and escalates rather than guessing."""
async def run(value: str, context: StepContext) -> str:
try:
response = await assistant.chat_with_tools(
ChatRequest(
messages=(
Message.system("Answer using the tools. If you cannot, say you cannot."),
Message.user(value),
)
),
tools=tools,
max_rounds=budget.rounds,
)
except AIError as error:
return f"escalated: {type(error).__name__}"
return response.message.content
return Workflow("agent", version="1", input_type=str).step(
"answer", run, timeout=seconds(budget.deadline)
)
async def main() -> None:
"""Two runs: one the agent answers with a tool, one it escalates."""
tools = ToolSet.of(lookup_ticket())
answered = FakeModelProvider(
[
ModelReply.tools(ToolCall(name="lookup_ticket", arguments={"ticket": "T-100"})),
ModelReply.ok("T-100 is 'printer is on fire'."),
]
)
first = await agent_step(AIFacade(answered), tools, Budget()).run(
input="What is ticket T-100?", timeout=seconds(10)
)
print("asked once: ", first.value)
looping = FakeModelProvider(
[ModelReply.tools(ToolCall(name="lookup_ticket", arguments={"ticket": "T-100"}))] * 4
)
second = await agent_step(AIFacade(looping), tools, Budget(rounds=1)).run(
input="What is ticket T-100?", timeout=seconds(10)
)
print("asked forever:", second.value)
refusing = FakeModelProvider([ModelReply.refusal(content="I cannot help with that.")])
third = await agent_step(AIFacade(refusing), tools, Budget()).run(
input="What is ticket T-100?", timeout=seconds(10)
)
print("refused: ", third.value)
print("the loop is chat_with_tools, bounded by max_rounds; the runtime has no agent loop")
if __name__ == "__main__":
asyncio.run(main())
Budget is a dataclass with two numbers, and it is the most important object on this page: a round limit
and a deadline. Every agent story that goes wrong goes wrong by removing one of them.
The step's error handling is the second decision: an AIError — a round limit, a refusal, a timeout, a
provider that is down — becomes an escalation, not a retry and not an invented answer.
How to run it¶
What it prints¶
asked once: T-100 is 'printer is on fire'.
asked forever: escalated: AIToolError
refused: escalated: AIRefusalError
the loop is chat_with_tools, bounded by max_rounds; the runtime has no agent loop
Three runs, three answers, and two of them are escalations for different reasons:
| Run | Outcome | Why escalating is right |
|---|---|---|
| answers with a tool | T-100 is 'printer is on fire'. |
the loop finished inside its budget |
| asks forever | escalated: AIToolError |
the model kept asking; the round limit is what stopped it |
| refuses | escalated: AIRefusalError |
the model declined; retrying would cost tokens and refuse again |
The last line printed is the honest description of the architecture: the loop lives in
chat_with_tools, the budget lives in your code, and the sequencing lives in a workflow. Nothing in the
runtime decides for you how long an agent may think.
Common mistakes¶
- Removing the round limit because it was inconvenient. The limit is the difference between an agent and a runaway loop that bills by the minute.
- Letting the agent decide when to escalate. Escalation is a policy: an error class, a confidence, a budget — decided by the application, and testable without a model.
- Giving the loop a write tool. With tools, the model's output becomes actions; every action needs an approval boundary, which is the hybrid path's whole subject.
- Treating the model's summary as the record. The tool events and the step outcomes are the record; the answer is what the user was told.
- An unbounded conversation as "context". A tool loop plus an unbounded window is the most expensive request in the system; bound both, and say what the bound costs.
Next¶
Lesson 15 turns everything so far into tests that run with no model service — which is how this path itself is verified.