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 callsenableAgenticLoop: false: Single-pass mode - LLM → execute tools → done (doesn't call LLM again)
Test Mode:
testMode: false(default): Normal production mode - all DB operations executetestMode: 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/typeparams: Tool parametersdata: (Optional) Will be populated with the tool result after execution
Return the tool result which will be:
- Sent back to the LLM for the next conversation turn (if loop mode enabled)
- Automatically stored in
toolCall.databy 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¶
- 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,
});
}
}
- 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:
- LLM calls still execute: The agent makes real LLM API calls to test prompt/response handling
- DB operations are skipped: All
StoryVideosModelwrites (updates, logs, etc.) are bypassed - Local state is preserved: In-memory state (
this.storyData,this.sceneData, etc.) is still updated so the agentic loop can continue correctly - 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
StoryVideosModelcalls withif (!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¶
- Keep tools focused: Each tool should do one thing well
- Return meaningful results: Tool results are sent back to the LLM, make them descriptive
- Handle errors in tools: Catch errors in
executeTool()and return error messages - Use structured prompts: Clearly define available tools and their parameters in system prompt
- Save history externally: The base class manages in-memory history during the run, but you should persist to DB in your callback
- Add tool types to enum: Define your tool types in the
GuruToolTypesenum inagenticGuru.types.ts