Skip to content

Instantly share code, notes, and snippets.

@decagondev
Created January 21, 2026 17:52
Show Gist options
  • Select an option

  • Save decagondev/534a90784a1ae831571ddb8010177080 to your computer and use it in GitHub Desktop.

Select an option

Save decagondev/534a90784a1ae831571ddb8010177080 to your computer and use it in GitHub Desktop.

Office Hours: Building a PPM Image Drawing Agent

Objective: Add a custom AI agent that can create PPM images using natural language instructions.

This guide walks through adding a new Image Artist agent to the modular agent supervisor system. The agent will understand human-readable prompts like:

"Draw a PPM image of size 200 by 200, add a square at coordinate 10,10 with a size of 20 pixels in red color, then add 10 circles of varying sizes and colors randomly around the image, and write the file as image.ppm"


Table of Contents

  1. Overview
  2. Create the PPM Drawing Tools Module
  3. Register Tools with the ToolRegistry
  4. Create the Image Artist Agent
  5. Export the Agent from Implementations
  6. Add Agent Configuration to YAML
  7. Register the Agent in SupervisorGraph
  8. Test the Complete Implementation
  9. Complete File Reference

Step 0: Overview

What We're Building

We're adding:

  1. PPM Drawing Tools - A set of tools for pixel manipulation and PPM file output
  2. Image Artist Agent - An AI agent that interprets drawing instructions

Architecture Recap

app/
├── tools/
│   ├── registry.py          ← We'll register new tools here
│   └── ppm_tools.py          ← NEW: Our drawing tools
├── agents/
│   └── implementations/
│       └── image_artist.py   ← NEW: Our agent
└── config/
    └── agents.yaml           ← Add agent configuration

PPM P6 Format Basics

PPM (Portable Pixmap) P6 is a simple binary image format:

  • Header: P6\n{width} {height}\n255\n
  • Body: Raw RGB bytes (3 bytes per pixel, row by row)

Step 1: Create the PPM Drawing Tools Module

File: app/tools/ppm_tools.py

This module provides all the drawing primitives our agent needs.

1.1 Create the File Structure

Create a new file at app/tools/ppm_tools.py:

"""
PPM Image Drawing Tools.

This module provides tools for creating PPM P6 format images,
including primitive drawing operations and file output.
"""

from typing import Tuple
from langchain_core.tools import tool


# Global canvas state (shared between tools)
_canvas = {
    "width": 0,
    "height": 0,
    "pixels": None,  # Will be a bytearray
}


def _rgb_tuple(color: str) -> Tuple[int, int, int]:
    """
    Convert a color name or hex value to RGB tuple.
    
    Args:
        color: Color name ('red', 'blue', etc.) or hex ('#FF0000')
    
    Returns:
        Tuple of (r, g, b) values 0-255
    """
    color_map = {
        "red": (255, 0, 0),
        "green": (0, 255, 0),
        "blue": (0, 0, 255),
        "yellow": (255, 255, 0),
        "cyan": (0, 255, 255),
        "magenta": (255, 0, 255),
        "white": (255, 255, 255),
        "black": (0, 0, 0),
        "orange": (255, 165, 0),
        "purple": (128, 0, 128),
        "pink": (255, 192, 203),
        "gray": (128, 128, 128),
        "grey": (128, 128, 128),
    }
    
    color_lower = color.lower().strip()
    
    if color_lower in color_map:
        return color_map[color_lower]
    
    # Try hex format
    if color_lower.startswith("#") and len(color_lower) == 7:
        try:
            r = int(color_lower[1:3], 16)
            g = int(color_lower[3:5], 16)
            b = int(color_lower[5:7], 16)
            return (r, g, b)
        except ValueError:
            pass
    
    # Default to white if unknown
    return (255, 255, 255)

1.2 Add the init_canvas Tool

Add this tool to initialize the pixel buffer:

@tool
def init_canvas(width: int, height: int, background_color: str = "black") -> str:
    """
    Initialize a new canvas with the given dimensions and background color.
    
    This must be called before any drawing operations.
    
    Args:
        width: Canvas width in pixels (e.g., 200)
        height: Canvas height in pixels (e.g., 200)
        background_color: Background color name or hex value (default: 'black')
    
    Returns:
        Confirmation message with canvas dimensions.
    """
    _canvas["width"] = width
    _canvas["height"] = height
    
    # Initialize pixel buffer (3 bytes per pixel: RGB)
    total_pixels = width * height
    _canvas["pixels"] = bytearray(total_pixels * 3)
    
    # Fill with background color
    r, g, b = _rgb_tuple(background_color)
    for i in range(total_pixels):
        _canvas["pixels"][i * 3] = r
        _canvas["pixels"][i * 3 + 1] = g
        _canvas["pixels"][i * 3 + 2] = b
    
    return f"Canvas initialized: {width}x{height} pixels with {background_color} background."

1.3 Add the put_pixel Tool

Add this tool to set individual pixels:

@tool
def put_pixel(x: int, y: int, color: str) -> str:
    """
    Set a single pixel at the specified coordinates.
    
    Args:
        x: X coordinate (0 = left edge)
        y: Y coordinate (0 = top edge)
        color: Color name ('red', 'blue', etc.) or hex value ('#FF0000')
    
    Returns:
        Confirmation message or error if out of bounds.
    """
    if _canvas["pixels"] is None:
        return "Error: Canvas not initialized. Call init_canvas first."
    
    width = _canvas["width"]
    height = _canvas["height"]
    
    if x < 0 or x >= width or y < 0 or y >= height:
        return f"Error: Coordinates ({x}, {y}) out of bounds for {width}x{height} canvas."
    
    r, g, b = _rgb_tuple(color)
    
    # Calculate pixel index (row-major order)
    index = (y * width + x) * 3
    _canvas["pixels"][index] = r
    _canvas["pixels"][index + 1] = g
    _canvas["pixels"][index + 2] = b
    
    return f"Pixel set at ({x}, {y}) with color {color}."

1.4 Add the draw_square Tool

Add this tool to draw filled squares:

@tool
def draw_square(x: int, y: int, size: int, color: str) -> str:
    """
    Draw a filled square on the canvas.
    
    Args:
        x: X coordinate of the top-left corner
        y: Y coordinate of the top-left corner
        size: Side length of the square in pixels
        color: Color name ('red', 'blue', etc.) or hex value ('#FF0000')
    
    Returns:
        Confirmation message with the square details.
    """
    if _canvas["pixels"] is None:
        return "Error: Canvas not initialized. Call init_canvas first."
    
    width = _canvas["width"]
    height = _canvas["height"]
    r, g, b = _rgb_tuple(color)
    
    pixels_drawn = 0
    
    for py in range(y, min(y + size, height)):
        if py < 0:
            continue
        for px in range(x, min(x + size, width)):
            if px < 0:
                continue
            index = (py * width + px) * 3
            _canvas["pixels"][index] = r
            _canvas["pixels"][index + 1] = g
            _canvas["pixels"][index + 2] = b
            pixels_drawn += 1
    
    return f"Square drawn at ({x}, {y}) with size {size}px in {color}. ({pixels_drawn} pixels)"

1.5 Add the draw_circle Tool

Add this tool to draw filled circles:

@tool
def draw_circle(x: int, y: int, radius: int, color: str) -> str:
    """
    Draw a filled circle on the canvas.
    
    Args:
        x: X coordinate of the circle center
        y: Y coordinate of the circle center
        radius: Radius of the circle in pixels
        color: Color name ('red', 'blue', etc.) or hex value ('#FF0000')
    
    Returns:
        Confirmation message with the circle details.
    """
    if _canvas["pixels"] is None:
        return "Error: Canvas not initialized. Call init_canvas first."
    
    width = _canvas["width"]
    height = _canvas["height"]
    r, g, b = _rgb_tuple(color)
    
    pixels_drawn = 0
    radius_squared = radius * radius
    
    # Iterate over bounding box of the circle
    for py in range(max(0, y - radius), min(height, y + radius + 1)):
        for px in range(max(0, x - radius), min(width, x + radius + 1)):
            # Check if point is inside circle using distance formula
            dx = px - x
            dy = py - y
            if dx * dx + dy * dy <= radius_squared:
                index = (py * width + px) * 3
                _canvas["pixels"][index] = r
                _canvas["pixels"][index + 1] = g
                _canvas["pixels"][index + 2] = b
                pixels_drawn += 1
    
    return f"Circle drawn at center ({x}, {y}) with radius {radius}px in {color}. ({pixels_drawn} pixels)"

1.6 Add the write_ppm Tool

Add this tool to save the canvas as a PPM file:

@tool
def write_ppm(filename: str) -> str:
    """
    Write the current canvas to a PPM P6 (binary) file.
    
    Args:
        filename: Output filename (e.g., 'image.ppm')
    
    Returns:
        Confirmation message with file details.
    """
    if _canvas["pixels"] is None:
        return "Error: Canvas not initialized. Call init_canvas first."
    
    width = _canvas["width"]
    height = _canvas["height"]
    
    # Ensure .ppm extension
    if not filename.lower().endswith(".ppm"):
        filename += ".ppm"
    
    try:
        with open(filename, "wb") as f:
            # Write PPM P6 header
            header = f"P6\n{width} {height}\n255\n"
            f.write(header.encode("ascii"))
            
            # Write pixel data
            f.write(_canvas["pixels"])
        
        file_size = len(_canvas["pixels"]) + len(header)
        return f"PPM file written: {filename} ({width}x{height}, {file_size} bytes)"
    
    except IOError as e:
        return f"Error writing file: {e}"

1.7 Add Tool Export List

At the end of the file, add the export list:

# Export all tools for registration
PPM_TOOLS = [
    init_canvas,
    put_pixel,
    draw_square,
    draw_circle,
    write_ppm,
]

Step 2: Register Tools with the ToolRegistry

File: app/tools/registry.py

Modify the registry to include our new PPM tools.

2.1 Add the Import

At the top of registry.py, add the import:

from app.tools.ppm_tools import PPM_TOOLS

2.2 Register the Tools

In the _register_default_tools method, add registration for each PPM tool:

def _register_default_tools(self) -> None:
    """Register the default set of available tools."""
    self._tools["tavily_search"] = TavilySearchResults(max_results=5)
    self._tools["python_repl"] = PythonREPLTool()
    
    # Register PPM drawing tools
    for tool in PPM_TOOLS:
        self._tools[tool.name] = tool

Complete Updated registry.py

Here's the complete file after modifications:

"""
Tool registry for managing available tools.

This module provides a centralized registry for tool instances,
enabling configuration-driven tool assignment to agents.
"""

from typing import Any

from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_experimental.tools import PythonREPLTool

from app.tools.ppm_tools import PPM_TOOLS


class ToolRegistry:
    """
    Registry for managing and retrieving tool instances.

    The registry maintains a mapping of tool names to their instances,
    allowing agents to be configured with tools by name.
    """

    def __init__(self):
        """Initialize the registry with default tools."""
        self._tools: dict[str, Any] = {}
        self._register_default_tools()

    def _register_default_tools(self) -> None:
        """Register the default set of available tools."""
        self._tools["tavily_search"] = TavilySearchResults(max_results=5)
        self._tools["python_repl"] = PythonREPLTool()
        
        # Register PPM drawing tools
        for tool in PPM_TOOLS:
            self._tools[tool.name] = tool

    def register(self, name: str, tool: Any) -> None:
        """
        Register a new tool with the given name.

        Args:
            name: Unique identifier for the tool.
            tool: The tool instance to register.
        """
        self._tools[name] = tool

    def get(self, name: str) -> Any:
        """
        Retrieve a tool by name.

        Args:
            name: The name of the tool to retrieve.

        Returns:
            The tool instance.

        Raises:
            KeyError: If the tool name is not registered.
        """
        if name not in self._tools:
            raise KeyError(f"Tool '{name}' not found in registry")
        return self._tools[name]

    def get_tools(self, names: list[str]) -> list[Any]:
        """
        Retrieve multiple tools by their names.

        Args:
            names: List of tool names to retrieve.

        Returns:
            List of tool instances in the same order as names.
        """
        return [self.get(name) for name in names]

    def list_available(self) -> list[str]:
        """
        List all available tool names.

        Returns:
            List of registered tool names.
        """
        return list(self._tools.keys())

Step 3: Create the Image Artist Agent

File: app/agents/implementations/image_artist.py

Create a new agent that specializes in image generation.

Complete Agent File

Create app/agents/implementations/image_artist.py:

"""
Image Artist agent implementation.

This agent specializes in creating PPM images based on
natural language descriptions of what to draw.
"""

from langchain_openai import ChatOpenAI

from app.agents.base import BaseAgent


class ImageArtistAgent(BaseAgent):
    """
    Agent specialized in creating PPM images from descriptions.

    The Image Artist interprets human-readable drawing instructions
    and uses PPM drawing tools to create images. It can handle
    requests like "draw a red square" or "create an image with
    random circles".
    """

    def __init__(
        self,
        name: str,
        llm: ChatOpenAI,
        tools: list,
        system_prompt: str,
    ):
        """
        Initialize the image artist agent.

        Args:
            name: Agent identifier.
            llm: Language model for understanding instructions.
            tools: PPM drawing tools.
            system_prompt: Prompt defining drawing behavior.
        """
        super().__init__(name, llm, tools, system_prompt)

    def get_description(self) -> str:
        """Return a description of the image artist's capabilities."""
        return (
            "An image creation agent that generates PPM images from "
            "natural language descriptions. It can draw shapes like "
            "squares and circles, set individual pixels, and save "
            "images to PPM P6 format files."
        )

Step 4: Export the Agent from Implementations

File: app/agents/implementations/__init__.py

Update the init file to export our new agent.

Complete Updated init.py

"""Agent implementations module containing concrete agent classes."""

from app.agents.implementations.researcher import ResearcherAgent
from app.agents.implementations.coder import CoderAgent
from app.agents.implementations.reviewer import ReviewerAgent
from app.agents.implementations.qa_tester import QATesterAgent
from app.agents.implementations.image_artist import ImageArtistAgent

__all__ = [
    "ResearcherAgent",
    "CoderAgent",
    "ReviewerAgent",
    "QATesterAgent",
    "ImageArtistAgent",
]

Step 5: Add Agent Configuration to YAML

File: app/config/agents.yaml

Add the configuration for our Image Artist agent.

Add Image Artist Configuration

Add this block to the agents: section in agents.yaml:

  image_artist:
    name: "Image Artist"
    tools:
      - "init_canvas"
      - "put_pixel"
      - "draw_square"
      - "draw_circle"
      - "write_ppm"
    system_prompt: >
      You are an Image Artist agent that creates PPM images based on user descriptions.
      
      IMPORTANT WORKFLOW:
      1. ALWAYS call init_canvas FIRST with the requested dimensions
      2. Then use draw_square, draw_circle, or put_pixel to add shapes
      3. ALWAYS call write_ppm at the end to save the file
      
      When the user asks for random shapes, generate reasonable random values:
      - Random positions within canvas bounds
      - Random sizes appropriate for the canvas
      - Random colors from: red, green, blue, yellow, cyan, magenta, orange, purple, pink
      
      Be creative and make visually interesting compositions.
      Always confirm what you drew after completing the image.

Complete Updated agents.yaml

agents:
  researcher:
    name: "Researcher"
    tools:
      - "tavily_search"
    system_prompt: "You are a web researcher agent."

  coder:
    name: "Coder"
    tools:
      - "python_repl"
    system_prompt: >
      You may generate safe Python code to analyze data with pandas
      and generate charts using matplotlib.

  reviewer:
    name: "Reviewer"
    tools:
      - "tavily_search"
    system_prompt: >
      You are a senior developer. You excel at code reviews.
      You give detailed and specific actionable feedback.
      You are not rude, but you don't worry about being polite either.
      Instead you just communicate directly about the technical review.

  qa_tester:
    name: "QA Tester"
    tools:
      - "python_repl"
    system_prompt: >
      You may generate safe Python code to test functions and classes
      using unittest or pytest.

  image_artist:
    name: "Image Artist"
    tools:
      - "init_canvas"
      - "put_pixel"
      - "draw_square"
      - "draw_circle"
      - "write_ppm"
    system_prompt: >
      You are an Image Artist agent that creates PPM images based on user descriptions.
      
      IMPORTANT WORKFLOW:
      1. ALWAYS call init_canvas FIRST with the requested dimensions
      2. Then use draw_square, draw_circle, or put_pixel to add shapes
      3. ALWAYS call write_ppm at the end to save the file
      
      When the user asks for random shapes, generate reasonable random values:
      - Random positions within canvas bounds
      - Random sizes appropriate for the canvas
      - Random colors from: red, green, blue, yellow, cyan, magenta, orange, purple, pink
      
      Be creative and make visually interesting compositions.
      Always confirm what you drew after completing the image.

Step 6: Register the Agent in SupervisorGraph

File: app/graphs/supervisor.py

Update the supervisor graph to recognize our new agent class.

6.1 Add the Import

Add ImageArtistAgent to the imports:

from app.agents.implementations import (
    CoderAgent,
    ImageArtistAgent,
    QATesterAgent,
    ResearcherAgent,
    ReviewerAgent,
)

6.2 Register the Agent Class

In the _register_agent_classes method, add registration for the Image Artist:

def _register_agent_classes(self) -> None:
    """Register the default agent classes with the factory."""
    self._agent_factory.register_agent_class("researcher", ResearcherAgent)
    self._agent_factory.register_agent_class("coder", CoderAgent)
    self._agent_factory.register_agent_class("reviewer", ReviewerAgent)
    self._agent_factory.register_agent_class("qa_tester", QATesterAgent)
    self._agent_factory.register_agent_class("image_artist", ImageArtistAgent)

Step 7: Test the Complete Implementation

7.1 Start the Server

poetry run uvicorn app.server:app --host 0.0.0.0 --port 8080

7.2 Test via the Playground

Open http://localhost:8080/playground/ and try this prompt:

Draw a PPM image of size 200 by 200. Add a square at coordinate 10,10 with a size of 20 pixels in red color. Then add 10 circles of varying sizes and colors randomly around the image. Write the PPM file called image.ppm

7.3 Test via curl

curl -X POST "http://localhost:8080/invoke" \
  -H "Content-Type: application/json" \
  -d '{
    "input": {
      "messages": [
        {
          "type": "human",
          "content": "Draw a PPM image of size 200 by 200. Add a square at coordinate 10,10 with a size of 20 pixels in red color. Then add 10 circles of varying sizes and colors randomly around the image. Write the PPM file called image.ppm"
        }
      ],
      "next": ""
    }
  }'

7.4 View the Generated Image

The PPM file will be created in the working directory. You can view it with:

  • GIMP - Open directly
  • ImageMagick - display image.ppm or convert: convert image.ppm image.png
  • Python - Use Pillow: from PIL import Image; Image.open('image.ppm').show()

Complete File Reference

Below are all the complete files for reference.

app/tools/ppm_tools.py (Complete)

"""
PPM Image Drawing Tools.

This module provides tools for creating PPM P6 format images,
including primitive drawing operations and file output.
"""

from typing import Tuple
from langchain_core.tools import tool


# Global canvas state (shared between tools)
_canvas = {
    "width": 0,
    "height": 0,
    "pixels": None,  # Will be a bytearray
}


def _rgb_tuple(color: str) -> Tuple[int, int, int]:
    """
    Convert a color name or hex value to RGB tuple.
    
    Args:
        color: Color name ('red', 'blue', etc.) or hex ('#FF0000')
    
    Returns:
        Tuple of (r, g, b) values 0-255
    """
    color_map = {
        "red": (255, 0, 0),
        "green": (0, 255, 0),
        "blue": (0, 0, 255),
        "yellow": (255, 255, 0),
        "cyan": (0, 255, 255),
        "magenta": (255, 0, 255),
        "white": (255, 255, 255),
        "black": (0, 0, 0),
        "orange": (255, 165, 0),
        "purple": (128, 0, 128),
        "pink": (255, 192, 203),
        "gray": (128, 128, 128),
        "grey": (128, 128, 128),
    }
    
    color_lower = color.lower().strip()
    
    if color_lower in color_map:
        return color_map[color_lower]
    
    # Try hex format
    if color_lower.startswith("#") and len(color_lower) == 7:
        try:
            r = int(color_lower[1:3], 16)
            g = int(color_lower[3:5], 16)
            b = int(color_lower[5:7], 16)
            return (r, g, b)
        except ValueError:
            pass
    
    # Default to white if unknown
    return (255, 255, 255)


@tool
def init_canvas(width: int, height: int, background_color: str = "black") -> str:
    """
    Initialize a new canvas with the given dimensions and background color.
    
    This must be called before any drawing operations.
    
    Args:
        width: Canvas width in pixels (e.g., 200)
        height: Canvas height in pixels (e.g., 200)
        background_color: Background color name or hex value (default: 'black')
    
    Returns:
        Confirmation message with canvas dimensions.
    """
    _canvas["width"] = width
    _canvas["height"] = height
    
    # Initialize pixel buffer (3 bytes per pixel: RGB)
    total_pixels = width * height
    _canvas["pixels"] = bytearray(total_pixels * 3)
    
    # Fill with background color
    r, g, b = _rgb_tuple(background_color)
    for i in range(total_pixels):
        _canvas["pixels"][i * 3] = r
        _canvas["pixels"][i * 3 + 1] = g
        _canvas["pixels"][i * 3 + 2] = b
    
    return f"Canvas initialized: {width}x{height} pixels with {background_color} background."


@tool
def put_pixel(x: int, y: int, color: str) -> str:
    """
    Set a single pixel at the specified coordinates.
    
    Args:
        x: X coordinate (0 = left edge)
        y: Y coordinate (0 = top edge)
        color: Color name ('red', 'blue', etc.) or hex value ('#FF0000')
    
    Returns:
        Confirmation message or error if out of bounds.
    """
    if _canvas["pixels"] is None:
        return "Error: Canvas not initialized. Call init_canvas first."
    
    width = _canvas["width"]
    height = _canvas["height"]
    
    if x < 0 or x >= width or y < 0 or y >= height:
        return f"Error: Coordinates ({x}, {y}) out of bounds for {width}x{height} canvas."
    
    r, g, b = _rgb_tuple(color)
    
    # Calculate pixel index (row-major order)
    index = (y * width + x) * 3
    _canvas["pixels"][index] = r
    _canvas["pixels"][index + 1] = g
    _canvas["pixels"][index + 2] = b
    
    return f"Pixel set at ({x}, {y}) with color {color}."


@tool
def draw_square(x: int, y: int, size: int, color: str) -> str:
    """
    Draw a filled square on the canvas.
    
    Args:
        x: X coordinate of the top-left corner
        y: Y coordinate of the top-left corner
        size: Side length of the square in pixels
        color: Color name ('red', 'blue', etc.) or hex value ('#FF0000')
    
    Returns:
        Confirmation message with the square details.
    """
    if _canvas["pixels"] is None:
        return "Error: Canvas not initialized. Call init_canvas first."
    
    width = _canvas["width"]
    height = _canvas["height"]
    r, g, b = _rgb_tuple(color)
    
    pixels_drawn = 0
    
    for py in range(y, min(y + size, height)):
        if py < 0:
            continue
        for px in range(x, min(x + size, width)):
            if px < 0:
                continue
            index = (py * width + px) * 3
            _canvas["pixels"][index] = r
            _canvas["pixels"][index + 1] = g
            _canvas["pixels"][index + 2] = b
            pixels_drawn += 1
    
    return f"Square drawn at ({x}, {y}) with size {size}px in {color}. ({pixels_drawn} pixels)"


@tool
def draw_circle(x: int, y: int, radius: int, color: str) -> str:
    """
    Draw a filled circle on the canvas.
    
    Args:
        x: X coordinate of the circle center
        y: Y coordinate of the circle center
        radius: Radius of the circle in pixels
        color: Color name ('red', 'blue', etc.) or hex value ('#FF0000')
    
    Returns:
        Confirmation message with the circle details.
    """
    if _canvas["pixels"] is None:
        return "Error: Canvas not initialized. Call init_canvas first."
    
    width = _canvas["width"]
    height = _canvas["height"]
    r, g, b = _rgb_tuple(color)
    
    pixels_drawn = 0
    radius_squared = radius * radius
    
    # Iterate over bounding box of the circle
    for py in range(max(0, y - radius), min(height, y + radius + 1)):
        for px in range(max(0, x - radius), min(width, x + radius + 1)):
            # Check if point is inside circle using distance formula
            dx = px - x
            dy = py - y
            if dx * dx + dy * dy <= radius_squared:
                index = (py * width + px) * 3
                _canvas["pixels"][index] = r
                _canvas["pixels"][index + 1] = g
                _canvas["pixels"][index + 2] = b
                pixels_drawn += 1
    
    return f"Circle drawn at center ({x}, {y}) with radius {radius}px in {color}. ({pixels_drawn} pixels)"


@tool
def write_ppm(filename: str) -> str:
    """
    Write the current canvas to a PPM P6 (binary) file.
    
    Args:
        filename: Output filename (e.g., 'image.ppm')
    
    Returns:
        Confirmation message with file details.
    """
    if _canvas["pixels"] is None:
        return "Error: Canvas not initialized. Call init_canvas first."
    
    width = _canvas["width"]
    height = _canvas["height"]
    
    # Ensure .ppm extension
    if not filename.lower().endswith(".ppm"):
        filename += ".ppm"
    
    try:
        with open(filename, "wb") as f:
            # Write PPM P6 header
            header = f"P6\n{width} {height}\n255\n"
            f.write(header.encode("ascii"))
            
            # Write pixel data
            f.write(_canvas["pixels"])
        
        file_size = len(_canvas["pixels"]) + len(header)
        return f"PPM file written: {filename} ({width}x{height}, {file_size} bytes)"
    
    except IOError as e:
        return f"Error writing file: {e}"


# Export all tools for registration
PPM_TOOLS = [
    init_canvas,
    put_pixel,
    draw_square,
    draw_circle,
    write_ppm,
]

app/tools/registry.py (Complete)

"""
Tool registry for managing available tools.

This module provides a centralized registry for tool instances,
enabling configuration-driven tool assignment to agents.
"""

from typing import Any

from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_experimental.tools import PythonREPLTool

from app.tools.ppm_tools import PPM_TOOLS


class ToolRegistry:
    """
    Registry for managing and retrieving tool instances.

    The registry maintains a mapping of tool names to their instances,
    allowing agents to be configured with tools by name.
    """

    def __init__(self):
        """Initialize the registry with default tools."""
        self._tools: dict[str, Any] = {}
        self._register_default_tools()

    def _register_default_tools(self) -> None:
        """Register the default set of available tools."""
        self._tools["tavily_search"] = TavilySearchResults(max_results=5)
        self._tools["python_repl"] = PythonREPLTool()
        
        # Register PPM drawing tools
        for tool in PPM_TOOLS:
            self._tools[tool.name] = tool

    def register(self, name: str, tool: Any) -> None:
        """
        Register a new tool with the given name.

        Args:
            name: Unique identifier for the tool.
            tool: The tool instance to register.
        """
        self._tools[name] = tool

    def get(self, name: str) -> Any:
        """
        Retrieve a tool by name.

        Args:
            name: The name of the tool to retrieve.

        Returns:
            The tool instance.

        Raises:
            KeyError: If the tool name is not registered.
        """
        if name not in self._tools:
            raise KeyError(f"Tool '{name}' not found in registry")
        return self._tools[name]

    def get_tools(self, names: list[str]) -> list[Any]:
        """
        Retrieve multiple tools by their names.

        Args:
            names: List of tool names to retrieve.

        Returns:
            List of tool instances in the same order as names.
        """
        return [self.get(name) for name in names]

    def list_available(self) -> list[str]:
        """
        List all available tool names.

        Returns:
            List of registered tool names.
        """
        return list(self._tools.keys())

app/agents/implementations/image_artist.py (Complete)

"""
Image Artist agent implementation.

This agent specializes in creating PPM images based on
natural language descriptions of what to draw.
"""

from langchain_openai import ChatOpenAI

from app.agents.base import BaseAgent


class ImageArtistAgent(BaseAgent):
    """
    Agent specialized in creating PPM images from descriptions.

    The Image Artist interprets human-readable drawing instructions
    and uses PPM drawing tools to create images. It can handle
    requests like "draw a red square" or "create an image with
    random circles".
    """

    def __init__(
        self,
        name: str,
        llm: ChatOpenAI,
        tools: list,
        system_prompt: str,
    ):
        """
        Initialize the image artist agent.

        Args:
            name: Agent identifier.
            llm: Language model for understanding instructions.
            tools: PPM drawing tools.
            system_prompt: Prompt defining drawing behavior.
        """
        super().__init__(name, llm, tools, system_prompt)

    def get_description(self) -> str:
        """Return a description of the image artist's capabilities."""
        return (
            "An image creation agent that generates PPM images from "
            "natural language descriptions. It can draw shapes like "
            "squares and circles, set individual pixels, and save "
            "images to PPM P6 format files."
        )

app/agents/implementations/init.py (Complete)

"""Agent implementations module containing concrete agent classes."""

from app.agents.implementations.researcher import ResearcherAgent
from app.agents.implementations.coder import CoderAgent
from app.agents.implementations.reviewer import ReviewerAgent
from app.agents.implementations.qa_tester import QATesterAgent
from app.agents.implementations.image_artist import ImageArtistAgent

__all__ = [
    "ResearcherAgent",
    "CoderAgent",
    "ReviewerAgent",
    "QATesterAgent",
    "ImageArtistAgent",
]

app/config/agents.yaml (Complete)

agents:
  researcher:
    name: "Researcher"
    tools:
      - "tavily_search"
    system_prompt: "You are a web researcher agent."

  coder:
    name: "Coder"
    tools:
      - "python_repl"
    system_prompt: >
      You may generate safe Python code to analyze data with pandas
      and generate charts using matplotlib.

  reviewer:
    name: "Reviewer"
    tools:
      - "tavily_search"
    system_prompt: >
      You are a senior developer. You excel at code reviews.
      You give detailed and specific actionable feedback.
      You are not rude, but you don't worry about being polite either.
      Instead you just communicate directly about the technical review.

  qa_tester:
    name: "QA Tester"
    tools:
      - "python_repl"
    system_prompt: >
      You may generate safe Python code to test functions and classes
      using unittest or pytest.

  image_artist:
    name: "Image Artist"
    tools:
      - "init_canvas"
      - "put_pixel"
      - "draw_square"
      - "draw_circle"
      - "write_ppm"
    system_prompt: >
      You are an Image Artist agent that creates PPM images based on user descriptions.
      
      IMPORTANT WORKFLOW:
      1. ALWAYS call init_canvas FIRST with the requested dimensions
      2. Then use draw_square, draw_circle, or put_pixel to add shapes
      3. ALWAYS call write_ppm at the end to save the file
      
      When the user asks for random shapes, generate reasonable random values:
      - Random positions within canvas bounds
      - Random sizes appropriate for the canvas
      - Random colors from: red, green, blue, yellow, cyan, magenta, orange, purple, pink
      
      Be creative and make visually interesting compositions.
      Always confirm what you drew after completing the image.

app/graphs/supervisor.py (Relevant Sections)

Updated imports:

from app.agents.implementations import (
    CoderAgent,
    ImageArtistAgent,
    QATesterAgent,
    ResearcherAgent,
    ReviewerAgent,
)

Updated _register_agent_classes method:

def _register_agent_classes(self) -> None:
    """Register the default agent classes with the factory."""
    self._agent_factory.register_agent_class("researcher", ResearcherAgent)
    self._agent_factory.register_agent_class("coder", CoderAgent)
    self._agent_factory.register_agent_class("reviewer", ReviewerAgent)
    self._agent_factory.register_agent_class("qa_tester", QATesterAgent)
    self._agent_factory.register_agent_class("image_artist", ImageArtistAgent)

Summary

You've now added a complete Image Artist agent to the system! Here's what we created:

Component File Purpose
PPM Tools app/tools/ppm_tools.py Drawing primitives and file output
Tool Registry app/tools/registry.py Register tools for agent use
Agent Class app/agents/implementations/image_artist.py Agent implementation
Agent Export app/agents/implementations/__init__.py Export for imports
Agent Config app/config/agents.yaml YAML configuration
Graph Registration app/graphs/supervisor.py Factory registration

The agent can now interpret natural language like:

  • "Create a 100x100 image with a blue circle in the center"
  • "Draw a checkerboard pattern with red and white squares"
  • "Make abstract art with random shapes"

Bonus: Example Prompts to Try

  1. Simple shapes:

    Create a 100x100 PPM image with a white background, draw a red square of size 30 at position 35,35, and save it as simple.ppm
    
  2. Multiple shapes:

    Make a 300x200 PPM with black background. Draw a yellow sun (circle radius 25) at 50,50. Draw green ground (square 300x50) at bottom. Save as landscape.ppm
    
  3. Random art:

    Create a 256x256 abstract art PPM. Add 5 random squares and 8 random circles with various colors. Save as abstract.ppm
    
  4. Pixel art:

    Create a tiny 16x16 PPM. Draw a red heart shape using individual pixels. Save as heart.ppm
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment