flare-agent Bài 5: Core — Agent Loop và Tool Registry

Xây dựng @flare-agent/core — trái tim của framework với Agent class, AgentLoop xử lý tool calling, và ToolRegistry quản lý tools type-safe.

5 phút đọc
Tập này đang được chuẩn bị, quay lại sau nhé.

flare-agent Bài 5: Core — Agent Loop và Tool Registry

Series: Build Your Own AI Agent Framework trên Cloudflare Bài: 5 / 7 — @flare-agent/core


Đây là trái tim của framework

@flare-agent/core là nơi mọi thứ được kết nối lại:

  • LLMRouter từ @flare-agent/providers
  • MemoryManager từ @flare-agent/memory
  • ToolRegistry quản lý tools
  • AgentLoop chạy vòng lặp tool calling
  • Agent class — public API mà developer dùng

Cấu trúc package

packages/core/
  src/
    tool.ts          # tool() helper, ToolRegistry
    AgentLoop.ts     # vòng lặp chính
    Agent.ts         # public API
    stream.ts        # SSE helpers
    durable.ts       # Durable Object wrapper
    index.ts

Tool Registry

// src/tool.ts
import type { ToolDefinition, ToolSchema, AgentContext } from '@flare-agent/types';

// Helper tạo tool với type inference
export function tool<TArgs, TResult>(
  definition: ToolDefinition<TArgs, TResult>
): ToolDefinition<TArgs, TResult> {
  return definition;
}

export class ToolRegistry {
  private tools = new Map<string, ToolDefinition>();

  register(definition: ToolDefinition): this {
    this.tools.set(definition.schema.name, definition);
    return this;
  }

  registerMany(definitions: ToolDefinition[]): this {
    definitions.forEach((d) => this.register(d));
    return this;
  }

  get(name: string): ToolDefinition | undefined {
    return this.tools.get(name);
  }

  getSchemas(): ToolSchema[] {
    return [...this.tools.values()].map((t) => t.schema);
  }

  async execute(
    name: string,
    args: Record<string, unknown>,
    ctx: AgentContext
  ): Promise<unknown> {
    const toolDef = this.tools.get(name);
    if (!toolDef) throw new Error(`Tool "${name}" not found in registry`);
    return toolDef.execute(args, ctx);
  }
}

Agent Loop

Đây là vòng lặp cốt lõi — while loop chạy đến khi LLM trả về text response hoặc hết max iterations:

// src/AgentLoop.ts
import type {
  Message, AgentContext, AgentResult, LLMProvider
} from '@flare-agent/types';
import type { MemoryManager } from '@flare-agent/memory';
import type { ToolRegistry } from './tool';

interface LoopConfig {
  systemPrompt: string;
  provider: LLMProvider;
  memory: MemoryManager;
  tools: ToolRegistry;
  maxIterations: number;
}

export class AgentLoop {
  constructor(private config: LoopConfig) {}

  async run(
    userMessage: string,
    ctx: AgentContext
  ): Promise<AgentResult> {
    const { provider, memory, tools, maxIterations } = this.config;
    const toolCallLog: AgentResult['toolCalls'] = [];

    // Thêm user message vào memory
    await memory.addMessage(ctx.sessionId, {
      role: 'user',
      content: userMessage,
    });

    const systemMessage: Message = {
      role: 'system',
      content: this.config.systemPrompt,
    };

    for (let i = 0; i < maxIterations; i++) {
      const history = await memory.getMessages(ctx.sessionId);
      const messages: Message[] = [systemMessage, ...history];
      const toolSchemas = tools.getSchemas();

      const response = await provider.chat(messages, toolSchemas);

      // --- Tool call branch ---
      if (response.type === 'tool_call' && response.toolCall) {
        const { id, name, args } = response.toolCall;

        let result: unknown;
        let isError = false;

        try {
          result = await tools.execute(name, args, ctx);
        } catch (err) {
          result = { error: err instanceof Error ? err.message : String(err) };
          isError = true;
        }

        toolCallLog.push({ name, args, result });

        // Thêm tool result vào memory để LLM tiếp tục
        await memory.addMessage(ctx.sessionId, {
          role: 'tool',
          content: JSON.stringify(result),
          toolCallId: id,
        });

        // Nếu tool bị lỗi, dừng loop sớm
        if (isError) break;

        continue; // tiếp tục vòng lặp
      }

      // --- Final text response ---
      const finalContent = response.content ?? '';
      await memory.addMessage(ctx.sessionId, {
        role: 'assistant',
        content: finalContent,
      });

      return {
        output: finalContent,
        iterations: i + 1,
        toolCalls: toolCallLog,
      };
    }

    throw new Error(
      `Agent exceeded max iterations (${maxIterations}). ` +
      `Tool calls made: ${toolCallLog.map((t) => t.name).join(', ')}`
    );
  }
}

Agent Class — Public API

// src/Agent.ts
import type { AgentConfig, AgentContext, AgentResult } from '@flare-agent/types';
import { LLMRouter } from '@flare-agent/providers';
import { MemoryManager, KVMemoryAdapter, D1MemoryAdapter } from '@flare-agent/memory';
import { ToolRegistry, tool } from './tool';
import { AgentLoop } from './AgentLoop';

export class Agent {
  private registry = new ToolRegistry();

  constructor(private config: AgentConfig) {}

  // Fluent API — chain tools
  use(...tools: Parameters<ToolRegistry['register']>[0][]): this {
    this.registry.registerMany(tools);
    return this;
  }

  private buildProvider(env: Record<string, unknown>) {
    const { provider, model } = this.config.model;
    const router = new LLMRouter();

    if (provider === 'groq') {
      router.register('groq', {
        apiKey: env.GROQ_API_KEY as string,
        model,
      });
    } else if (provider === 'workersai') {
      router.register('workersai', { ai: env.AI as Ai, model });
    } else if (provider === 'ollama') {
      router.register('ollama', {
        baseUrl: env.OLLAMA_BASE_URL as string,
        model,
      });
    }

    return router.get(provider);
  }

  private buildMemory(env: Record<string, unknown>): MemoryManager {
    const type = this.config.memory ?? 'kv';
    return new MemoryManager({
      shortTerm: type !== 'none'
        ? new KVMemoryAdapter(env.KV as KVNamespace)
        : undefined,
      longTerm: type === 'd1'
        ? new D1MemoryAdapter(env.DB as D1Database)
        : undefined,
    });
  }

  private getSystemPrompt(ctx: AgentContext): string {
    const { systemPrompt } = this.config;
    if (!systemPrompt) return 'You are a helpful assistant.';
    if (typeof systemPrompt === 'function') return systemPrompt(ctx);
    return systemPrompt;
  }

  // Run agent — main entry point
  async run(input: string, ctx: AgentContext): Promise<AgentResult> {
    const provider = this.buildProvider(ctx.env);
    const memory = this.buildMemory(ctx.env);

    const loop = new AgentLoop({
      systemPrompt: this.getSystemPrompt(ctx),
      provider,
      memory,
      tools: this.registry,
      maxIterations: this.config.maxIterations ?? 10,
    });

    return loop.run(input, ctx);
  }

  // Convert sang Durable Object
  toDurableObject() {
    const agent = this;
    return class extends DurableObject {
      async fetch(request: Request): Promise<Response> {
        const body = await request.json<{
          input: string;
          sessionId: string;
          userId?: string;
        }>();

        const ctx: AgentContext = {
          sessionId: body.sessionId,
          userId: body.userId,
          env: this.env as any,
        };

        const result = await agent.run(body.input, ctx);
        return Response.json(result);
      }
    };
  }
}

SSE Stream Helper

// src/stream.ts
export function createSSEResponse(
  stream: ReadableStream<string>
): Response {
  const encoder = new TextEncoder();

  const sseStream = stream.pipeThrough(
    new TransformStream<string, Uint8Array>({
      transform(chunk, controller) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ text: chunk })}\n\n`)
        );
      },
      flush(controller) {
        controller.enqueue(encoder.encode('data: [DONE]\n\n'));
      },
    })
  );

  return new Response(sseStream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

Dùng trong Worker

// apps/worker/src/index.ts
import { Hono } from 'hono';
import { Agent, tool } from '@flare-agent/core';

const vocabularyAgent = new Agent({
  name: 'vocabulary-tutor',
  model: { provider: 'groq', model: 'llama-3.3-70b-versatile' },
  memory: 'd1',
  systemPrompt: 'Bạn là trợ lý học từ vựng tiếng Anh. Hãy giúp người dùng học từ vựng hiệu quả.',
})
.use(
  tool({
    schema: {
      name: 'get_word',
      description: 'Lấy thông tin chi tiết về một từ vựng',
      parameters: {
        type: 'object',
        properties: { word: { type: 'string' } },
        required: ['word'],
      },
    },
    execute: async ({ word }, ctx) => {
      const db = ctx.env.DB as D1Database;
      return db.prepare('SELECT * FROM vocabulary WHERE word = ?')
        .bind(word).first();
    },
  })
);

export const AgentDO = vocabularyAgent.toDurableObject();

const app = new Hono();
app.post('/chat', async (c) => {
  const { input, sessionId, userId } = await c.req.json();
  const result = await vocabularyAgent.run(input, {
    sessionId,
    userId,
    env: c.env as any,
  });
  return c.json(result);
});

export default app;

Checklist

  • Tạo packages/core/
  • Implement ToolRegistry, AgentLoop, Agent
  • Test agent loop với mock provider
  • Verify tool calling flow end-to-end

Bài tiếp theo: Bài 6 — Workflow Engine: Graph-based Multi-step