Designing Your Agent-Computer Interface

Module 11: Agentic System Design | Expansion Guide

Back to Module 11

The Problem

Your agent can "read files" and "run commands." Sounds powerful. Then it tries to read a 500MB log file and crashes. It runs rm -rf / because you forgot to restrict dangerous commands. It calls your API 10,000 times in a loop and racks up a $2000 bill.

The tools you give your agent are the only things standing between it and disaster.

Most developers treat tools as an afterthought - "just wrap the function and let the agent call it." But tool design determines whether your agent is useful or dangerous.

The Core Insight

Tools are contracts between agent intent and system safety. Design them defensively.

Think of tools like giving someone physical access to your computer. You wouldn't hand over root access without guardrails. Same with agents: every tool is a capability, and capabilities need constraints.

Good tool design makes the right thing easy and the dangerous thing impossible.

The Walkthrough

Tool Design Principles

1. Make Tools Atomic

One tool, one action. Don't create super-tools that do multiple things.

# Bad: Swiss army knife tool
def file_operations(action, path, content=None):
    if action == "read":
        return open(path).read()
    elif action == "write":
        open(path, 'w').write(content)
    elif action == "delete":
        os.remove(path)
    # Agent must guess action parameter

# Good: Atomic tools
def read_file(path: str) -> str:
    """Read and return file contents."""
    return safe_read(path)

def write_file(path: str, content: str) -> bool:
    """Write content to file. Returns success status."""
    return safe_write(path, content)

# Agent picks the right tool naturally

2. Add Guard Rails

Every tool should validate inputs and limit scope:

def read_file(path: str, max_size_mb: int = 10) -> str:
    """
    Read file contents.

    Args:
        path: File path (must be within project directory)
        max_size_mb: Max file size to read (default 10MB)

    Raises:
        SecurityError: If path is outside allowed directory
        FileTooLargeError: If file exceeds size limit
    """
    # Security: Only allow project files
    if not is_safe_path(path):
        raise SecurityError(f"Access denied: {path}")

    # Prevent reading huge files
    size_mb = os.path.getsize(path) / 1024 / 1024
    if size_mb > max_size_mb:
        raise FileTooLargeError(
            f"File too large ({size_mb:.1f}MB). Use read_file_chunk instead."
        )

    with open(path, 'r') as f:
        return f.read()

3. Return Structured Data

Help agents parse results reliably:

# Bad: Unstructured string return
def run_command(cmd):
    result = subprocess.run(cmd, capture_output=True)
    return result.stdout + result.stderr  # Agent must parse

# Good: Structured return
def run_command(cmd: str) -> CommandResult:
    result = subprocess.run(cmd, capture_output=True, text=True)
    return {
        "success": result.returncode == 0,
        "stdout": result.stdout,
        "stderr": result.stderr,
        "exit_code": result.returncode
    }

# Agent can check result["success"] reliably

The Tool Safety Levels

Categorize tools by risk:

Level Risk Examples Safeguards
Read-Only Low read_file, list_directory, search_code Size limits, path restrictions
Write Medium write_file, create_directory Path whitelist, backup before write
Execute High run_command, call_api Command whitelist, rate limits, sandboxing
Destructive Critical delete_file, drop_database Confirmation required, audit log, undo capability

The Confirmation Pattern for Destructive Actions

def delete_file(path: str, confirm: bool = False) -> dict:
    """Delete a file. Requires explicit confirmation."""
    if not confirm:
        return {
            "action": "confirmation_required",
            "message": f"About to delete {path}. Call with confirm=True to proceed.",
            "tool": "delete_file",
            "params": {"path": path, "confirm": True}
        }

    # Actually delete
    os.remove(path)
    return {"success": True, "deleted": path}

Forces agent to make two deliberate calls for dangerous actions.

Error Handling in Tools

Agents can't handle exceptions well. Return errors as data:

# Bad: Throws exception agent can't catch
def api_call(endpoint):
    response = requests.get(endpoint)
    response.raise_for_status()  # Agent sees generic error
    return response.json()

# Good: Returns error as structured data
def api_call(endpoint: str) -> dict:
    """Call API endpoint and return structured result."""
    try:
        response = requests.get(endpoint, timeout=10)
        response.raise_for_status()
        return {
            "success": True,
            "data": response.json(),
            "status_code": response.status_code
        }
    except requests.Timeout:
        return {
            "success": False,
            "error": "timeout",
            "message": "API call timed out after 10 seconds",
            "retry_suggested": True
        }
    except requests.HTTPError as e:
        return {
            "success": False,
            "error": "http_error",
            "status_code": e.response.status_code,
            "message": str(e),
            "retry_suggested": e.response.status_code >= 500
        }

# Agent can check result["success"] and act accordingly

The Tool Documentation Pattern

Agents rely on tool descriptions. Make them clear:

def search_codebase(
    pattern: str,
    file_extension: str = None,
    case_sensitive: bool = False
) -> list[dict]:
    """
    Search for pattern across codebase files.

    Use this when you need to find where code exists or how
    something is implemented. Returns file paths and line numbers.

    Args:
        pattern: Search string or regex pattern
        file_extension: Optional filter (e.g., "py", "js")
        case_sensitive: Whether to match case (default: False)

    Returns:
        List of matches with:
        - file: File path
        - line: Line number
        - content: The matching line
        - context: 2 lines before/after

    Examples:
        search_codebase("def handle_payment")
        search_codebase("TODO", file_extension="py")
    """
    # Implementation...

Failure Patterns

1. The Unrestricted Tool

Symptom: Agent deletes production database because you gave it run_sql() with no limits.

Fix: Whitelist safe operations. Read-only by default. Destructive actions require confirmation.

2. The Black-Box Tool

Symptom: Tool returns "Error" and agent has no idea what went wrong.

Fix: Return structured errors with actionable messages: what failed, why, what to try next.

3. The Expensive Loop

Symptom: Agent calls api_fetch() 10,000 times in a loop, maxing out rate limits.

Fix: Add rate limiting and batch operations. Provide api_fetch_batch() for bulk operations.

4. The Tool Zoo

Symptom: 50 tools, agent has no idea which to use when.

Fix: 5-10 tools max. Each with clear, distinct purpose. Good docs. Examples.

The Turing Tarpit

Giving an agent a run_python() or exec_code() tool seems powerful. It's also dangerous and unpredictable. Better: give specific tools for specific tasks. Execution should be last resort, heavily sandboxed.

Example: File System Tool Suite

Minimal Safe Interface

class FileSystemTools:
    """Safe file operations for agents."""

    def __init__(self, allowed_dir: str):
        self.allowed_dir = Path(allowed_dir).resolve()

    def read_file(self, path: str) -> dict:
        """Read file contents (max 10MB)."""
        full_path = self._validate_path(path)
        if full_path.stat().st_size > 10 * 1024 * 1024:
            return {"error": "file_too_large", "max_size": "10MB"}

        return {
            "success": True,
            "content": full_path.read_text(),
            "size_bytes": full_path.stat().st_size
        }

    def write_file(self, path: str, content: str, create_backup: bool = True) -> dict:
        """Write file with automatic backup."""
        full_path = self._validate_path(path)

        if create_backup and full_path.exists():
            backup_path = full_path.with_suffix(full_path.suffix + '.backup')
            shutil.copy(full_path, backup_path)

        full_path.write_text(content)
        return {"success": True, "path": str(full_path)}

    def list_files(self, pattern: str = "*") -> dict:
        """List files matching pattern."""
        files = list(self.allowed_dir.rglob(pattern))
        return {
            "success": True,
            "files": [str(f.relative_to(self.allowed_dir)) for f in files],
            "count": len(files)
        }

    def _validate_path(self, path: str) -> Path:
        """Ensure path is within allowed directory."""
        full_path = (self.allowed_dir / path).resolve()
        if not str(full_path).startswith(str(self.allowed_dir)):
            raise SecurityError(f"Path outside allowed directory: {path}")
        return full_path

Quick Reference

Tool Design Checklist:

Safety Levels:

  1. Read: Size limits, path restrictions
  2. Write: Whitelist locations, auto-backup
  3. Execute: Command whitelist, sandboxing
  4. Destroy: Confirmation + audit log

Rule of Thumb:

If you wouldn't trust a junior developer with unrestricted access, don't give it to your agent. Tools are trust boundaries.