Skip to content

monocursive/tinyagent

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Demystifying Agents

Agents are everywhere when we are talking about AI. They are sold as the missing link between an LLM and our systems. In this article, we'll explore the concept of AI agents and how they can be implemented in TypeScript.

Tools, it's all about tools

Let's start with LLMs. At their core, LLMs are large language models that can generate text based on a given prompt. They are some sofisticated heuristic algorithms that given an input, a little bit of artificial entropy, and a set of rules, can generate text that is coherent and relevant to the prompt.

Text in, text out.

At most they can give me written instructions, and if I execute them, then the LLM interacted with the world. That seems exhausting.

Couldn't we find a way for the LLM to execute those instructions for us?

Nope, text in, text out. Only text.

What if we wrote a program that could execute those instructions for us?

Here we go. They send text and our tools are the ones that execute them.

Let's implement a simple agent system using TypeScript.

Prerequisites

We will use Typescript and Bun for simplicity.

Install bun if not already installed:

curl https://bun.sh/install | bash

Create a new directory for your project:

mkdir tinyagent
cd tinyagent

and init a bun project:

bun init

a bunch of files will be created, let's do it the old way, no dependencies.

Test that everything is set up correctly:

bun run index.ts

Let's dive in

Using your favorite text editor (which should be zed) open index.ts and remove the console log.

replace it by:

// Types for our tool system
type Tool = {
	name: string;
	description: string;
	parameters: {
		type: string;
		properties: Record<string, any>;
		required: string[];
	};
	execute: (args: any) => Promise<any>;
};

type Message = {
	role: "system" | "user" | "assistant" | "tool";
	content: string;
	toolCalls?: ToolCall[];
	toolCallId?: string;
	name?: string;
};

type ToolCall = {
	id: string;
	name: string;
	arguments: string;
};

Those types are the building blocks of our agent system. They define the structure of our tools and messages.

Tool is just a function with associated metadata. The execute block will be used to call any function you want.

Message is a simple object that represents a message sent by the user or the LLM. It can be a system message, a user message, a LLM message, or a tool message.

ToolCall is the bridge between the LLM and our functions (tools)

Our first tool

Let's start simple. We want our agent to be able to do some simple calculations.

const tools: Tool[] = [
	{
		name: "calculate",
		description: "Perform basic math calculations",
		parameters: {
			type: "object",
			properties: {
				operation: {
					type: "string",
					enum: ["add", "subtract", "multiply", "divide"],
				},
				a: { type: "number" },
				b: { type: "number" },
			},
			required: ["operation", "a", "b"],
		},
		execute: async (args) => {
			const ops: Record<string, (a: number, b: number) => number> = {
				add: (a, b) => a + b,
				subtract: (a, b) => a - b,
				multiply: (a, b) => a * b,
				divide: (a, b) => a / b,
			};
			const operation = ops[args.operation];
			if (!operation) {
				throw new Error(`Unknown operation: ${args.operation}`);
			}
			return { result: operation(args.a, args.b) };
		},
	}
];

Nothing too fancy, a bunch of metadatas, which are completely arbitrary. You could define other metadatas. We just agree with ourselves that our tools will respect this shape.

Parameters are respecting a JSON schema definition. LLMs love structure, and we do too.

The execute block is where the magic happens. It's where the code is executed. The LLM will infer the parameters from the JSON schema definition and pass them as arguments to the execute function.

Will the LLM execute the functions by itself now?

Well. No. Text in, text out.

But, hey, that's cute but we don't care about a calculator that much.

A real tool

const tools: Tool[] = [
  {
    //calculator tool we defined above

  },
	{
		name: "get_github_issues",
		description: "Get open issues from a GitHub repository",
		parameters: {
			type: "object",
			properties: {
				repository: {
					type: "string",
					description: 'Repository in format "owner/repo" (e.g., "facebook/react")',
				},
				limit: {
					type: "number",
					description: "Number of issues to return (default: 10, max: 100)",
				},
			},
			required: ["repository"],
		},
		execute: async (args) => {
			// Validate repository format
			if (!args.repository.includes("/")) {
				throw new Error(
					'Repository must be in format "owner/repo" (e.g., "facebook/react")',
				);
			}

			const limit = Math.min(args.limit || 10, 100);
			const url = `https://api.github.com/repos/${args.repository}/issues?state=open&per_page=${limit}`;

			try {
				const response = await fetch(url, {
					headers: {
						Accept: "application/vnd.github+json",
						"User-Agent": "tinyagent",
					},
				});

				if (!response.ok) {
					if (response.status === 404) {
						throw new Error(`Repository "${args.repository}" not found`);
					}
					if (response.status === 403) {
						throw new Error("GitHub API rate limit exceeded");
					}
					throw new Error(`GitHub API error: ${response.statusText}`);
				}

				const issues = (await response.json()) as any[];

				// Format the response
				return {
					repository: args.repository,
					count: issues.length,
					issues: issues.map((issue: any) => ({
						number: issue.number,
						title: issue.title,
						state: issue.state,
						url: issue.html_url,
						body: issue.body
							? issue.body.substring(0, 200) +
								(issue.body.length > 200 ? "..." : "")
							: null,
						created_at: issue.created_at,
						author: issue.user.login,
						labels: issue.labels.map((l: any) => l.name),
						assignees: issue.assignees.map((a: any) => a.login),
						milestone: issue.milestone?.title || null,
						comments: issue.comments,
					})),
				};
			} catch (error) {
				if (error instanceof Error) {
					throw error;
				}
				throw new Error("Failed to fetch GitHub issues");
			}
		},
	},
];

I will not spend too much time on this block, but it defines a tool called "get_github_issues" that fetches open issues from a specified GitHub repository using the GitHub REST API. It accepts a repository in "owner/repo" format and an optional limit parameter (defaulting to 10, capped at 100), then makes an API request with proper error handling for common cases like rate limiting and missing repositories. The function returns a formatted response containing issue metadata including number, title, author, labels, assignees, and a truncated body preview (200 characters max) for each issue.

You could modify it to include a github token and request data on your private repos.

But let's keep it simple.

Your tool could do absolutely anything possible in your app. Database calls, writing or reading files, executing shell commands, ordering pizzas using food delivery services APIs. The sky (and runtime) is the limit. A common pattern is to use tools to orchestrate smaller LLMS for more specialized tasks.

The agent

class AIAgent {
	private apiKey: string;
	private tools: Tool[];
	private messages: Message[];

	constructor(apiKey: string, tools: Tool[]) {
		this.apiKey = apiKey;
		this.tools = tools;
		this.messages = [
			{
				role: "system",
				content:
					'You are a helpful assistant with access to tools. When you need to use a tool, respond with a JSON object in this format: {"tool": "tool_name", "arguments": {...}}',
			},
		];
	}
}

Here's our agent class. This will orchestrate the LLM's calls and our tools. We initialize it with our array of tools, defined above, and an OpenAi api key. You could use any LLM api, even local ones with Ollama for example, but you would need to implement the logic to handle the API calls and responses differently.

this.messages is an array of messages that will be sent to the LLM. It starts with a system message that defines the role of the agent and the format of the response. You can consider it an append only log. Each time the agent is called we will append messages to this array, so it acts as a very naive and inneficient, but functionnal, history.

Have a look at the prompt: "When you need to use a tool, respond with a JSON object in this format: {"tool": "tool_name", "arguments": {...}" this instructs the LLM to format its response in json in a format we can uderstand when tools are needed.

Is it reliable? No, but 99% of the time it works. It's enough for Tinyagent but you would need to implement a more robust error handling and retry mechanism for production use.

Once you have your OpenAi Api key, don't hardcode it in your code. Instead, create a .env file in the root of your project and add the following line:

OPENAI_API_KEY=your_api_key_here

or run the project like this:

OPENAI_API_KEY=your_api_key_here bun run index.ts

Harcoding secret keys is usually how you go bankrupt or get fired from your job.

Let's talk to the LLMS

class AIAgent {
      private apiKey: string;
      private tools: Tool[];
      private messages: Message[];

      constructor(apiKey: string, tools: Tool[]) {
          this.apiKey = apiKey;
          this.tools = tools;
          this.messages = [
              {
                  role: "system",
                  content:
                      'You are a helpful assistant with access to tools. When you need to use a tool, respond with a JSON
   object in this format: {"tool": "tool_name", "arguments": {...}}',
              },
          ];
      }

      // Convert tools to format for API
      private getToolsSchema() {
          return this.tools.map((tool) => ({
              type: "function",
              function: {
                  name: tool.name,
                  description: tool.description,
                  parameters: tool.parameters,
              },
          }));
      }

      // Make API call to OpenAI
      private async callOpenAI(messages: Message[]): Promise<any> {
          const response = await fetch("https://api.openai.com/v1/chat/completions", {
              method: "POST",
              headers: {
                  "Authorization": `Bearer ${this.apiKey}`,
                  "content-type": "application/json",
              },
              body: JSON.stringify({
                  model: "gpt-4o-mini",
                  max_tokens: 1024,
                  tools: this.getToolsSchema(),
                  messages: messages.map((m) => ({
                      role: m.role,
                      content: m.content,
                      ...(m.toolCalls && { tool_calls: m.toolCalls }),
                      ...(m.toolCallId && { tool_call_id: m.toolCallId }),
                  })),
              }),
          });

          if (!response.ok) {
              throw new Error(`API error: ${response.statusText}`);
          }

          return await response.json();
      }
  }

getToolsSchema() extract the tool name and description for the LLM to be able to refer to them and understand their purpose. Again, text in, text out. The LLM will decide what tools it needs and when to ask for them. It doesn't need the actual function implementation.

callOpenAI() make API call to OpenAI. content is a an array that contains the message to be sent to the LLM, where we append the reorganized messages array we defined at the begining so the LLM continues the conversation.

The Agent Loop - Where the Magic Happens

class AIAgent {
	private apiKey: string;

  //rest of the code written above
  // ...

	private async executeTool(toolName: string, args: any): Promise<any> {
		const tool = this.tools.find((t) => t.name === toolName);
		if (!tool) {
			throw new Error(`Tool ${toolName} not found`);
		}
		return await tool.execute(args);
	}

	// Main agent loop
	async run(userMessage: string): Promise<string> {
		// Add user message
		this.messages.push({
			role: "user",
			content: userMessage,
		});

		let finalResponse = "";
		let iterations = 0;
		const maxIterations = 5;

		while (iterations < maxIterations) {
			iterations++;
			console.log(`\n--- Iteration ${iterations} ---`);

			// Call OpenAI API
			const response = await this.callOpenAI(this.messages);
			console.log("Response:", JSON.stringify(response, null, 2));

			const message = response.choices[0]?.message;

			// Check if OpenAI wants to use a tool
			if (message?.tool_calls && message.tool_calls.length > 0) {
				const toolCall = message.tool_calls[0];
				console.log(`\nCalling tool: ${toolCall.function.name}`);
				console.log("Arguments:", toolCall.function.arguments);

				// Execute the tool
				const args = JSON.parse(toolCall.function.arguments);
				const toolResult = await this.executeTool(toolCall.function.name, args);
				console.log("Tool result:", JSON.stringify(toolResult, null, 2));

				// Add assistant message with tool call
				this.messages.push({
					role: "assistant",
					content: message.content || "",
					toolCalls: message.tool_calls,
				});

				// Add tool result
				this.messages.push({
					role: "tool",
					content: JSON.stringify(toolResult),
					toolCallId: toolCall.id,
				});
			} else {
				// No tool needed, return final response
				finalResponse = message?.content || "";
				this.messages.push({
					role: "assistant",
					content: finalResponse,
				});
				break;
			}
		}

		return finalResponse;
	}
}

in run() We implement an agentic loop that processes a user message through OpenAI's API, allowing the AI to iteratively call tools (functions) up to 5 times to gather information before providing a final response. When OpenAI requests a tool call, the code executes that tool, appends both the assistant's tool request and the tool's result to the conversation history, then calls OpenAI again with the updated context. The loop continues until OpenAI provides a final text response without requesting any tools, at which point it returns that response to the user. We limit the number of tool calls to 5 to prevent infinite loops.

executeTool() searches through our available tools and finds a tool matching the toolName. If the tool is found it executes the tool's execute method with the provided args. If the tool is not found, it throws an error.

A very naive dispatcher, but it gets the job done.

Putting It All Together

Here is an example of how our agent will act when asked for information about a GitHub repository:

User: "What are the open issues in monocursive/tinyagent? Show me 5 issues."

Iteration 1:

→ LLM decides to use get_github_issues tool

→ Tool executes, returns JSON data

→ Results added to conversation

Iteration 2:

→ LLM sees tool results

→ Formats them into a human-readable response

→ Returns final answer

Ready for the code?

async function main() {
	const agent = new AIAgent(apiKey, tools);

	const queries = [
		"What are the open issues in zed-industries/zed?",
		"What are the open issues in monocursive/tinyagent?",
	];

	for (const query of queries) {
		const response = await agent.run(query);
		console.log(`Final Response: ${response}`);
	}
}

Yep. That's it. We instantiate the agent with the API key and tools, then call agent.run() with our queries.

How clean is this?

Go run it with

OPENAI_API_KEY=your_api_key_here bun run index.ts

if you created a .env file with the OpenAI API key, you can just run

bun run index.ts

What now?

I hope you enjoyed this article, and that the agents are now a tangible concept for you. There is a lot to improve, but this is a good start.

You could ask the LLM to rank the severity of the issues.

You could try to create a terminal UI for the agent.

You could use SQLite to store the conversation history.

You could create a web UI for it.

Everything is possible, given enough time and coffee.

If you have any questions or feedback, feel free to reach out on social medias.

About

A toy AI agent for educational purpose. Linked blog article:

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published