Skip to content

tripl3tr3s/agentic-tool-loop

Repository files navigation

agentic-tool-loop

CI coverage types

A tiny, fully-typed, bounded and self-correcting agentic tool-use loop for the Anthropic Messages API. No framework. No orchestration engine. No API key needed to run the tests - the whole loop is exercised against a scripted mock client.

The problem

Most "agents" are a single LLM call that emits one tool call. That is not an agent - it is a function call with extra steps. A real agentic loop lets the model:

  • call multiple tools across several turns,
  • see its own tool failures and self-correct instead of aborting,
  • stop when it decides it is done (end_turn), not after a fixed number of steps,

...while the harness enforces hard guards so it can never run forever, never hangs on a safety refusal, and never sends a malformed follow-up request.

agentic-tool-loop is ~120 lines that get those four things right.

Quickstart

pnpm add agentic-tool-loop @anthropic-ai/sdk
import Anthropic from '@anthropic-ai/sdk';
import { runAgentLoop } from 'agentic-tool-loop';

const client = new Anthropic();

const result = await runAgentLoop({
  client,
  model: 'claude-sonnet-4-6',
  system: 'You are a helpful assistant.',
  messages: [{ role: 'user', content: 'What is (2 + 3) x 4?' }],
  tools: [
    {
      name: 'add',
      description: 'Add x and y',
      input_schema: {
        type: 'object',
        properties: { x: { type: 'number' }, y: { type: 'number' } },
        required: ['x', 'y'],
      },
    },
    {
      name: 'multiply',
      description: 'Multiply x and y',
      input_schema: {
        type: 'object',
        properties: { x: { type: 'number' }, y: { type: 'number' } },
        required: ['x', 'y'],
      },
    },
  ],
  // The one seam you own: run the tool, report the outcome.
  async executeTool(name, input) {
    const x = Number(input.x),
      y = Number(input.y);
    if (name === 'add') return { ok: true, result: { value: x + y } };
    if (name === 'multiply') return { ok: true, result: { value: x * y } };
    return { ok: false, error: `unknown tool: ${name}` };
  },
  onEvent: (e) => e.type === 'text_delta' && process.stdout.write(e.text),
});

// result.status is 'completed' | 'refused' | 'max_iterations' | 'incomplete'
console.log(result);

Run the offline demo (no API key):

pnpm example

How it works

                 ┌───────────────────────────────────────────┐
                 │  for iteration in 0..maxIterations (=6)    │
                 └───────────────────────────────────────────┘
                                    │
                       stream one model turn
                                    │
                       ┌────────────┴────────────┐
                  stop_reason?                    │
        ┌───────────┬─────────────┬──────────────┴─────────┐
     end_turn    tool_use       refusal            (max_tokens / null)
        │            │              │                       │
        ▼            ▼              ▼                       ▼
    completed   run ALL tools    refused              incomplete
    + text      in parallel
                     │
              feed each result back as tool_result
              (failures -> is_error: true)
                     │
                 loop again  ──────────────────────────────┐
                                                            ▼
                          never finished in maxIterations -> max_iterations

Design decisions

  • Parallel tool execution. When the model requests several tools in one turn, they run with Promise.all and each gets a matching tool_result. This is not just speed: the API rejects any follow-up where a tool_use id has no corresponding tool_result, so the 1:1 mapping is a correctness invariant, not an optimization.
  • Failure feedback drives self-correction. A failed tool (or a thrown executor) is returned to the model as a tool_result with is_error: true and a message - so the model reads the reason and retries, instead of the loop throwing.
  • Explicit refusal guard. stop_reason === 'refusal' resolves a distinct refused result rather than falling through to a generic error.
  • The loop is bounded. No end_turn within maxIterations resolves max_iterations
    • an agent that cannot be trusted to terminate is a bug, not a feature.
  • Zero domain coupling. Tool execution is injected via executeTool; inputs can be rewritten via transformToolInput (e.g. stamp a trusted tenant id so it can never be set from model output). The loop knows nothing about your tools.

API

function runAgentLoop(config: AgentLoopConfig): Promise<AgentResult>;
AgentLoopConfig field Type Notes
client AnthropicClientLike A real new Anthropic() or any structural mock.
model string e.g. claude-sonnet-4-6.
system string | TextBlockParam[] Plain string or cacheable blocks.
messages MessageParam[] Copied, never mutated.
tools Tool[] Empty array = tool-free single turn.
executeTool (name, input) => Promise<ToolOutcome> The one required seam.
transformToolInput? (name, input) => input Rewrite input before execution.
maxIterations? number Default 6.
maxTokens? number Default 8192.
thinking? ThinkingConfigParam Adaptive-thinking passthrough.
onEvent? (event: AgentEvent) => void Streaming: iteration, text_delta, tool_call, tool_result.

AgentResult is a discriminated union on status: completed (with text + toolCalls), refused, max_iterations, or incomplete (with reason).

Develop

pnpm install
pnpm run build        # tsup -> dist (ESM + .d.ts)
pnpm run typecheck    # tsc --noEmit
pnpm run test         # vitest
pnpm run test:coverage
pnpm run lint

License

MIT

About

A tiny, fully-typed, bounded and self-correcting agentic tool-use loop for the Anthropic Messages API

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages