<p align="center">
  <img src="logo.png" alt="Agentlings" width="256">
</p>

<h1 align="center">Agentlings</h1>

<p align="center">
  Lightweight single-process agent framework exposing both
  <a href="https://a2a-protocol.org">A2A</a> and
  <a href="https://modelcontextprotocol.io">MCP</a> on a single HTTP port.
</p>

---

Each agentling is a small, focused AI agent whose identity is defined by a YAML config file — name, description, system prompt, tools, and skills. The framework handles protocol compliance, conversation journaling, and context management. The LLM is the agent; the framework records and replays.

## Quick start

```bash
pip install -e ".[dev]"

# Create your agent definition
cp agent.example.yaml agent.yaml

# Run with mock LLM (no API key needed)
AGENT_CONFIG=./agent.yaml AGENT_LLM_BACKEND=mock AGENT_API_KEY=dev agentling

# Run with Anthropic
AGENT_CONFIG=./agent.yaml ANTHROPIC_API_KEY=sk-ant-... AGENT_API_KEY=your-key agentling

# See available tools
agentling --list-tools
```

The agent serves:
- `GET /.well-known/agent-card.json` — A2A Agent Card (public, no auth)
- `POST /a2a` — A2A JSON-RPC endpoint
- `POST /mcp` — MCP Streamable HTTP endpoint

## Running as a daemon

### systemd (Linux)

Create `/etc/systemd/system/agentling.service`:

```ini
[Unit]
Description=Agentling
After=network.target

[Service]
Type=simple
User=agentling
WorkingDirectory=/opt/agentling
EnvironmentFile=/opt/agentling/.env
ExecStart=/opt/agentling/venv/bin/agentling
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
```

```bash
# Set up
sudo useradd -r -s /bin/false agentling
sudo mkdir -p /opt/agentling
sudo python3 -m venv /opt/agentling/venv
sudo /opt/agentling/venv/bin/pip install agentlings

# Copy your config
sudo cp agent.yaml /opt/agentling/agent.yaml
sudo cp .env /opt/agentling/.env    # ANTHROPIC_API_KEY, AGENT_API_KEY, AGENT_CONFIG=./agent.yaml

# Start
sudo systemctl daemon-reload
sudo systemctl enable --now agentling
sudo journalctl -u agentling -f
```

### launchd (macOS)

Create `~/Library/LaunchAgents/com.donkeywork.agentling.plist`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.donkeywork.agentling</string>
    <key>ProgramArguments</key>
    <array>
        <string>/path/to/venv/bin/agentling</string>
    </array>
    <key>WorkingDirectory</key>
    <string>/path/to/agentling</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>AGENT_CONFIG</key>
        <string>./agent.yaml</string>
        <key>ANTHROPIC_API_KEY</key>
        <string>sk-ant-...</string>
        <key>AGENT_API_KEY</key>
        <string>your-key</string>
    </dict>
    <key>KeepAlive</key>
    <true/>
    <key>StandardErrorPath</key>
    <string>/tmp/agentling.err</string>
</dict>
</plist>
```

```bash
launchctl load ~/Library/LaunchAgents/com.donkeywork.agentling.plist
tail -f /tmp/agentling.err
```

## Agent definition

Agent identity lives in a YAML file (`agent.yaml`):

```yaml
name: k3s-agentling
description: A k3s cluster management agent

tools:
  - bash
  - filesystem

skills:
  - id: k8s-ops
    name: Kubernetes Operations
    description: Manage cluster resources, diagnose issues, apply manifests
    tags: [kubernetes, k3s, devops]
  - id: file-management
    name: File Management
    description: Read, write, and search configuration files
    tags: [files, yaml]

system_prompt: |
  You are a DevOps engineer managing a k3s Kubernetes cluster.

  All configuration changes go through /mnt/lab/k3s as the source of truth.
  Never use kubectl patch/edit/set directly — write manifests and apply them.

  Before any destructive operation, describe the impact and ask for confirmation.
```

Point to it with `AGENT_CONFIG=./agent.yaml`.

### Available tools

| Group | Tools | Description |
|-------|-------|-------------|
| `bash` | `bash` | Shell command execution with timeout |
| `filesystem` | `read_file`, `write_file`, `edit_file`, `list_directory`, `search_files` | File operations with offset/limit, find-and-replace, glob search |
| `memory` | `memory_edit` | Read and write the agent's persistent long-term memory |

Tools are off by default. Run `agentling --list-tools` for details.

## Docker

```bash
docker build -t agentling:latest .
docker run -e AGENT_API_KEY=your-key -e AGENT_LLM_BACKEND=mock -p 8420:8420 agentling
```

## Environment variables

Secrets and runtime settings stay in env vars (or `.env` file):

| Variable | Default | Description |
|----------|---------|-------------|
| `AGENT_CONFIG` | — | Path to agent YAML definition |
| `ANTHROPIC_API_KEY` | — | Anthropic API key (required for api.anthropic.com; optional with `ANTHROPIC_BASE_URL` pointed at e.g. Ollama) |
| `ANTHROPIC_BASE_URL` | — | Override the Messages endpoint. Use `http://localhost:11434` to target Ollama's Anthropic-compatible API |
| `AGENT_API_KEY` | — | API key for authenticating clients |
| `AGENT_MODEL` | `claude-sonnet-4-6` | Model ID — set to an Ollama model (e.g. `qwen3-coder`) when using `ANTHROPIC_BASE_URL` |
| `AGENT_MAX_TOKENS` | `4096` | Max tokens per LLM response |
| `AGENT_HOST` | `0.0.0.0` | Bind address |
| `AGENT_PORT` | `8420` | Bind port |
| `AGENT_DATA_DIR` | `./data` | JSONL journal storage directory |
| `AGENT_LOG_LEVEL` | `INFO` | Log level |
| `AGENT_LLM_BACKEND` | `anthropic` | `anthropic` or `mock` |
| `AGENT_EXTERNAL_URL` | — | Public URL for Agent Card (needed in Docker/k8s) |
| `AGENT_OTEL_ENDPOINT` | — | OpenTelemetry collector endpoint |
| `AGENT_OTEL_PROTOCOL` | `http` | Collector protocol (`http` or `grpc`) |
| `AGENT_OTEL_INSECURE` | `true` | Disable TLS for collector connection |
| `AGENT_OTEL_HEADERS` | — | Comma-separated `key=value` pairs for collector auth |

## Memory

Agentlings can maintain persistent long-term memory — a curated set of key-value facts that survive across conversations. Memory transforms an agent from a tool that forgets into one that learns.

### How it works

Memory is a JSON file (`data/memory/memory.json`) containing entries like:

```json
{
  "entries": [
    {
      "key": "cluster-node-count",
      "value": "4 nodes: node1 (control), node2-4 (workers)",
      "recorded": "2026-04-01T10:00:00Z"
    }
  ]
}
```

The memory block is injected into the system prompt on every LLM call, between the agent's identity and the conversation history. The agent sees its accumulated knowledge as working context, not as a separate tool call.

### The memory tool

When the `memory` tool group is enabled, the agent gets a `memory_edit` tool with three operations:

| Operation | Description |
|-----------|-------------|
| `set` | Upsert an entry by key. Updates the timestamp. |
| `remove` | Delete an entry by key. |
| `list` | Return all current entries. |

The agent decides what to remember based on its system prompt. A DevOps agent might store cluster topology and known issues. A support agent might store escalation paths and recurring problems.

### CLI

```bash
# Show current memory
agentling memory show
```

### Configuration

```yaml
memory:
  token_budget: 2000        # max tokens for the memory block in the system prompt
  # injection_prompt: null   # override the memory/data-dir-awareness template
```

## Sleep cycle

<p align="center">
  <img src="sleep.png" alt="Sleep Cycle" width="256">
</p>

The sleep cycle is a nightly process that journals the day's activity, consolidates new knowledge into memory, prunes stale entries, and cleans up old files. It maps to biological sleep phases.

```mermaid
graph LR
    L[Light Sleep<br/>Gate check] --> D[Deep Sleep<br/>Replay & journal]
    D --> R[REM<br/>Integrate & prune]
    R --> H[Housekeeping<br/>Retention cleanup]
```

### Phase 1: Light sleep — gate check

Quick check: were there any conversations today? If not, skip everything. No LLM calls, no cost.

### Phase 2: Deep sleep — replay and file

For each conversation from today, the sleep cycle reads the JSONL journal from the last compaction marker and submits all summaries as a single **batch request** to the Anthropic Message Batches API. Batch processing runs at 50% cost and processes in parallel.

Each summary call receives the agent's system prompt (so the agent's persona shapes what it considers important), current memory, and the conversation content. The LLM returns a structured `ConversationSummary` with a narrative and memory candidates.

Results are written to `data/journals/YYYY-MM-DD.md`.

### Phase 3: REM — integrate and prune

A single LLM call receives current memory, today's journal, and all extracted memory candidates. It integrates new facts, deduplicates, reviews existing entries for staleness, and returns a `ConsolidatedMemory` — the complete updated memory store. Written atomically to `memory.json`.

### Phase 4: Housekeeping — retention cleanup

Deletes conversation JSONL files older than `conversation_retention_days` and journal files older than `journal_retention_days`.

### Configuration

```yaml
sleep:
  schedule: "0 2 * * *"           # cron expression (default: 2am daily)
  journal_retention_days: 30       # keep journals for 30 days
  conversation_retention_days: 14  # keep JSONL conversations for 14 days
  memory_max_entries: 50           # hard cap after consolidation
  # model: null                    # override model for sleep calls
  # summary_prompt: null           # override per-conversation summary prompt
  # consolidation_prompt: null     # override REM consolidation prompt
```

### CLI

```bash
# Trigger sleep cycle manually
agentling sleep --date 2026-04-01
```

### Data directory layout

```
data/
  abc123.jsonl           # conversation journals (flat, as before)
  def456.jsonl
  memory/
    memory.json          # persistent memory store
  journals/
    2026-04-01.md        # daily sleep journal
    2026-04-02.md
```

The agent is told about this directory structure and can use its filesystem tools to search past journals and conversation logs for context beyond what fits in memory.

## OpenTelemetry

The sleep cycle and memory tool emit spans and metrics to an OpenTelemetry collector when telemetry is enabled.

```yaml
telemetry:
  enabled: true
  endpoint: "http://otel-collector:4318"
  protocol: "http"                        # "http" or "grpc"
  service_name: "agentling"
  insecure: true
  headers:                                # optional auth headers
    Authorization: "Bearer your-token"
```

Or via env vars: `AGENT_OTEL_ENDPOINT=http://collector:4318 AGENT_OTEL_HEADERS="Authorization=Bearer tok"`.


When telemetry is disabled (the default) or the OpenTelemetry packages are not installed, all instrumentation is a no-op.

## Architecture

```mermaid
graph TB
    A2A[A2A Client] -->|POST /a2a| A2ASDK[a2a-sdk Server]
    MCP[MCP Client] -->|POST /mcp| MCPSDK[mcp SDK Server]
    A2ASDK --> Executor[AgentlingExecutor]
    Executor --> Loop[MessageLoop]
    MCPSDK --> Loop
    Loop --> Store[JSONL Store]
    Loop --> LLM[LLM Client]
    Loop --> Tools[Tool Registry]
```

Both protocols feed into a single `MessageLoop.process_message()` entrance. Conversations are persisted as append-only JSONL journals with compaction markers as replay cursors.

## Testing

```bash
# Unit tests (no network, no LLM)
pytest tests/unit/ -v

# Integration tests (starts real server with mock LLM)
pytest tests/integration/ -v

# All tests
pytest tests/ -v
```

Integration tests use native SDK clients — `a2a-sdk` `ClientFactory` for A2A and `mcp` `ClientSession` for MCP — talking to a real server over HTTP. All LLM responses are mocked.

## Built with

- [a2a-sdk](https://github.com/a2aproject/a2a-python) — A2A protocol server + client
- [mcp](https://github.com/modelcontextprotocol/python-sdk) — MCP protocol server + client
- [anthropic](https://github.com/anthropics/anthropic-sdk-python) — LLM backend
- [starlette](https://www.starlette.io) + [uvicorn](https://www.uvicorn.org) — HTTP server
