Skip to content

Agentic Guru Framework

A reusable base class framework for creating agentic conversational AI systems that can make tool calls and execute actions through iterative LLM conversations.

Overview

The Agentic Guru framework provides a base class that handles the complex orchestration of:

  • Running conversational loops with OpenAI LLM
  • Parsing structured responses
  • Detecting and executing tool calls
  • Managing chat history
  • Emitting responses after each conversation turn

Key Features

  • Event-based callback architecture: Fires events for iteration start, LLM responses, and tool completions for real-time updates
  • Configurable execution modes: Single-pass mode (execute once) or loop mode (repeat until no tool calls)
  • Context injection: Optional context that child classes can provide to include in every LLM call
  • Structured JSON output: Uses OpenAI's Chat Completions API with JSON mode for reliable parsing
  • Chat history management: Automatic trimming based on configurable window size
  • Extensible: Child classes only implement domain-specific tool execution logic

Architecture

┌─────────────────────────────────────────────────────────────┐
│                    AgenticGuru Base Class                    │
│                                                               │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ runAgent(userMessage, onEvent)                       │    │
│  │   │                                                   │    │
│  │   ├──> Fire ITERATION_STARTED event ────────────────┼───>│ Caller receives event
│  │   │                                                   │    │
│  │   ├──> Call LLM with system prompt + chat history    │    │
│  │   │                                                   │    │
│  │   ├──> Parse response { textResponse, toolCalls[] }   │    │
│  │   │                                                   │    │
│  │   ├──> Fire LLM_RESPONSE event ──────────────────────┼───>│ Caller receives response
│  │   │                                                   │    │ (emit to socket, save to DB)
│  │   ├──> If toolCalls exist:                           │    │
│  │   │       ├──> executeTool() for each [child impl]   │    │
│  │   │       ├──> Fire TOOL_COMPLETE event ─────────────┼───>│ For each tool execution
│  │   │       ├──> Store results in toolCall.data        │    │
│  │   │       ├──> Format tool results                   │    │
│  │   │       └──> Loop back to LLM                      │    │
│  │   │                                                   │    │
│  │   └──> If no toolCalls: Done                         │    │
│  └─────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘

Usage

1. Create a Child Class

Extend AgenticGuru and implement the executeTool() method:

import AgenticGuru from './agenticGuru.base';
import { AgenticGuruConfig, GuruToolCall } from './agenticGuru.types';

class MyGuru extends AgenticGuru {
  constructor(
    config: AgenticGuruConfig,
    // Add any domain-specific parameters your child class needs
    private promptCacheKey: string
  ) {
    super(config); // Only config is required by base class
  }

  // Optional: Override to provide context for LLM calls
  protected async getContext(): Promise<string> {
    // Return empty string if no context needed (default behavior)
    // Or return context specific to your guru
    const project = await ProjectModel.findById(this.promptCacheKey);
    return project ? `Project: ${project.name}\nStatus: ${project.status}` : '';
  }

  protected async executeTool(toolCall: GuruToolCall): Promise<any> {
    switch (toolCall.type) {
      case GuruToolTypes.CREATE_CHARACTER:
        return await this.createCharacter(toolCall.params);

      case GuruToolTypes.GENERATE_STORY:
        return await this.generateStory(toolCall.params);

      default:
        console.warn(`Unknown tool: ${toolCall.type}`);
        return {};
    }
  }

  private async createCharacter(params: Record<string, any>) {
    // Your domain-specific logic here
    const character = await CharacterModel.create({
      name: params.name,
      description: params.description,
    });
    return { character };
  }

  private async generateStory(params: Record<string, any>) {
    // Your domain-specific logic here
    return { story: params };
  }
}

2. Use the Guru

// Initialize (some params can be child class specific, else only base class params need to be provided)
const guru = new MyGuru(
  {
    systemPrompt: 'You are a helpful assistant that creates characters...',
    MAX_CHAT_WINDOW: 20,
    modelId: OpenAIModelId.GPT_4_1,
    temperature: 0.7,
  },
  'my_guru_cache_key' // Your domain-specific parameter
);

// Optionally load existing chat history
const existingHistory = await getHistoryFromDB(sessionId);
guru.loadHistory(existingHistory);

// Run the agent
await guru.runAgent(userMessage, async (event) => {
  // This callback fires for different event types during agent execution
  // Caller can implement specific logic based on event type

  switch (event.type) {
    case GuruCallbackEventType.ITERATION_STARTED:
      console.log(`Starting iteration ${event.data.iterationNumber}`);
      socketService.emit(socketId, 'guru:iteration', event.data);
      break;

    case GuruCallbackEventType.LLM_RESPONSE:
      // Emit to socket for real-time updates
      socketService.emit(socketId, 'guru:response', {
        textResponse: event.data.textResponse,
        toolCalls: event.data.toolCalls,
      });

      // Save to database
      await saveMessageToDB(sessionId, {
        role: 'assistant',
        content: event.data.textResponse,
        toolCalls: event.data.toolCalls,
        rawResponse: event.data.rawResponse,
      });
      break;

    case GuruCallbackEventType.TOOL_COMPLETE:
      console.log(`Tool ${event.data.toolName} completed`);
      socketService.emit(socketId, 'guru:tool', event.data);
      break;
  }
});

// Agent is complete when runAgent() finishes
socketService.emit(socketId, 'guru:complete');

System Prompt Format

Your system prompt should instruct the LLM to return responses in JSON format. Both formats are supported:

Array Format (Preferred - supports multiple tools):

{
  "textResponse": "The text response to show the user",
  "toolCalls": [
    {
      "type": "tool_name",
      "params": {
        "param1": "value1",
        "param2": "value2"
      }
    }
  ]
}

Single Tool Format (Alternative):

{
  "textResponse": "The text response to show the user",
  "toolCall": "tool_name or null",
  "toolParams": {
    "param1": "value1",
    "param2": "value2"
  }
}

Example system prompt snippet:

You are a helpful assistant. Always respond in JSON format:
{
  "textResponse": "Your message to the user",
  "toolCalls": []
}

Available tools:
- create_character: Creates a new character. Params: { name, description }
- generate_story: Generates a story. Params: { title, characters, plot }

When you need to use tools, populate toolCalls array with objects containing:
{
  "type": "tool_name",
  "params": { ... }
}

When just chatting, leave toolCalls as an empty array.

Configuration Options

interface AgenticGuruConfig {
  systemPrompt: string; // Required: System prompt for the LLM
  MAX_CHAT_WINDOW?: number; // Optional: Max messages in history (default: 50)
  MAX_ITERATIONS?: number; // Optional: Max agent loop iterations (default: 10)
  modelId?: OpenAIModelId; // Optional: OpenAI model (default: GPT_4_1)
  temperature?: number; // Optional: LLM temperature (default: undefined)
  enableAgenticLoop?: boolean; // Optional: If false, runs single pass (default: true)
  testMode?: boolean; // Optional: Skip DB operations while preserving local state (default: false)
}

Execution Modes:

  • enableAgenticLoop: true (default): Runs full agentic loop - LLM → execute tools → LLM again → repeat until no tool calls
  • enableAgenticLoop: false: Single-pass mode - LLM → execute tools → done (doesn't call LLM again)

Test Mode:

  • testMode: false (default): Normal production mode - all DB operations execute
  • testMode: true: Testing mode - DB writes are skipped, but local state is still updated

Public Methods

runAgent(userMessage: string, onEvent: AgenticGuruEventCallback): Promise<void>

Main method that runs the agentic loop. Fires events for iteration starts, LLM responses, and tool completions.

loadHistory(messages: GuruChatMessage[]): void

Load existing chat history from database or previous session.

getHistory(): GuruChatMessage[]

Get current chat history.

clearHistory(): void

Clear all chat history.

isTestMode(): boolean

Check if the guru is running in test mode. Useful for conditional logic in callbacks or external code.

Methods to Implement/Override

executeTool(toolCall: GuruToolCall): Promise<any> (Required)

Execute a tool call. The toolCall parameter contains:

  • type: The tool name/type
  • params: Tool parameters
  • data: (Optional) Will be populated with the tool result after execution

Return the tool result which will be:

  1. Sent back to the LLM for the next conversation turn (if loop mode enabled)
  2. Automatically stored in toolCall.data by the base class

getContext(): Promise<string> (Optional)

Override to provide context that gets injected in every LLM call. Context is added as a user message with [CONTEXT] prefix before chat history.

Default implementation returns empty string (no context).

Event Format

The callback receives a GuruCallbackEvent object with different event types:

interface GuruCallbackEvent {
  type: GuruCallbackEventType;
  data: AgenticGuruResponse | GuruToolResult | IterationStartResponse;
}

enum GuruCallbackEventType {
  ITERATION_STARTED = 'ITERATION_STARTED', // Fired at start of each iteration
  LLM_RESPONSE = 'LLM_RESPONSE', // Fired after LLM generates response
  TOOL_COMPLETE = 'TOOL_COMPLETE', // Fired after each tool execution
}

// Event data types:
interface IterationStartResponse {
  iterationNumber: number;
}

interface AgenticGuruResponse {
  textResponse: string;
  toolCalls: GuruToolCall[];
  rawResponse?: string;
}

interface GuruToolResult {
  toolName: GuruToolTypes;
  result: any;
}

Flow Example

User: "Create a character named Alice"
  ↓
Event: ITERATION_STARTED { iterationNumber: 1 }
  ↓
LLM Turn 1: {
  textResponse: "I'll create Alice",
  toolCalls: [{ type: "create_character", params: { name: "Alice" } }]
}
  ↓
Event: LLM_RESPONSE { textResponse, toolCalls }
  ↓
Execute Tool: createCharacter({ name: "Alice" })
  ↓
Tool Result: { character: { id: "123", name: "Alice" } }
  ↓
Event: TOOL_COMPLETE { toolName: "create_character", result }
  ↓ (Result stored in toolCalls[0].data)
  ↓
Event: ITERATION_STARTED { iterationNumber: 2 }
  ↓
LLM Turn 2: { textResponse: "Alice has been created!", toolCalls: [] }
  ↓
Event: LLM_RESPONSE { textResponse, toolCalls: [] }
  ↓
Done (no tool calls)

Safety Features

  • Max iteration limit: Prevents infinite loops (default: 10, configurable via MAX_ITERATIONS)
  • History trimming: Automatically trims chat history based on MAX_CHAT_WINDOW (default: 50)
  • Error handling: Configurable JSON parsing behavior - can gracefully fallback (default) or throw errors
  • Type safety: Full TypeScript types for all interfaces
  • Multiple tool support: Correctly handles multiple tool calls of the same type in a single response

Existing Implementations

The framework is currently used in production by:

  • WorkbenchStoryGuru: Story-level orchestration (scene index navigation, scene generation, visual style management)
  • WorkbenchSceneGuru: Scene-level operations (shot planning, layout constraints, image generation, spatial validation)
  • ShotImageEditGuru: Shot-level image editing operations
  • MinimaticsGuru: Minimatics project orchestration

See these files for real-world examples of the framework in action.

I/O Layer (io/ folder)

The io/ folder contains utilities for handling guru chat communication over HTTP + JaduSpine (Centrifugo):

agenticGuru/
├── io/
│   ├── guruChat.controller.base.ts  # Abstract base controller for HTTP endpoints
│   ├── guruChat.publisher.ts        # JaduSpine publishing utility
│   └── guruChat.types.ts            # Shared interfaces

Architecture

Frontend                    JaduSpine/Centrifugo           Backend
   │                              │                           │
   │  POST /api/.../guru/message  │                           │
   │──────────────────────────────┼──────────────────────────>│
   │                              │                           │
   │  HTTP 200 (acknowledged)     │                           │
   │<─────────────────────────────┼───────────────────────────│
   │                              │                           │
   │                              │  publish(iterationStarted)│
   │                              │<──────────────────────────│
   │  WebSocket event             │                           │
   │<─────────────────────────────│                           │
   │                              │  publish(response)        │
   │                              │<──────────────────────────│
   │  WebSocket event             │                           │
   │<─────────────────────────────│                           │
   │                              │  publish(complete)        │
   │                              │<──────────────────────────│
   │  WebSocket event             │                           │
   │<─────────────────────────────│                           │

Usage

  1. Create a controller extending GuruChatControllerBase:
import GuruChatControllerBase from '../../agenticGuru/io/guruChat.controller.base';
import { GuruChatConfig, GuruChatCallbacks } from '../../agenticGuru/io/guruChat.types';

export default class MyGuruChatController extends GuruChatControllerBase {
  private static config: GuruChatConfig = {
    eventPrefix: 'myModule',           // Event names: myModuleGuruResponse, myModuleGuruComplete, etc.
    frontend: FrontendChannels.STUDIO, // JaduSpine channel
    guruType: AgenticGuruType.STORY,
  };

  public static async sendMessage(req: Request, res: Response): Promise<void> {
    const userId = req.auth.userID;
    const { storyId, userMessage } = req.body;

    await GuruChatControllerBase.handleMessage(req, res, userId, { storyId, userMessage }, MyGuruChatController.config, {
      initializeGuru: async () => {
        const guru = new MyGuru(...);
        return { guru, contextData: { storyId } };
      },

      onBeforeRunAgent: async (guru) => {
        // Configure guru, validate, pre-process
        guru.guruLLMRequestTimeout = 60;
      },

      enrichEventData: (eventData) => ({ ...eventData, storyId }),

      saveSession: async (guru, contextData) => {
        await MyService.saveChatSession(contextData.storyId, guru.getHistory());
      },

      getSessionId: (guru, contextData) => contextData.sessionId,
    });
  }
}
  1. Register routes:
const myGuruChatRouter = Router();
myGuruChatRouter.get('/session', MyGuruChatController.fetchSession);
myGuruChatRouter.post('/message', MyGuruChatController.sendMessage);

Key Features

  • Immediate HTTP acknowledgment: Returns 200 immediately, processes asynchronously
  • Non-blocking saves: Session save failures are logged but don't abort processing
  • Callback-based customization: Module-specific logic via GuruChatCallbacks
  • Consistent event format: All events include event, status, isSuccess, message, data, timestamp

Test Mode

The framework supports a testMode flag that allows running the agentic loop without persisting changes to the database. This is useful for:

  • Testing: Run integration tests without affecting production data
  • Debugging: Trace through the agentic flow without side effects
  • Dry runs: Preview what operations would occur without committing them

How It Works

When testMode: true is set in the config:

  1. LLM calls still execute: The agent makes real LLM API calls to test prompt/response handling
  2. DB operations are skipped: All StoryVideosModel writes (updates, logs, etc.) are bypassed
  3. Local state is preserved: In-memory state (this.storyData, this.sceneData, etc.) is still updated so the agentic loop can continue correctly
  4. Tool results are accurate: Tools return proper results reflecting what would have happened

Usage

const guru = new WorkbenchSceneGuru(
  {
    systemPrompt: '...',
    testMode: true, // Enable test mode
  },
  storyData,
  sceneData,
  userId
);

// Run agent - LLM calls happen, but no DB writes
await guru.runAgent(userMessage, onEvent);

// Check test mode status
if (guru.isTestMode()) {
  console.log('Running in test mode - no DB changes');
}

Implementing Test Mode in Child Classes

When implementing a new Guru child class, follow this pattern in your tool methods:

private async myTool(params: MyToolParams): Promise<any> {
  // 1. Execute business logic (LLM calls, computations, etc.)
  const result = await this.doSomething(params);

  // 2. Skip DB operations in test mode
  if (!this.testMode) {
    await StoryVideosModel.updateField(this.storyId, 'field', result);
    if (response.logId) {
      await StoryVideosModel.addLog(this.storyId, response.logId, LogEvent.MY_EVENT);
    }
  }

  // 3. Always update local state (needed for subsequent tool calls)
  this.localData.field = result;

  return result;
}

Key principles:

  • Wrap all StoryVideosModel calls with if (!this.testMode)
  • Always update local state (this.storyData, this.sceneData, etc.) regardless of test mode
  • For operations that should be completely skipped in test mode (e.g., expensive external API calls), return early with a mock result

Best Practices

  1. Keep tools focused: Each tool should do one thing well
  2. Return meaningful results: Tool results are sent back to the LLM, make them descriptive
  3. Handle errors in tools: Catch errors in executeTool() and return error messages
  4. Use structured prompts: Clearly define available tools and their parameters in system prompt
  5. Save history externally: The base class manages in-memory history during the run, but you should persist to DB in your callback
  6. Add tool types to enum: Define your tool types in the GuruToolTypes enum in agenticGuru.types.ts