Skip to content

itaylor/agentic-loop

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

26 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

agentic-loop

A lightweight, functional library for running agentic loops with LLMs, tool calling, and summarization support.

Installation

npm install @itaylor/agentic-loop

You'll also need an AI SDK provider package for whichever LLM you're using, e.g.:

npm install @ai-sdk/openai
# or @ai-sdk/anthropic, @ai-sdk/google, ai-sdk-ollama, etc.

Quick Start

import { runAgentSession } from "@itaylor/agentic-loop";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

// Simple case - await directly
const result = await runAgentSession(
  { languageModel: openai("gpt-4o") },
  {
    tools: {
      search: {
        description: "Search for information",
        inputSchema: z.object({ query: z.string() }),
        execute: async ({ query }) => ({ results: ["result1", "result2"] }),
      },
    },
  }
);

console.log(result.finalOutput);

// Advanced case - get sessionId immediately
const session = runAgentSession(modelConfig, sessionConfig);
console.log("Session started:", session.sessionId);
await logSessionStart(session.sessionId);
const result = await session.promise;

Features

  • Multi-turn conversations with automatic tool calling
  • Any AI SDK provider (OpenAI, Anthropic, Google, Ollama, Mistral, and more, anything that is supported by AI SDK)
  • Built-in task completion - agents call task_complete when done
  • Session suspension - pause sessions to wait for external events (approval, async ops, etc.)
  • Session resumption - continue from saved messages after crashes
  • Token management - automatic summarization when approaching limits
  • Error handling - automatic retries with errors added to conversation
  • Idle detection - nudges agents stuck in thinking loops
  • Event callbacks - hooks for logging, persistence, monitoring
  • Functional design - no classes, pure functions

API

runAgentSession(modelConfig, sessionConfig): AgentSession

Returns immediately with session object containing sessionId, initialMessage, and promise. The session object is "thenable" - you can await it directly.

ModelConfig:

{
  languageModel: LanguageModel;                   // Any AI SDK LanguageModelV3 instance
  languageModelSettings?: LanguageModelSettings;  // Applied to every agent LLM call
  summaryLanguageModel?: LanguageModel;           // Optional separate model for summarization
  summaryLanguageModelSettings?: LanguageModelSettings; // Settings for summary model only
}

languageModel accepts any AI SDK-compatible model instance:

import { openai } from "@ai-sdk/openai";
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import { ollama } from "ai-sdk-ollama";

{ languageModel: openai("gpt-4o") }
{ languageModel: anthropic("claude-opus-4-5") }
{ languageModel: google("gemini-2.0-flash") }
{ languageModel: ollama("llama3.2") }

LanguageModelSettings covers temperature, topP, topK, maxOutputTokens, providerOptions, and other per-call settings:

// OpenAI reasoning effort
{ languageModelSettings: { providerOptions: { openai: { reasoningEffort: "high" } } } }

// Anthropic extended thinking
{ languageModelSettings: { providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 10000 } } } } }

// Cheap fast model for summarization, expensive reasoning model for agent work
{
  languageModel: anthropic("claude-opus-4-5"),
  languageModelSettings: { providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 10000 } } } },
  summaryLanguageModel: anthropic("claude-haiku-4-5"),
  summaryLanguageModelSettings: { temperature: 0 },
}

When summaryLanguageModel is not provided, summarization uses languageModel + languageModelSettings. When summaryLanguageModel is provided, summaryLanguageModelSettings is fully independent — no fallback from the main settings.

AgentSessionConfig:

{
  systemPrompt?: string;        // Optional — defaults to a generic helpful assistant prompt
  tools: Record<string, Tool>;
  sessionId?: string;           // Auto-generated if not provided
  messages?: Message[];         // Resume from saved messages (ignored if empty)
  initialMessage?: string;      // Starting message for fresh sessions (ignored if messages provided)
  maxTurns?: number;            // Default: 50
  tokenLimit?: number;          // Trigger summarization
  llmTimeout?: number;          // LLM call timeout (ms)
  toolTimeout?: number;         // Tool call timeout (ms)
  logger?: Logger;              // Custom logger
  callbacks?: SessionCallbacks; // Event hooks
  metadata?: Record<string, any>;
}

AgentSession:

{
  sessionId: string;                // Available immediately
  initialMessage: string;           // The message that started the session
  promise: Promise<AgentSessionResult>;
  then: (...) => ...;               // Makes it awaitable
}

AgentSessionResult:

{
  sessionId: string;
  finalOutput: string;
  totalTurns: number;
  completionReason: "task_complete" | "max_turns" | "error" | "suspended";
  messages: Message[];
  taskResult?: any;    // Data from task_complete tool
  suspendInfo?: SessionSuspendInfo;  // Present if suspended
  error?: Error;
}

Callbacks

All callbacks receive sessionId as first parameter:

{
  onTurnStart?: (sessionId: string, turn: number) => void | Promise<void>;
  onAssistantMessage?: (sessionId: string, text: string, turn: number) => void | Promise<void>;
  onToolCall?: (sessionId: string, info: ToolCallInfo) => void | Promise<void>;
  onToolResult?: (sessionId: string, info: ToolResultInfo) => void | Promise<void>;
  onError?: (sessionId: string, info: ErrorInfo) => void | Promise<void>;
  onComplete?: (sessionId: string, info: SessionCompleteInfo) => void | Promise<void>;
  onMessagesUpdate?: (sessionId: string, messages: Message[]) => void | Promise<void>;
  
  // Summarization callbacks (library handles the LLM call)
  onBeforeSummarize?: (sessionId: string, messages: Message[]) => Message[] | Promise<Message[]>;
  onAfterSummarize?: (sessionId: string, summarizedMessages: Message[]) => Message[] | Promise<Message[]>;
  
  // Suspension callback
  onSuspend?: (sessionId: string, info: SessionSuspendInfo) => void | Promise<void>;
}

Messages

The Message type reflects the actual AI SDK message structure. Messages can contain simple text or complex content with tool calls, results, images, and files:

type Message = 
  | {
      role: "user";
      content: string | Array<TextPart | ImagePart | FilePart>;
    }
  | {
      role: "assistant";
      content: string | Array<TextPart | FilePart | ToolCallPart | ToolResultPart>;
    };

Simple text messages:

{ role: "user", content: "Hello!" }
{ role: "assistant", content: "Hi there!" }

Complex messages with tool calls/results: When the agent calls tools, the AI SDK automatically creates messages with structured content arrays containing ToolCallPart and ToolResultPart objects. These are handled internally by the library.

For most use cases, you can treat message content as strings. The library handles the complex formats automatically when tools are used.

Examples

Basic Usage

import { ollama } from "ai-sdk-ollama";

const result = await runAgentSession(
  { languageModel: ollama("qwen2.5:7b") },
  {
    tools: { /* your tools */ },
    initialMessage: "Summarize the three laws of thermodynamics.",
  }
);

With Persistence

const session = runAgentSession(modelConfig, {
  sessionId: generateId(),
  systemPrompt: "You are a specialized data analyst.",
  tools: myTools,
  callbacks: {
    onMessagesUpdate: async (sessionId, messages) => {
      await db.saveMessages(sessionId, messages);
    },
    onComplete: async (sessionId, info) => {
      await db.markComplete(sessionId, info);
    },
  },
});

console.log("Tracking session:", session.sessionId);
const result = await session.promise;

Resume After Crash

// Server crashed, restarting...
const savedMessages = await db.loadMessages("session-123");

const session = runAgentSession(modelConfig, {
  sessionId: "session-123",   // Same ID
  messages: savedMessages,    // Continue from here
  systemPrompt: "...",
  tools: myTools,
});

const result = await session.promise;

With MCP Tools

import { createMCPClient } from "@ai-sdk/mcp";

const mcpClient = await createMCPClient({ /* config */ });
const mcpTools = await mcpClient.tools();

const result = await runAgentSession(modelConfig, {
  tools: mcpTools,  // Pass MCP tools directly
  // ...
});

await mcpClient.close();

If you want to give your agent code editing capabilities (read/write files, search, apply patches, etc.), consider using agent-mcp — a fast stdio MCP server providing file system navigation, text search, code analysis, and patch application.

import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
import { createMCPClient } from "@ai-sdk/mcp";

const transport = new Experimental_StdioMCPTransport({
  command: "/path/to/agent-mcp",
  args: ["/path/to/your/repo"],
});

const mcpClient = await createMCPClient({ transport });
const mcpTools = await mcpClient.tools();

const result = await runAgentSession(modelConfig, {
  tools: mcpTools,
  initialMessage: "Review the codebase and fix any TypeScript errors you find.",
});

await mcpClient.close();

Get SessionId Immediately

const session = runAgentSession(modelConfig, sessionConfig);

// SessionId available right away
await monitoring.startTracking(session.sessionId);
await logSessionStart(session.sessionId, session.initialMessage);

// Then wait for completion
const result = await session.promise;

Token Summarization

The library automatically summarizes messages when approaching the token limit:

const result = await runAgentSession(modelConfig, {
  systemPrompt: "...",
  tools: myTools,
  tokenLimit: 100000, // ~25k tokens - triggers summarization
  callbacks: {
    // Optional: modify messages before summarization
    onBeforeSummarize: async (sessionId, messages) => {
      console.log(`[${sessionId}] Summarizing ${messages.length} messages`);
      // Example: keep last 10 messages out of summarization
      return messages.slice(0, -10);
    },
    
    // Optional: modify messages after summarization
    onAfterSummarize: async (sessionId, summarizedMessages) => {
      console.log(`[${sessionId}] Summary complete`);
      // Example: append the last 10 messages we kept
      return [...summarizedMessages, ...recentMessages];
    },
  },
});

How it works:

  1. Library detects token limit approaching
  2. Calls onBeforeSummarize(sessionId, messages) → returns messages to summarize
    • Default: all messages are summarized
    • Example: Remove system prompts, keep recent messages
  3. Library calls the LLM to summarize the messages
  4. Creates summary as 2 messages: "Previous conversation summary:" + summary text
  5. Calls onAfterSummarize(sessionId, summarizedMessages) → returns final messages
    • Default: just the summary (2 messages)
    • Example: Add back system prompts, append recent messages
  6. Replaces message history and continues

Examples:

// Keep last 10 messages
onBeforeSummarize: (sessionId, messages) => messages.slice(0, -10),
onAfterSummarize: (sessionId, summary) => [...summary, ...messages.slice(-10)],

// Remove system prompts before, add back after
onBeforeSummarize: (sessionId, messages) => 
  messages.filter(m => !m.content.startsWith('You are')),
onAfterSummarize: (sessionId, summary) => 
  [{ role: 'user', content: systemPrompt }, ...summary],

No LLM setup needed - library handles the heavy lifting!

Built-in Tools

task_complete

Every session includes this tool automatically:

// Agent calls this internally:
task_complete({
  summary: "Completed the analysis",
  result: { findings: [...] }
})

Signals graceful completion with completionReason: "task_complete".

Session Suspension

Agents can suspend their session to wait for external events (human approval, async operations, etc.). The session stops cleanly and can be resumed later, even after server restarts.

Creating a Suspendable Tool

Any tool can suspend a session by returning a special __suspend__ signal:

const result = await runAgentSession(modelConfig, {
  systemPrompt: "You are a helpful assistant that needs approval.",
  initialMessage: "Please request approval to proceed.",
  tools: {
    request_approval: {
      description: "Request approval from a human. Your session will pause until they respond.",
      inputSchema: z.object({
        action: z.string().describe("The action that needs approval"),
        reason: z.string().describe("Why this action is needed"),
      }),
      execute: async (args) => {
        // Store the approval request somewhere
        await db.createApprovalRequest(args);
        
        // Return suspension signal
        return {
          __suspend__: true,
          reason: "waiting_for_approval",
          data: {
            action: args.action,
            requestId: generateId(),
          },
        };
      },
    },
  },
  callbacks: {
    onSuspend: async (sessionId, info) => {
      console.log(`Session ${sessionId} suspended: ${info.reason}`);
      // Save suspension state to database
      await db.saveSuspendedSession(sessionId, info);
    },
  },
});

// Session stopped with completionReason: "suspended"
console.log(result.completionReason); // "suspended"
console.log(result.suspendInfo); // { reason: "waiting_for_approval", data: {...}, turn: 1 }

Resuming a Suspended Session

To resume, simply call runAgentSession again with the saved messages plus the response:

// Later, when approval arrives...
const suspendedSession = await db.loadSuspendedSession(sessionId);

const result = await runAgentSession(modelConfig, {
  sessionId: sessionId,  // Same session ID
  systemPrompt: "You are a helpful assistant that needs approval.",
  messages: [
    ...suspendedSession.messages,  // All previous messages
    {
      role: "user",
      content: "Approval granted! You may proceed. Call task_complete when done.",
    },
  ],
  tools: {}, // Same tools or empty if no longer needed
});

// Agent continues from where it left off
console.log(resumedResult.completionReason); // "task_complete"

Persistence Across Restarts

The suspension state is just data - it survives server restarts:

// Before restart - save everything
const result = await runAgentSession(modelConfig, config);
if (result.completionReason === "suspended") {
  await fs.writeFile(`sessions/${result.sessionId}.json`, JSON.stringify({
    sessionId: result.sessionId,
    messages: result.messages,
    suspendInfo: result.suspendInfo,
  }));
}

// --- SERVER RESTART ---

// After restart - load and resume
const saved = JSON.parse(await fs.readFile(`sessions/${sessionId}.json`));
const resumedResult = await runAgentSession(modelConfig, {
  sessionId: saved.sessionId,
  messages: [
    ...saved.messages,
    { role: "user", content: "External data has arrived: {...}" },
  ],
  // ... rest of config
});

Use Cases

  • Human approval workflows - Agent requests permission, waits for response
  • Async API calls - Wait for webhook callbacks or long-running operations
  • Multi-agent coordination - Agent asks another agent a question, blocks until answered
  • Rate limiting - Suspend when rate limited, resume when quota refreshes
  • Scheduled operations - Suspend until a specific time

Multiple Suspensions

Sessions can suspend and resume multiple times:

let result = await runAgentSession(modelConfig, config);

// First suspension
assert.equal(result.completionReason, "suspended");
result = await runAgentSession(modelConfig, {
  messages: [...result.messages, { role: "user", content: "Step 1 done" }],
  // ...
});

// Second suspension
assert.equal(result.completionReason, "suspended");
result = await runAgentSession(modelConfig, {
  messages: [...result.messages, { role: "user", content: "Step 2 done" }],
  // ...
});

// Final completion
assert.equal(result.completionReason, "task_complete");

See examples/suspension.ts for complete working examples.

Error Handling

  • LLM errors - Retry with error in conversation
  • Tool parsing errors - Report to agent for correction
  • Tool execution errors - Caught and logged
  • Timeouts - Configurable for LLM and tools
const result = await runAgentSession(modelConfig, {
  llmTimeout: 60000,  // 60 seconds
  toolTimeout: 30000, // 30 seconds
  callbacks: {
    onError: async (sessionId, info) => {
      console.error(`[${sessionId}] Error in ${info.phase}:`, info.error);
      await monitoring.logError(sessionId, info);
    },
  },
});

Idle Detection

If agent produces text without calling tools for 2 turns, a reminder is automatically sent:

REMINDER: If you have completed your task, you must call the task_complete 
tool with a summary. If you are not done yet, please continue working.

Architecture

Functional design - Pure functions, no classes Event-driven - Callbacks for all state changes Provider-agnostic - Works with any AI SDK provider Minimal dependencies - No MCP, no file I/O, no frameworks Separation of concerns - Library handles loop, caller handles tools/persistence

License

MIT

About

A lightweight, functional library for running agentic loops with LLMs, tool calling, and summarization support.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors