Skip to content

Agent

A user asks “what’s the weather in Paris?”. Your code can’t just hit a weather API — the LLM has to decide which tool to call, with what args, then read the result, then decide whether to call another tool or respond. That decide → act → observe → repeat is what makes an Agent an Agent, not a function call.

The Agent primitive is the ReAct loop (Yao 2022). One iteration:

LLM call → route → [tool calls → loop] OR [final answer]

Each iteration: the LLM produces text + optional tool calls. If tools were called, the framework executes them, appends results to the message history, and starts another iteration. If no tools were called (or maxIterations is hit), the loop exits with the final text.

If it doesn’t loop-with-tools, it isn’t an Agent — it’s an LLMCall.

Agent.create({ provider, model }) → builder. .system(...) sets the system prompt. .tool(...) registers tools (each with a JSON schema). .build() finalizes:

examples/core/02-agent-with-tools.ts (region: build)
const agent = Agent.create({
provider: provider ?? exampleProvider('feature', { respond: weatherRespond }),
model: 'mock',
maxIterations: 5,
})
.system('You answer weather questions using the `weather` tool.')
.tool({
schema: {
name: 'weather',
description: 'Get current weather for a city.',
inputSchema: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
},
execute: async (args) => `${(args as { city: string }).city}: sunny, 72°F`,
})
.build();

The framework owns the iteration loop. You declare what tools the agent has; the LLM decides when (and with what args) to call them; the framework dispatches and feeds results back.

Because the framework owns the loop, observability is just attaching listeners — no SDK, no agent-instrumentation wrapper:

examples/core/02-agent-with-tools.ts (region: observe)
agent.on('agentfootprint.stream.tool_start', (e) =>
console.log(`→ tool ${e.payload.toolName}(${JSON.stringify(e.payload.args)})`),
);
agent.on('agentfootprint.stream.tool_end', (e) =>
console.log(`← tool result: ${e.payload.result}`),
);

47 typed events fire across 13 domains during a single agent.run(). See the Observability guide for the full taxonomy.

Every Agent has a maxIterations cap (default 10). The loop exits when:

  1. The LLM returns text with no tool calls (normal completion)
  2. maxIterations is reached (forced exit; final iteration’s text becomes the result)
  3. A tool throws (propagates as an error; subject to withRetry if wrapped)
  4. The agent pauses via askHuman / pauseHere (returns a PauseResult instead of a string)

Tune maxIterations to your tool budget. Tool-heavy agents (research, code-gen) commonly run 15–30; chat agents 3–5.

If your agent uses memory (.memory(...)), every .run() call must include an identity so memory is scoped per tenant / principal / conversation:

await agent.run({
message: 'How long do refunds take?',
identity: { tenant: 'acme', principal: 'alice', conversationId: 'thread-42' },
});

Without an identity, memory falls back to a global namespace — fine for single-user prototypes, dangerous in production multi-tenant apps. See Memory guide.

An Agent is a runner, just like LLMCall, Sequence, Parallel, Conditional. Use them as steps in larger graphs:

import { Sequence } from 'agentfootprint';
const research = Sequence.create()
.step('plan', plannerLLM)
.step('execute', researchAgent)
.step('summarize', summarizerLLM)
.build();

There is no separate “multi-agent” class — agents are the building blocks. See Patterns for Reflexion / ToT / Debate / Map-Reduce / Swarm recipes.

  • Don’t subclass Agent for a “smarter” agent — use .skill() / .instruction() to inject behavior. Subclassing breaks composition.
  • Don’t put async I/O in tool execute’s synchronous setup — it runs every iteration. Cache outside; pure dispatch inside.
  • Don’t bypass maxIterations to “let the agent decide” — every loop has a cost ceiling. Set it explicitly; observe agentfootprint.agent.iteration_end to track.