diff --git a/Makefile b/Makefile index eba6e7b6..24b27ee6 100644 --- a/Makefile +++ b/Makefile @@ -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) \ @@ -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 diff --git a/examples/enterprise-knowledge-assistant/docker-compose.yml b/examples/enterprise-knowledge-assistant/docker-compose.yml new file mode 100644 index 00000000..924cdd39 --- /dev/null +++ b/examples/enterprise-knowledge-assistant/docker-compose.yml @@ -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 diff --git a/examples/starter/.env.example b/examples/starter/.env.example new file mode 100644 index 00000000..5e8733aa --- /dev/null +++ b/examples/starter/.env.example @@ -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 diff --git a/examples/starter/README.md b/examples/starter/README.md new file mode 100644 index 00000000..a40bce4b --- /dev/null +++ b/examples/starter/README.md @@ -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//system.md` | the live prompt for that node | +| `prompts//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/.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. diff --git a/examples/starter/agents.yaml b/examples/starter/agents.yaml new file mode 100644 index 00000000..a1f09df7 --- /dev/null +++ b/examples/starter/agents.yaml @@ -0,0 +1,136 @@ +system: + name: Bank Concierge + +# Switch provider here and set the matching key in .env — nothing else in this +# file names a vendor. Defaults to a local Ollama model so the example runs +# without a paid API key. +defaults: + model: + provider: openai + name: qwen2.5:14b + temperature: 0.0 + +execution: + max_iterations: 12 + max_tool_calls: 8 + max_tool_calls_per_agent: 3 + max_child_agent_calls: 4 + allow_duplicate_tool_calls: false + +mcps: + # Bundled with the example (see mcps/bank) so it runs offline, but it speaks + # the same Streamable HTTP protocol as a hosted server — in production this is + # where you point at https://mcp.yourcompany.com/mcp, and a before_mcp_request + # hook attaches the credential. + bank_core: + url: "http://127.0.0.1:8765/mcp" + +tools: + # Python running in-process, for what an MCP would be overkill for: calling + # your own service, writing to a queue, hitting a database. The description is + # what the model reads when deciding to call it, so write it for the model. + block_card: + description: Block a bank card and issue a replacement. Needs the last four digits. + +# Injected into prompts as {{name}}. Implemented in plugins/resolvers/. +resolvers: + customer_name: + scope: shared + bank_name: + scope: shared + today: + scope: shared + +plugins: + import_roots: ["."] + +# These only log, so you can watch the sequence and see where real audit, +# redaction or tracing code would go. `failure_policy: warn` stops a raising +# hook from failing the request; the default is to fail. +hooks: + on_run_start: + - plugin: lifecycle + method: run_started + failure_policy: warn + after_tool_call: + - plugin: lifecycle + method: tool_finished + failure_policy: warn + transform_tool_result: + - plugin: lifecycle + method: shorten_result + failure_policy: warn + on_run_error: + - plugin: lifecycle + method: run_failed + failure_policy: warn + +orchestrators: + concierge: + name: Concierge + description: Front desk. Decides whether a request is about support or accounts. + prompts: + orchestrator: prompts/concierge/orchestrator.md + system: prompts/concierge/system.md + + support_router: + name: Support + description: Handles general questions and card problems. + prompts: + orchestrator: prompts/support_router/orchestrator.md + system: prompts/support_router/system.md + + accounts_router: + name: Accounts + description: Handles balances and transaction history. + prompts: + orchestrator: prompts/accounts_router/orchestrator.md + system: prompts/accounts_router/system.md + +agents: + faq_agent: + name: FAQ + description: Answers general questions about hours, fees and branches. + prompts: + system: prompts/faq_agent/system.md + resolvers: [bank_name] + auto: true + + card_agent: + name: Cards + description: Blocks lost or stolen cards and orders replacements. + prompts: + system: prompts/card_agent/system.md + resolvers: [customer_name] + tools: [block_card] + + # auto: true runs tool calls without a human approval prompt. Set it only when + # every tool the agent can reach is safe unattended — these two only read. + # card_agent above changes something, so it deliberately does not. + balance_agent: + name: Balances + description: Reports current account balances. + prompts: + system: prompts/balance_agent/system.md + resolvers: [customer_name] + mcps: [bank_core] + auto: true + + transactions_agent: + name: Transactions + description: Lists and explains recent transactions. + prompts: + system: prompts/transactions_agent/system.md + resolvers: [customer_name, today] + mcps: [bank_core] + auto: true + +# Indentation is the hierarchy: a node can only route to its own children. +graph: + concierge: + support_router: + faq_agent: + card_agent: + accounts_router: + balance_agent: + transactions_agent: diff --git a/examples/starter/docker-compose.yml b/examples/starter/docker-compose.yml new file mode 100644 index 00000000..edfd58d4 --- /dev/null +++ b/examples/starter/docker-compose.yml @@ -0,0 +1,33 @@ +services: + mcp: + image: extra:local + entrypoint: ["python", "-m", "mcps.bank.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 diff --git a/examples/starter/mcps/__init__.py b/examples/starter/mcps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/starter/mcps/bank/__init__.py b/examples/starter/mcps/bank/__init__.py new file mode 100644 index 00000000..ef41b781 --- /dev/null +++ b/examples/starter/mcps/bank/__init__.py @@ -0,0 +1,3 @@ +"""The starter example's own MCP server.""" + +SERVER_ID = "bank_core" diff --git a/examples/starter/mcps/bank/data.py b/examples/starter/mcps/bank/data.py new file mode 100644 index 00000000..3767fbf6 --- /dev/null +++ b/examples/starter/mcps/bank/data.py @@ -0,0 +1,42 @@ +"""Fake bank data. Fixed values, no database, no network.""" + +from __future__ import annotations + +# pydantic (which FastMCP uses to build tool schemas from these types) rejects +# typing.TypedDict on Python < 3.12, and the container runs 3.11. +from typing_extensions import TypedDict + + +class Account(TypedDict): + account_id: str + kind: str + balance: float + currency: str + + +class Transaction(TypedDict): + date: str + description: str + amount: float + + +ACCOUNTS: list[Account] = [ + {"account_id": "•••4821", "kind": "checking", "balance": 2480.15, "currency": "USD"}, + {"account_id": "•••9034", "kind": "savings", "balance": 15200.00, "currency": "USD"}, +] + +TRANSACTIONS: list[Transaction] = [ + {"date": "2026-07-21", "description": "Rent", "amount": -1450.00}, + {"date": "2026-07-20", "description": "Salary", "amount": 3200.00}, + {"date": "2026-07-19", "description": "Groceries", "amount": -86.40}, + {"date": "2026-07-18", "description": "Coffee shop", "amount": -4.75}, + {"date": "2026-07-17", "description": "Electricity bill", "amount": -112.30}, +] + + +def list_accounts() -> list[Account]: + return ACCOUNTS + + +def recent_transactions(limit: int = 5) -> list[Transaction]: + return TRANSACTIONS[: max(1, min(limit, len(TRANSACTIONS)))] diff --git a/examples/starter/mcps/bank/server.py b/examples/starter/mcps/bank/server.py new file mode 100644 index 00000000..3f584efe --- /dev/null +++ b/examples/starter/mcps/bank/server.py @@ -0,0 +1,53 @@ +"""A tiny MCP server, so the example needs no external service. + +It speaks the same Streamable HTTP protocol a hosted MCP would, so nothing about +the integration is simulated — only the data behind it. + + python -m mcps.bank.server # from examples/starter +""" + +from __future__ import annotations + +import argparse + +from mcp.server.fastmcp import FastMCP + +from . import data + + +def create_server(*, host: str = "127.0.0.1", port: int = 8765) -> FastMCP: + server = FastMCP( + "Bank Core", + instructions="Read-only account data for the starter example.", + host=host, + port=port, + streamable_http_path="/mcp", + stateless_http=True, + json_response=True, + ) + + @server.tool() + def get_accounts() -> list[data.Account]: + """List the customer's accounts with their current balances.""" + print("[BANK MCP] get_accounts", flush=True) + return data.list_accounts() + + @server.tool() + def get_transactions(limit: int = 5) -> list[data.Transaction]: + """List the customer's most recent transactions, newest first.""" + print(f"[BANK MCP] get_transactions limit={limit}", flush=True) + return data.recent_transactions(limit) + + return server + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the starter example's bank MCP") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + create_server(host=args.host, port=args.port).run(transport="streamable-http") + + +if __name__ == "__main__": + main() diff --git a/examples/starter/plugins/__init__.py b/examples/starter/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/starter/plugins/hooks/__init__.py b/examples/starter/plugins/hooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/starter/plugins/hooks/lifecycle.py b/examples/starter/plugins/hooks/lifecycle.py new file mode 100644 index 00000000..3cc47a7a --- /dev/null +++ b/examples/starter/plugins/hooks/lifecycle.py @@ -0,0 +1,83 @@ +"""Hook plugin — one method per lifecycle point, each one deliberately trivial. + +Run the example and watch these fire in order. Where each method logs, your own +code would do the real work: audit, metrics, redaction, tracing, cache warming. + +Two rules worth keeping: + +* Hooks are trusted application code, but they are not a security boundary for + the model. Never log secrets, tool arguments, tool results or user content. +* Hold no per-request state on the instance — it is shared across every + concurrent request. Read what you need from the event. +""" + +from __future__ import annotations + +import logging + +from agent_engine.runtime.hooks import ( + HookInvocation, + RunContext, + ToolCallContext, + ToolResultContext, +) + +logger = logging.getLogger("starter") + +# Results longer than this are trimmed before they reach the model, so one +# chatty tool cannot blow up the prompt. +_MAX_RESULT_CHARS = 2000 + + +class LifecycleHook: + def __init__(self) -> None: + # Long-lived, request-independent state is fine here: clients, caches, + # configuration. Per-request values are not. + self._runs = 0 + + async def run_started(self, event: HookInvocation) -> RunContext: + """on_run_start — fires once per request, before any routing. + + Whatever this returns replaces the run context for the rest of the run, + which is where a host application injects identity. Returning it + unchanged, as here, is a no-op. + """ + ctx = event.payload_as(RunContext) + self._runs += 1 + logger.info("run started run_id=%s total_runs=%d", ctx.run_id, self._runs) + return ctx + + async def tool_finished(self, event: HookInvocation) -> None: + """after_tool_call — fires after every tool, MCP or local.""" + call = event.payload_as(ToolCallContext) + logger.info( + "tool done agent=%s tool=%s provider=%s status=%s ms=%s", + call.agent_id, + call.tool_name, + call.provider, + call.status, + call.latency_ms, + ) + + async def shorten_result(self, event: HookInvocation) -> ToolResultContext: + """transform_tool_result — the returned value is what the model sees. + + This is the hook to reach for when you need to redact, reformat or cap a + tool result before it enters the conversation. + """ + result = event.payload_as(ToolResultContext) + if len(result.result) <= _MAX_RESULT_CHARS: + return result + logger.info( + "result trimmed tool=%s original_chars=%d kept_chars=%d", + result.tool_name, + len(result.result), + _MAX_RESULT_CHARS, + ) + return result.with_result(result.result[:_MAX_RESULT_CHARS] + "\n\n[trimmed]") + + async def run_failed(self, event: HookInvocation) -> None: + """on_run_error — the error type only. Messages can carry user data.""" + error = event.payload_as(BaseException) + run_id = event.run_context.run_id if event.run_context else None + logger.warning("run failed run_id=%s error_type=%s", run_id, type(error).__name__) diff --git a/examples/starter/plugins/plugins.toml b/examples/starter/plugins/plugins.toml new file mode 100644 index 00000000..a58bd0f7 --- /dev/null +++ b/examples/starter/plugins/plugins.toml @@ -0,0 +1,45 @@ +# plugins.toml — unified manifest for this client extension package. +# +# ONE manifest for ALL client extension code: hooks, resolvers, and tools. +# It is a catalog/generation companion. The runtime reads only [hooks.plugins] +# to resolve managed hook ids; resolvers and tools load by file path. +# `agentctl generate` creates this file if missing and merges new entries in +# without overwriting manual edits. +# +# SECURITY: never put secrets here. Only import refs and metadata — no tokens, +# client secrets, HMAC keys, or Authorization values. + +[package] +name = "plugins" +description = "Client extension package for hooks, resolvers, and tools." + +[paths] +hooks = "plugins.hooks" +resolvers = "plugins.resolvers" +tools = "plugins.tools" + +[hooks] +on_engine_start = [] +on_engine_stop = [] +on_run_start = [] +on_run_end = [] +on_run_error = [] +before_tool_call = [] +after_tool_call = [] +transform_tool_result = [] +on_tool_error = [] +before_mcp_request = [] +after_mcp_response = [] + +[hooks.plugins] +lifecycle = "plugins.hooks.lifecycle:LifecycleHook" + +[resolvers] +balance_agent = "plugins.resolvers.balance_agent:Resolver" +card_agent = "plugins.resolvers.card_agent:Resolver" +faq_agent = "plugins.resolvers.faq_agent:Resolver" +shared = "plugins.resolvers.shared:SharedResolver" +transactions_agent = "plugins.resolvers.transactions_agent:Resolver" + +[tools] +block_card = "plugins.tools.block_card:block_card" diff --git a/examples/starter/plugins/resolvers/balance_agent.py b/examples/starter/plugins/resolvers/balance_agent.py new file mode 100644 index 00000000..2c5ac9e1 --- /dev/null +++ b/examples/starter/plugins/resolvers/balance_agent.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from shared import SharedResolver + + +class Resolver(SharedResolver): + def __init__(self) -> None: + super().__init__() diff --git a/examples/starter/plugins/resolvers/card_agent.py b/examples/starter/plugins/resolvers/card_agent.py new file mode 100644 index 00000000..2c5ac9e1 --- /dev/null +++ b/examples/starter/plugins/resolvers/card_agent.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from shared import SharedResolver + + +class Resolver(SharedResolver): + def __init__(self) -> None: + super().__init__() diff --git a/examples/starter/plugins/resolvers/faq_agent.py b/examples/starter/plugins/resolvers/faq_agent.py new file mode 100644 index 00000000..2c5ac9e1 --- /dev/null +++ b/examples/starter/plugins/resolvers/faq_agent.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from shared import SharedResolver + + +class Resolver(SharedResolver): + def __init__(self) -> None: + super().__init__() diff --git a/examples/starter/plugins/resolvers/shared.py b/examples/starter/plugins/resolvers/shared.py new file mode 100644 index 00000000..1e47ae24 --- /dev/null +++ b/examples/starter/plugins/resolvers/shared.py @@ -0,0 +1,27 @@ +"""Values injected into prompts as {{name}}. + +One method per resolver id declared in agents.yaml. Each takes the run context +and returns a value. Instantiated once, so anything expensive belongs in +__init__ rather than in the methods. +""" + +from __future__ import annotations + +import os +from datetime import date + + +class SharedResolver: + def __init__(self) -> None: + self._bank_name = os.getenv("BANK_NAME", "Northwind Bank") + + def bank_name(self, ctx: dict) -> str: + return self._bank_name + + def customer_name(self, ctx: dict) -> str: + # A real deployment would look this up from the identity in ctx. + # ctx carries run_id, conversation_id, user_id and auth details. + return str(ctx.get("user_id") or "Dana") + + def today(self, ctx: dict) -> str: + return date.today().isoformat() diff --git a/examples/starter/plugins/resolvers/transactions_agent.py b/examples/starter/plugins/resolvers/transactions_agent.py new file mode 100644 index 00000000..2c5ac9e1 --- /dev/null +++ b/examples/starter/plugins/resolvers/transactions_agent.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from shared import SharedResolver + + +class Resolver(SharedResolver): + def __init__(self) -> None: + super().__init__() diff --git a/examples/starter/plugins/tools/block_card.py b/examples/starter/plugins/tools/block_card.py new file mode 100644 index 00000000..d6dec292 --- /dev/null +++ b/examples/starter/plugins/tools/block_card.py @@ -0,0 +1,15 @@ +def block_card(input: dict) -> str: + """Block a card and order a replacement. + + Local tools take one dict and return a string. The dict is whatever the + model decided to pass, so treat every key as untrusted and missing. + """ + last_four = str(input.get("last_four") or "").strip() + if not last_four.isdigit() or len(last_four) != 4: + return "Need the last four digits of the card, e.g. {'last_four': '4821'}." + + # A real implementation would call the card system here. + return ( + f"Card ending {last_four} is blocked. A replacement is on its way and " + f"should arrive within 5 working days." + ) diff --git a/examples/starter/prompts/accounts_router/orchestrator.md b/examples/starter/prompts/accounts_router/orchestrator.md new file mode 100644 index 00000000..950ab802 --- /dev/null +++ b/examples/starter/prompts/accounts_router/orchestrator.md @@ -0,0 +1,11 @@ +# Accounts — routing reference + +> Live prompt is `system.md`; this file documents the policy. + +``` +accounts_router ← you are here +├── balance_agent get_accounts (bank_core MCP) +└── transactions_agent get_transactions (bank_core MCP) +``` + +Balance is "right now"; transactions are "what happened". diff --git a/examples/starter/prompts/accounts_router/system.md b/examples/starter/prompts/accounts_router/system.md new file mode 100644 index 00000000..8ef8e823 --- /dev/null +++ b/examples/starter/prompts/accounts_router/system.md @@ -0,0 +1,9 @@ +You handle account enquiries for a bank. You never answer yourself — you pick +one specialist and return their answer. + +- `balance_agent` — how much is in an account right now. +- `transactions_agent` — recent activity, spending, or a specific charge. + +Rules: +- Always call one specialist. Never answer directly. +- "How much did I spend" is a transactions question, not a balance question. diff --git a/examples/starter/prompts/balance_agent/system.md b/examples/starter/prompts/balance_agent/system.md new file mode 100644 index 00000000..ac8d8237 --- /dev/null +++ b/examples/starter/prompts/balance_agent/system.md @@ -0,0 +1,4 @@ +You report account balances for {{customer_name}}. + +Call `get_accounts` and present the results as a short list: account, type, +balance. Nothing else — no advice, no totals unless asked. diff --git a/examples/starter/prompts/card_agent/system.md b/examples/starter/prompts/card_agent/system.md new file mode 100644 index 00000000..dbb8dc8a --- /dev/null +++ b/examples/starter/prompts/card_agent/system.md @@ -0,0 +1,7 @@ +You help {{customer_name}} with card problems. + +To block a card you need the last four digits. If they have not been given, ask +for them and nothing else. Once you have them, call `block_card`. + +Report exactly what the tool returns. Do not promise a delivery date it did not +mention. diff --git a/examples/starter/prompts/concierge/orchestrator.md b/examples/starter/prompts/concierge/orchestrator.md new file mode 100644 index 00000000..3b0c43bd --- /dev/null +++ b/examples/starter/prompts/concierge/orchestrator.md @@ -0,0 +1,13 @@ +# Concierge — routing reference + +> The engine loads `system.md` as the live prompt and appends its tool-use +> contract. This file documents the policy `system.md` implements — keep the two +> in step. + +``` +concierge ← you are here +├── support_router hours, fees, branches, card problems +└── accounts_router balances, transactions, spending +``` + +One department per request. The concierge never answers the customer directly. diff --git a/examples/starter/prompts/concierge/system.md b/examples/starter/prompts/concierge/system.md new file mode 100644 index 00000000..a4deafb0 --- /dev/null +++ b/examples/starter/prompts/concierge/system.md @@ -0,0 +1,15 @@ +You are the front desk of a bank. You never answer questions yourself. Your only +job is to pick the right department and pass the request to it. + +Route each request to exactly one department: + +- `support_router` — opening hours, fees, branches, general questions, and any + problem with a card (lost, stolen, damaged, needs replacing). +- `accounts_router` — balances, how much money the customer has, recent + transactions, spending, or a specific charge. + +Rules: +- Always call one department. Never answer directly, and never say you cannot + help — one of the two departments covers every request. +- Call only one department per request. +- Return the department's answer as it comes back, without adding commentary. diff --git a/examples/starter/prompts/faq_agent/system.md b/examples/starter/prompts/faq_agent/system.md new file mode 100644 index 00000000..4b54ae12 --- /dev/null +++ b/examples/starter/prompts/faq_agent/system.md @@ -0,0 +1,9 @@ +You answer general questions about {{bank_name}}. + +Facts you may use: +- Branches open 09:00–17:00, Monday to Friday. +- No monthly fee on checking accounts. +- ATM withdrawals are free at any {{bank_name}} machine. + +Answer in one or two sentences. If you do not know, say so and suggest calling a +branch — never invent a fee, rate or policy. diff --git a/examples/starter/prompts/support_router/orchestrator.md b/examples/starter/prompts/support_router/orchestrator.md new file mode 100644 index 00000000..033b593b --- /dev/null +++ b/examples/starter/prompts/support_router/orchestrator.md @@ -0,0 +1,11 @@ +# Support — routing reference + +> Live prompt is `system.md`; this file documents the policy. + +``` +support_router ← you are here +├── faq_agent general questions (auto: true, no approval prompt) +└── card_agent blocks cards via the block_card local tool +``` + +One specialist per request. diff --git a/examples/starter/prompts/support_router/system.md b/examples/starter/prompts/support_router/system.md new file mode 100644 index 00000000..e5f9bd22 --- /dev/null +++ b/examples/starter/prompts/support_router/system.md @@ -0,0 +1,9 @@ +You handle general support for a bank. You never answer yourself — you pick one +specialist and return their answer. + +- `faq_agent` — hours, fees, branches, anything general. +- `card_agent` — a card is lost, stolen, damaged, or needs replacing. + +Rules: +- Always call one specialist. Never answer directly. +- Card problems always go to `card_agent`, even when phrased as a question. diff --git a/examples/starter/prompts/transactions_agent/system.md b/examples/starter/prompts/transactions_agent/system.md new file mode 100644 index 00000000..c143a365 --- /dev/null +++ b/examples/starter/prompts/transactions_agent/system.md @@ -0,0 +1,6 @@ +You explain recent activity for {{customer_name}}. Today is {{today}}. + +Call `get_transactions` and answer the question that was actually asked. If they +want a total, add it up. If they asked about one charge, name that one. + +Show amounts as they are: negative means money left the account.