Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 56 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,22 @@
PYTHON ?= python3
SRC := src
TESTS := tests
FLAGSHIP := examples/enterprise-knowledge-assistant/agents.yaml
# Which example the local stack runs. The starter example is the default
# because it needs one API key and no internet:
# make up EXAMPLE=examples/enterprise-knowledge-assistant
EXAMPLE ?= examples/starter
CONFIG ?= agents.yaml
FLAGSHIP := $(EXAMPLE)/$(CONFIG)

# Every example must stay offline-valid — `make validate` checks them all.
EXAMPLES := examples/starter examples/enterprise-knowledge-assistant

IMAGE := extra:local
COMPOSE := CONFIG=$(CONFIG) docker compose -f $(EXAMPLE)/docker-compose.yml

.DEFAULT_GOAL := help
.PHONY: help install generate-ai sync-ai sync-skills test lint format typecheck check clean validate inspect
.PHONY: help install generate-ai sync-ai sync-skills test lint format typecheck check clean validate inspect \
build validate-image up up-ui down logs generate-check

help: ## Show available targets.
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
Expand Down Expand Up @@ -39,14 +51,53 @@ typecheck: ## Type-check the codebase (mypy).
test: ## Run the test suite (pytest).
pytest

check: lint typecheck test ## Quality gate: lint + typecheck + test.
check: lint typecheck test generate-check ## Quality gate: lint + typecheck + test + generated stubs.

# Compares the example tree before and after `generate` rather than against
# HEAD, so an unrelated work-in-progress diff cannot make this fail.
generate-check: ## Fail if `agentctl generate` would write anything (stale stubs).
@for ex in $(EXAMPLES); do \
before=$$(find $$ex -type f -not -path '*/__pycache__/*' | sort | xargs shasum | shasum); \
agentctl generate --config $$ex/agents.yaml >/dev/null; \
after=$$(find $$ex -type f -not -path '*/__pycache__/*' | sort | xargs shasum | shasum); \
if [ "$$before" != "$$after" ]; then \
echo "Stale stubs in $$ex — run 'agentctl generate --config $$ex/agents.yaml' and commit."; \
exit 1; \
fi; \
done

validate: ## Validate the flagship example offline (no LLM calls, no network, no API keys).
agentctl validate $(FLAGSHIP)
validate: ## Validate every example offline (no LLM calls, no network, no API keys).
@for ex in $(EXAMPLES); do agentctl validate $$ex/agents.yaml || exit 1; done

inspect: ## Inspect the flagship example offline (agents, MCPs, hooks, plugins, tags).
agentctl inspect $(FLAGSHIP)

build: ## Build the container image, tagged extra:local.
docker build -t $(IMAGE) .

# Validate inside the image so only Docker is required, not a local install.
validate-image: build
docker run --rm -v "$(PWD)/$(EXAMPLE):/workspace" $(IMAGE) validate /workspace/$(CONFIG)

up: validate-image $(EXAMPLE)/.env ## Run the example in engine mode: API on http://localhost:8090
$(COMPOSE) --profile engine up -d
@echo "engine: http://localhost:8090/health"

up-ui: validate-image $(EXAMPLE)/.env ## Run the example in manager mode: chat UI on http://localhost:8100/demo
$(COMPOSE) --profile ui up -d
@echo "chat UI: http://localhost:8100/demo"

down: ## Stop the example stack.
$(COMPOSE) --profile engine --profile ui down

logs: ## Follow logs from the example stack.
$(COMPOSE) --profile engine --profile ui logs -f

$(EXAMPLE)/.env:
@cp $(EXAMPLE)/.env.example $@
@echo "Created $@ — fill in the required keys, then run make again."
@exit 1

clean: ## Remove caches and build artifacts.
rm -rf .pytest_cache .mypy_cache .ruff_cache dist build *.egg-info
find . -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null || true
33 changes: 33 additions & 0 deletions examples/enterprise-knowledge-assistant/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
services:
mcp:
image: extra:local
entrypoint: ["python", "-m", "mcps.local_knowledge_mcp.server"]
volumes:
- .:/workspace
ports:
- "8090:8090"
- "8100:8100"

engine:
image: extra:local
profiles: ["engine"]
command: ["serve", "--config", "/workspace/${CONFIG:-agents.yaml}", "--host", "0.0.0.0", "--port", "8090"]
volumes:
- .:/workspace
env_file:
- .env
network_mode: "service:mcp"
depends_on:
- mcp

manager:
image: extra:local
profiles: ["ui"]
command: ["agent-manager", "--config", "/workspace/${CONFIG:-agents.yaml}", "--host", "0.0.0.0", "--port", "8100"]
volumes:
- .:/workspace
env_file:
- .env
network_mode: "service:mcp"
depends_on:
- mcp
23 changes: 23 additions & 0 deletions examples/starter/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copy to .env in this directory. .env is gitignored.
#
# Defaults to a local Ollama model, so the example runs with no paid API key:
#
# ollama pull qwen2.5:14b
#
# OPENAI_API_KEY is required by the OpenAI client but unused by Ollama, so any
# non-empty value works.
OPENAI_API_KEY=ollama

# host.docker.internal reaches Ollama on your machine from inside the container,
# which is how `make up` runs it. Running without Docker? Use 127.0.0.1 instead.
OPENAI_BASE_URL=http://host.docker.internal:11434/v1

# For a hosted provider, set provider + name in agents.yaml and the key here:
#
# provider: openai -> OPENAI_API_KEY (and drop OPENAI_BASE_URL)
# provider: anthropic -> ANTHROPIC_API_KEY
# provider: gemini -> GEMINI_API_KEY
# provider: bedrock -> AWS credentials + region

# Optional — read by the bank_name resolver.
# BANK_NAME=Northwind Bank
133 changes: 133 additions & 0 deletions examples/starter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Starter example — Bank Concierge

The example to read first. A small bank assistant that uses every part of the
platform, with logic simple enough that nothing distracts from the wiring.

Everything is fake — two accounts, five transactions, no database, no internet.
It runs against a local Ollama model by default, so it needs no paid API key at
all.

## Run it

```bash
ollama pull qwen2.5:14b
```

Then from the repository root:

```bash
make up
```

First run creates `.env` from `.env.example` and stops so you can check it —
the defaults already point at Ollama, so you can usually run `make up` straight
again. Then:

```bash
agentctl chat --url http://localhost:8090
```

Or without Docker — two terminals:

```bash
cd examples/starter && python3 -m mcps.bank.server
```
```bash
agentctl chat --config examples/starter/agents.yaml
```

Offline checks need no key and no MCP:

```bash
make validate # every example, offline
make inspect # agents, MCPs, hooks, tools
```

## Try these

| Ask | What it shows |
| --- | --- |
| `What are your opening hours?` | routing three levels deep; a resolver filling `{{bank_name}}` |
| `How much money do I have?` | an MCP tool call against the bundled server |
| `How much did I spend on groceries and coffee?` | the model reading tool output and computing an answer |
| `I lost my card ending 4821, please block it` | a local Python tool — and an approval prompt before it runs |

The route is printed with every answer, so you can see which agents handled it.

## The system

```
concierge routes to a department, never answers
├── support_router
│ ├── faq_agent auto: true — no tools, answers from its prompt
│ └── card_agent block_card local tool, requires approval
└── accounts_router
├── balance_agent get_accounts (bank_core MCP)
└── transactions_agent get_transactions (bank_core MCP)
```

Orchestrators route and never answer. Agents do the work. A node can only reach
its own children — the indentation in `graph:` *is* the permission model.

## What each file is for

| Path | What it does |
| --- | --- |
| `agents.yaml` | the whole system: models, limits, tools, MCPs, resolvers, hooks, graph |
| `prompts/<node>/system.md` | the live prompt for that node |
| `prompts/<node>/orchestrator.md` | routing reference for humans; **not** loaded at runtime |
| `plugins/tools/block_card.py` | a local tool: one dict in, one string out |
| `plugins/resolvers/shared.py` | values injected into prompts as `{{name}}` |
| `plugins/resolvers/<agent>.py` | per-agent resolver class; inherits the shared one |
| `plugins/hooks/lifecycle.py` | one method per lifecycle point, each logging |
| `plugins/plugins.toml` | manifest of hooks, resolvers and tools |
| `mcps/bank/` | the bundled MCP server and its fake data |

Note the split in `prompts/`: the engine loads **`system.md`**. `orchestrator.md`
documents the routing policy for whoever reads the repo — keep the two in step.

## Switching model

Change `defaults.model` in `agents.yaml` and set the matching key in `.env`.
Nothing else in the YAML names a provider, so it's one edit. The default is a
local Ollama model through the OpenAI-compatible API:

```yaml
defaults:
model:
provider: openai # anthropic | openai | gemini | bedrock
name: qwen2.5:14b
temperature: 0.0
```

```
OPENAI_API_KEY=ollama
OPENAI_BASE_URL=http://host.docker.internal:11434/v1
```

`host.docker.internal` is how a container reaches Ollama on your machine, which
is what `make up` does. Running without Docker, use `127.0.0.1` instead.

For a hosted provider, set `provider` and `name`, drop `OPENAI_BASE_URL`, and
put the real key in `.env`.

## Approvals

`card_agent` changes something, so its tool call pauses for a human decision.
The read-only agents set `auto: true` and run straight through.

Approvals are resolved by `agentctl chat` and by the engine's HTTP API
(`/runs/{run_id}/approvals/...`). `agentctl run` has no way to answer the
prompt, so a run that needs one returns nothing — use `chat` for the card.

## Adding to it

Add a node to `agents.yaml`, put it in `graph:`, then:

```bash
agentctl generate --config examples/starter/agents.yaml
```

That writes any missing prompt and plugin stubs without touching your existing
files, and does nothing when everything is already in place. Fill in the stubs
and commit them.
Loading
Loading