From b88ed5f171eea7647df54110056df61be4af576a Mon Sep 17 00:00:00 2001 From: Amit Avital Date: Sat, 25 Jul 2026 21:33:04 +0300 Subject: [PATCH 1/4] feat: one-command local dev stack for the flagship example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make up` builds the image and runs the Enterprise Knowledge Assistant in containers, so a new developer needs only Docker and one API key. make up engine -> http://localhost:8090 make up-ui chat UI -> http://localhost:8100/demo make down All services run the same `extra:local` image and differ only in the command, so local runs use the same artifact the release build ships. They share one network, which keeps `http://127.0.0.1:8765/mcp` in the example config true inside containers exactly as it is on a laptop. The example no longer reaches the network: the knowledge agents use the local mock MCP that already ships with it instead of DeepWiki and Context7, and CONTEXT7_API_KEY is gone. The full graph is unchanged — three routers, five agents, five hook points, two local tools, four resolvers, one protected agent — it just runs offline now. `make up` validates the config inside the image before starting, and `make check` fails if `agentctl generate` would write anything, so committed stubs cannot go stale. Co-Authored-By: Claude Opus 5 --- Makefile | 49 ++++++++++++++- .../agents.yaml | 13 +--- .../docker-compose.yml | 33 ++++++++++ .../plugins/hooks/research_hooks.py | 63 ++++++++++--------- tests/cli/test_validate_command.py | 15 +++-- ...rise_knowledge_assistant_research_hooks.py | 62 +++++++++++------- 6 files changed, 161 insertions(+), 74 deletions(-) create mode 100644 examples/enterprise-knowledge-assistant/docker-compose.yml diff --git a/Makefile b/Makefile index eba6e7b6..d43fa31b 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,16 @@ PYTHON ?= python3 SRC := src TESTS := tests -FLAGSHIP := examples/enterprise-knowledge-assistant/agents.yaml +EXAMPLE := examples/enterprise-knowledge-assistant +CONFIG ?= agents.yaml +FLAGSHIP := $(EXAMPLE)/$(CONFIG) + +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,7 +45,18 @@ 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). + @before=$$(find $(EXAMPLE) -type f -not -path '*/__pycache__/*' | sort | xargs shasum | shasum); \ + agentctl generate --config $(FLAGSHIP) >/dev/null; \ + after=$$(find $(EXAMPLE) -type f -not -path '*/__pycache__/*' | sort | xargs shasum | shasum); \ + if [ "$$before" != "$$after" ]; then \ + echo "Generated stubs are stale — run 'agentctl generate --config $(FLAGSHIP)' and commit."; \ + exit 1; \ + fi validate: ## Validate the flagship example offline (no LLM calls, no network, no API keys). agentctl validate $(FLAGSHIP) @@ -47,6 +64,32 @@ validate: ## Validate the flagship example offline (no LLM calls, no network, no 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/agents.yaml b/examples/enterprise-knowledge-assistant/agents.yaml index 5b61a819..a24f63b0 100644 --- a/examples/enterprise-knowledge-assistant/agents.yaml +++ b/examples/enterprise-knowledge-assistant/agents.yaml @@ -19,12 +19,6 @@ mcps: local_knowledge_mcp: url: "http://127.0.0.1:8765/mcp" - deepwiki: - url: "https://mcp.deepwiki.com/mcp" - - context7: - url: "https://mcp.context7.com/mcp" - tools: @@ -64,7 +58,7 @@ hooks: before_mcp_request: - plugin: research_hooks - method: inject_context7_auth + method: inject_mcp_auth after_tool_call: - plugin: research_hooks @@ -145,7 +139,7 @@ agents: - preferred_language mcps: - - deepwiki + - local_knowledge_mcp documentation_agent: @@ -162,7 +156,7 @@ agents: - preferred_language mcps: - - context7 + - local_knowledge_mcp enterprise_docs_agent: @@ -176,7 +170,6 @@ agents: system: prompts/enterprise_docs_agent/system.md mcps: - - context7 - local_knowledge_mcp protected: 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/enterprise-knowledge-assistant/plugins/hooks/research_hooks.py b/examples/enterprise-knowledge-assistant/plugins/hooks/research_hooks.py index 0765bd80..ab21ee3a 100644 --- a/examples/enterprise-knowledge-assistant/plugins/hooks/research_hooks.py +++ b/examples/enterprise-knowledge-assistant/plugins/hooks/research_hooks.py @@ -5,12 +5,12 @@ * ``on_engine_start`` → ``validate_environment``: fail fast at build time if a required credential is missing. -* ``before_mcp_request`` → ``inject_context7_auth``: attach Context7's auth header - — and **only** for the ``context7`` server. DeepWiki is public and stays +* ``before_mcp_request`` → ``inject_mcp_auth``: attach an auth header — and + **only** for servers that declare one. Servers absent from the map stay unauthenticated. * ``after_tool_call`` → ``audit_tool_call``: best-effort audit of *safe* tool metadata (declared ``failure_policy: warn`` in YAML). -* ``transform_tool_result`` → ``truncate_tool_result``: cap oversized DeepWiki +* ``transform_tool_result`` → ``truncate_tool_result``: cap oversized MCP results before they enter the agent conversation. * ``on_run_error`` → ``record_run_failure``: record a run failure by type. @@ -36,20 +36,24 @@ logger = logging.getLogger("research_hooks") -# Cap large DeepWiki MCP results before they are appended to the agent -# conversation, to keep prompts from blowing up. -_DEEPWIKI_SERVER = "deepwiki" +# Cap large MCP results before they are appended to the agent conversation, to +# keep prompts from blowing up. +_KNOWLEDGE_SERVER = "local_knowledge_mcp" _MAX_MCP_RESULT_CHARS = 8000 # Environment variable NAMES the system needs. These are names, not secrets; -# the values are read from os.environ only at runtime and never logged. -_REQUIRED_ENV: tuple[str, ...] = ("ANTHROPIC_API_KEY", "CONTEXT7_API_KEY") +# the values are read from os.environ only at runtime and never logged. Keep +# this in step with `defaults.model.provider` in agents.yaml. +_REQUIRED_ENV: tuple[str, ...] = ("ANTHROPIC_API_KEY",) -# The single authenticated MCP server, its credential env var, and Context7's -# documented header name. DeepWiki is public and intentionally absent here. -_CONTEXT7_SERVER = "context7" -_CONTEXT7_KEY_ENV = "CONTEXT7_API_KEY" -_CONTEXT7_HEADER = "CONTEXT7_API_KEY" # Context7 reads the key from this header +# MCP servers that get an auth header, and where each credential comes from. +# Servers absent from this map stay unauthenticated. The local knowledge server +# needs no credential, so its token is optional: set LOCAL_MCP_TOKEN to watch +# the header get attached, leave it unset and the request passes through. +_MCP_AUTH: dict[str, tuple[str, str]] = { + # server_id: (env var holding the credential, header to send it in) + _KNOWLEDGE_SERVER: ("LOCAL_MCP_TOKEN", "Authorization"), +} class ResearchHooksHook: @@ -73,21 +77,22 @@ async def validate_environment(self, event: HookInvocation) -> None: raise RuntimeError("Missing required environment variables: " + ", ".join(missing)) # -- before_mcp_request ------------------------------------------------- - async def inject_context7_auth(self, event: HookInvocation) -> McpRequestContext: - """Attach Context7's auth header — only for the context7 server. + async def inject_mcp_auth(self, event: HookInvocation) -> McpRequestContext: + """Attach an auth header — only for servers that declare one. - The hook runs for every MCP server, so we gate on ``server_id``: DeepWiki - (public) passes through untouched; context7 gets its key from the - environment, never from YAML. + The hook runs for every MCP server, so we gate on ``server_id``: servers + absent from ``_MCP_AUTH`` pass through untouched. Credentials come from + the environment, never from YAML. """ request = event.payload_as(McpRequestContext) - if request.server_id != _CONTEXT7_SERVER: - return request # e.g. deepwiki — leave unauthenticated - api_key = os.environ.get(_CONTEXT7_KEY_ENV) - if not api_key: - # on_engine_start already validated this; stay safe if it changed. - return request - return request.with_headers({_CONTEXT7_HEADER: api_key}) + auth = _MCP_AUTH.get(request.server_id) + if auth is None: + return request # public server — leave unauthenticated + key_env, header_name = auth + credential = os.environ.get(key_env) + if not credential: + return request # optional credential, not configured + return request.with_headers({header_name: credential}) # -- after_tool_call (failure_policy: warn) ----------------------------- async def audit_tool_call(self, event: HookInvocation) -> None: @@ -114,8 +119,8 @@ async def audit_tool_call(self, event: HookInvocation) -> None: async def truncate_tool_result(self, event: HookInvocation) -> ToolResultContext: """Cap oversized MCP results before they reach the agent conversation. - DeepWiki pages can be very large and blow up the prompt. We truncate - oversized results from the ``deepwiki`` MCP server and log only **safe + Knowledge documents can be very large and blow up the prompt. We + truncate oversized results from that MCP server and log only **safe metadata** (sizes, names, ids) — never the result content. Other MCP servers, local tools, and small results pass through unchanged. Returning the (possibly modified) context is what the engine appends to @@ -125,10 +130,10 @@ async def truncate_tool_result(self, event: HookInvocation) -> ToolResultContext text = result.result if ( result.provider != "mcp" - or result.server_id != _DEEPWIKI_SERVER + or result.server_id != _KNOWLEDGE_SERVER or len(text) <= _MAX_MCP_RESULT_CHARS ): - return result # non-DeepWiki tools / small DeepWiki results: unchanged + return result # other tools / small results: unchanged run_id = event.run_context.run_id if event.run_context else None logger.info( "tool_result truncated run_id=%s tool=%s server=%s original_chars=%d kept_chars=%d", diff --git a/tests/cli/test_validate_command.py b/tests/cli/test_validate_command.py index 64f912a8..9453f54a 100644 --- a/tests/cli/test_validate_command.py +++ b/tests/cli/test_validate_command.py @@ -58,18 +58,17 @@ def test_validate_passes_on_enterprise_knowledge_assistant_demo() -> None: """Smoke test for the richest example (`examples/enterprise-knowledge-assistant/agents.yaml`). This is the Enterprise Knowledge Assistant reference demo — multi-level - orchestration, remote + authenticated + local MCPs, local tools, - shared/agent-scoped resolvers, and all five hook lifecycle points. Nothing - else in the test suite parses it, so it can silently drift out of sync with - the schema. This - is intentionally the same offline check `agentctl validate` runs: no LLM - calls, no MCP network, no tool execution — hooks are imported/instantiated - but their methods are never invoked. + orchestration, a local MCP, local tools, shared/agent-scoped resolvers, a + protected agent, and all five hook lifecycle points. Nothing else in the + test suite parses it, so it can silently drift out of sync with the schema. + This is intentionally the same offline check `agentctl validate` runs: no + LLM calls, no MCP network, no tool execution — hooks are + imported/instantiated but their methods are never invoked. """ result = validate_spec(_ex("enterprise-knowledge-assistant/agents.yaml")) assert result.ok, result.errors assert result.agents == 5 - assert result.mcp_servers == 3 + assert result.mcp_servers == 1 assert result.hooks == 5 assert result.import_roots # "." resolved relative to the spec file diff --git a/tests/runtime/hooks/test_enterprise_knowledge_assistant_research_hooks.py b/tests/runtime/hooks/test_enterprise_knowledge_assistant_research_hooks.py index 9f94d456..6fead466 100644 --- a/tests/runtime/hooks/test_enterprise_knowledge_assistant_research_hooks.py +++ b/tests/runtime/hooks/test_enterprise_knowledge_assistant_research_hooks.py @@ -44,19 +44,19 @@ def _load_research_hooks() -> ModuleType: return module -async def test_research_hook_truncates_large_deepwiki_results( +async def test_research_hook_truncates_large_knowledge_results( caplog: pytest.LogCaptureFixture, ) -> None: module = _load_research_hooks() hook = module.ResearchHooksHook() - raw_result = "DeepWiki body " + ("X" * 9000) + raw_result = "Knowledge body " + ("X" * 9000) event = HookInvocation( hook_point="transform_tool_result", payload=ToolResultContext( agent_id="repository_agent", - tool_name="ask_question", + tool_name="search_internal_documents", provider="mcp", - server_id="deepwiki", + server_id="local_knowledge_mcp", result=raw_result, ), run_context=RunContext(run_id="run-1"), @@ -70,17 +70,17 @@ async def test_research_hook_truncates_large_deepwiki_results( assert "[truncated to 8000" in transformed.result assert "original_chars=" in caplog.text assert "kept_chars=8000" in caplog.text - assert "DeepWiki body" not in caplog.text + assert "Knowledge body" not in caplog.text assert "XXXXX" not in caplog.text -async def test_research_hook_leaves_non_deepwiki_results_unchanged() -> None: +async def test_research_hook_leaves_other_results_unchanged() -> None: module = _load_research_hooks() hook = module.ResearchHooksHook() result = "Z" * 9000 cases: tuple[tuple[Literal["mcp", "local"], str | None], ...] = ( - ("mcp", "context7"), + ("mcp", "some_other_mcp"), ("local", None), ) for provider, server_id in cases: @@ -98,23 +98,23 @@ async def test_research_hook_leaves_non_deepwiki_results_unchanged() -> None: assert transformed is original -async def test_context7_auth_logs_only_when_header_is_attached( +async def test_mcp_auth_logs_only_when_header_is_attached( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: module = _load_research_hooks() hook = module.ResearchHooksHook() - api_key = "secret-context7-key" - monkeypatch.setenv("CONTEXT7_API_KEY", api_key) + credential = "secret-local-mcp-token" + monkeypatch.setenv("LOCAL_MCP_TOKEN", credential) manager = HookManager( { "before_mcp_request": [ LoadedHook( point="before_mcp_request", - ref="research_hooks:inject_context7_auth", - func=hook.inject_context7_auth, + ref="research_hooks:inject_mcp_auth", + func=hook.inject_mcp_auth, plugin="research_hooks", - method="inject_context7_auth", + method="inject_mcp_auth", event_mode=True, ) ] @@ -123,19 +123,33 @@ async def test_context7_auth_logs_only_when_header_is_attached( caplog.clear() with caplog.at_level(logging.DEBUG, logger="agent_engine.runtime.hooks.manager"): - deepwiki = McpRequestContext(server_id="deepwiki", url="https://deepwiki.test/mcp") - unchanged = await manager.run_before_mcp_request(None, deepwiki) + public = McpRequestContext(server_id="public_mcp", url="https://public.test/mcp") + unchanged = await manager.run_before_mcp_request(None, public) - assert unchanged is deepwiki + assert unchanged is public assert caplog.text == "" with caplog.at_level(logging.INFO, logger="agent_engine.runtime.hooks.manager"): - context7 = McpRequestContext(server_id="context7", url="https://context7.test/mcp") - updated = await manager.run_before_mcp_request(None, context7) + knowledge = McpRequestContext( + server_id="local_knowledge_mcp", url="http://127.0.0.1:8765/mcp" + ) + updated = await manager.run_before_mcp_request(None, knowledge) - assert updated.headers["CONTEXT7_API_KEY"] == api_key - assert ( - "hook applied point=before_mcp_request ref=research_hooks:inject_context7_auth" - in caplog.text - ) - assert api_key not in caplog.text + assert updated.headers["Authorization"] == credential + assert "hook applied point=before_mcp_request ref=research_hooks:inject_mcp_auth" in caplog.text + assert credential not in caplog.text + + +async def test_mcp_auth_passes_through_when_credential_is_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The local knowledge server needs no credential, so an unset token is not + an error — the request goes out unauthenticated.""" + module = _load_research_hooks() + hook = module.ResearchHooksHook() + monkeypatch.delenv("LOCAL_MCP_TOKEN", raising=False) + + request = McpRequestContext(server_id="local_knowledge_mcp", url="http://127.0.0.1:8765/mcp") + event = HookInvocation(hook_point="before_mcp_request", payload=request) + + assert await hook.inject_mcp_auth(event) is request From a27d072fc9fb4cc7d0a986e0ba8dcd8d06446646 Mon Sep 17 00:00:00 2001 From: Amit Avital Date: Sat, 25 Jul 2026 22:31:38 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20add=20examples/starter=20=E2=80=94?= =?UTF-8?q?=20a=20small,=20complete=20reference=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bank concierge with fake data: two accounts, five transactions, no database and no internet. It uses every part of the platform with logic simple enough that nothing distracts from the wiring, so it can be the first example someone reads. concierge routes, never answers ├── support_router │ ├── faq_agent auto: true, answers from its prompt │ └── card_agent local tool, requires approval └── accounts_router ├── balance_agent get_accounts (bundled MCP) └── transactions_agent get_transactions (bundled MCP) Three levels of routing, a local Python tool, a bundled MCP server over the real Streamable HTTP transport, shared resolvers, and four hook lifecycle points that only log so the sequence is visible. The model is declared once in `defaults`, so switching provider is a single edit plus the matching key — the YAML names no vendor anywhere else. Read-only agents set `auto: true`; card_agent changes state and so pauses for approval, which shows both behaviours in one system. Runs with `make up EXAMPLE=examples/starter` or directly with `agentctl chat --config`. Co-Authored-By: Claude Opus 5 --- examples/starter/.env.example | 19 +++ examples/starter/README.md | 123 ++++++++++++++ examples/starter/agents.yaml | 155 ++++++++++++++++++ examples/starter/docker-compose.yml | 33 ++++ examples/starter/mcps/__init__.py | 0 examples/starter/mcps/bank/__init__.py | 3 + examples/starter/mcps/bank/data.py | 42 +++++ examples/starter/mcps/bank/server.py | 53 ++++++ examples/starter/plugins/__init__.py | 0 examples/starter/plugins/hooks/__init__.py | 0 examples/starter/plugins/hooks/lifecycle.py | 83 ++++++++++ examples/starter/plugins/plugins.toml | 45 +++++ .../plugins/resolvers/balance_agent.py | 8 + .../starter/plugins/resolvers/card_agent.py | 8 + .../starter/plugins/resolvers/faq_agent.py | 8 + examples/starter/plugins/resolvers/shared.py | 27 +++ .../plugins/resolvers/transactions_agent.py | 8 + examples/starter/plugins/tools/block_card.py | 15 ++ .../prompts/accounts_router/orchestrator.md | 11 ++ .../starter/prompts/accounts_router/system.md | 9 + .../starter/prompts/balance_agent/system.md | 4 + examples/starter/prompts/card_agent/system.md | 7 + .../starter/prompts/concierge/orchestrator.md | 13 ++ examples/starter/prompts/concierge/system.md | 15 ++ examples/starter/prompts/faq_agent/system.md | 9 + .../prompts/support_router/orchestrator.md | 11 ++ .../starter/prompts/support_router/system.md | 9 + .../prompts/transactions_agent/system.md | 6 + 28 files changed, 724 insertions(+) create mode 100644 examples/starter/.env.example create mode 100644 examples/starter/README.md create mode 100644 examples/starter/agents.yaml create mode 100644 examples/starter/docker-compose.yml create mode 100644 examples/starter/mcps/__init__.py create mode 100644 examples/starter/mcps/bank/__init__.py create mode 100644 examples/starter/mcps/bank/data.py create mode 100644 examples/starter/mcps/bank/server.py create mode 100644 examples/starter/plugins/__init__.py create mode 100644 examples/starter/plugins/hooks/__init__.py create mode 100644 examples/starter/plugins/hooks/lifecycle.py create mode 100644 examples/starter/plugins/plugins.toml create mode 100644 examples/starter/plugins/resolvers/balance_agent.py create mode 100644 examples/starter/plugins/resolvers/card_agent.py create mode 100644 examples/starter/plugins/resolvers/faq_agent.py create mode 100644 examples/starter/plugins/resolvers/shared.py create mode 100644 examples/starter/plugins/resolvers/transactions_agent.py create mode 100644 examples/starter/plugins/tools/block_card.py create mode 100644 examples/starter/prompts/accounts_router/orchestrator.md create mode 100644 examples/starter/prompts/accounts_router/system.md create mode 100644 examples/starter/prompts/balance_agent/system.md create mode 100644 examples/starter/prompts/card_agent/system.md create mode 100644 examples/starter/prompts/concierge/orchestrator.md create mode 100644 examples/starter/prompts/concierge/system.md create mode 100644 examples/starter/prompts/faq_agent/system.md create mode 100644 examples/starter/prompts/support_router/orchestrator.md create mode 100644 examples/starter/prompts/support_router/system.md create mode 100644 examples/starter/prompts/transactions_agent/system.md diff --git a/examples/starter/.env.example b/examples/starter/.env.example new file mode 100644 index 00000000..2704d8a0 --- /dev/null +++ b/examples/starter/.env.example @@ -0,0 +1,19 @@ +# Copy to .env in this directory and fill in. .env is gitignored. +# +# The only required value: the credential for the provider set in +# `defaults.model` in agents.yaml. +ANTHROPIC_API_KEY=your-anthropic-key-here + +# Switching provider is one edit in agents.yaml plus the matching key here: +# +# provider: openai -> OPENAI_API_KEY +# provider: gemini -> GEMINI_API_KEY +# provider: bedrock -> AWS credentials + region +# +# A local OpenAI-compatible server (Ollama, LM Studio, vLLM) works too — set +# provider: openai in agents.yaml and point the base URL here: +# OPENAI_API_KEY=ollama +# OPENAI_BASE_URL=http://127.0.0.1:11434/v1 + +# Optional — used 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..60d5e4c0 --- /dev/null +++ b/examples/starter/README.md @@ -0,0 +1,123 @@ +# 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. +One API key is the only thing you need. + +## Run it + +From the repository root: + +```bash +make up EXAMPLE=examples/starter +``` + +First run creates `.env` from `.env.example` and stops so you can add your key. +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 EXAMPLE=examples/starter +make inspect EXAMPLE=examples/starter +``` + +## 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 references a provider, so it's one edit: + +```yaml +defaults: + model: + provider: anthropic # anthropic | openai | gemini | bedrock + name: claude-haiku-4-5 + temperature: 0.0 +``` + +A local OpenAI-compatible server works too — set `provider: openai`, the model +name Ollama reports, and in `.env`: + +``` +OPENAI_API_KEY=ollama +OPENAI_BASE_URL=http://127.0.0.1:11434/v1 +``` + +## 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..8d6ae459 --- /dev/null +++ b/examples/starter/agents.yaml @@ -0,0 +1,155 @@ +# Starter example — a small bank concierge. +# +# Everything here is fake: three accounts, five transactions, no database and no +# internet. The point is the wiring, not the banking. Read it top to bottom and +# you have seen every part of a system definition. + +system: + name: Bank Concierge + +# The model is declared once, here. Every orchestrator and agent inherits it, +# so switching provider is a single edit. Any node may override it by adding its +# own `model:` block. +defaults: + model: + provider: anthropic + name: claude-haiku-4-5 + temperature: 0.0 + +# Guard rails for a single request. Without these a confused model can loop. +execution: + max_iterations: 12 + max_tool_calls: 8 + max_tool_calls_per_agent: 3 + max_child_agent_calls: 4 + allow_duplicate_tool_calls: false + +# Tools served over MCP. This one ships with the example (see mcps/bank) and +# speaks the same Streamable HTTP protocol a remote MCP would. +mcps: + bank_core: + url: "http://127.0.0.1:8765/mcp" + +# Tools implemented in Python, in plugins/tools/.py. Each takes one dict +# and returns a string. The description is what the model sees when deciding +# whether to call it — write it for the model, not for you. +tools: + block_card: + description: Block a bank card and issue a replacement. Needs the last four digits. + +# Values injected into prompts as {{name}}. `shared` resolvers are defined once +# in plugins/resolvers/shared.py; `agent` scope means the value is resolved per +# agent, so two agents can see different values for the same name. +resolvers: + customer_name: + scope: shared + bank_name: + scope: shared + today: + scope: shared + +# Where Python plugins are imported from, relative to this file. +plugins: + import_roots: ["."] + +# Hooks run at fixed points in the request lifecycle. These only log, so you can +# watch the sequence in the terminal and see where your own code would go. +# +# failure_policy: warn -> a raising hook is logged and the run continues. +# (default) fail -> a raising hook fails the run. Use it for real checks. +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 route. They never answer the user themselves — they pick a child +# and pass the work down. `orchestrator` is the routing prompt, `system` is the +# persona. +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 do the work. Each has a prompt, and optionally tools, MCP servers and +# resolvers. +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 means this agent's tool calls run without a human approval + # prompt. Only set it when every tool it can reach is safe to run + # unattended — this one has no tools at all. + 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] + + # Both account agents only read. Reading is safe to do unattended, so they + # set auto: true. card_agent above changes something, so it deliberately does + # not — watch the difference when you ask to block a card. + 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 + +# The graph. Indentation is the hierarchy: a node's children are the only nodes +# it can route to. The root is the entry point for every request. +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. From be900bfd2b44e86219ec90d11e8d881731cac0da Mon Sep 17 00:00:00 2001 From: Amit Avital Date: Sat, 25 Jul 2026 22:51:23 +0300 Subject: [PATCH 3/4] revert: leave the enterprise example exactly as it was MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local-dev work had rewritten the flagship example to drop its remote MCPs, rename a hook and relax its credential check. That was never the goal — examples/starter now covers the "runs with one key, no internet" case, so the flagship goes back to demonstrating remote and authenticated MCPs as before. Restored to origin/main: examples/enterprise-knowledge-assistant/agents.yaml examples/enterprise-knowledge-assistant/plugins/hooks/research_hooks.py tests/cli/test_validate_command.py tests/runtime/hooks/test_enterprise_knowledge_assistant_research_hooks.py `make up` now defaults to examples/starter, which needs one API key and no network; the flagship still runs with EXAMPLE=examples/enterprise-knowledge-assistant. `make validate` and the generate drift check now cover every example rather than just one, so neither can rot. Co-Authored-By: Claude Opus 5 --- Makefile | 30 +++++---- .../agents.yaml | 13 +++- .../plugins/hooks/research_hooks.py | 63 +++++++++---------- tests/cli/test_validate_command.py | 15 ++--- ...rise_knowledge_assistant_research_hooks.py | 62 +++++++----------- 5 files changed, 90 insertions(+), 93 deletions(-) diff --git a/Makefile b/Makefile index d43fa31b..24b27ee6 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,16 @@ PYTHON ?= python3 SRC := src TESTS := tests -EXAMPLE := examples/enterprise-knowledge-assistant +# 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 @@ -50,16 +56,18 @@ check: lint typecheck test generate-check ## Quality gate: lint + typecheck + te # 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). - @before=$$(find $(EXAMPLE) -type f -not -path '*/__pycache__/*' | sort | xargs shasum | shasum); \ - agentctl generate --config $(FLAGSHIP) >/dev/null; \ - after=$$(find $(EXAMPLE) -type f -not -path '*/__pycache__/*' | sort | xargs shasum | shasum); \ - if [ "$$before" != "$$after" ]; then \ - echo "Generated stubs are stale — run 'agentctl generate --config $(FLAGSHIP)' and commit."; \ - exit 1; \ - fi - -validate: ## Validate the flagship example offline (no LLM calls, no network, no API keys). - agentctl validate $(FLAGSHIP) + @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 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) diff --git a/examples/enterprise-knowledge-assistant/agents.yaml b/examples/enterprise-knowledge-assistant/agents.yaml index a24f63b0..5b61a819 100644 --- a/examples/enterprise-knowledge-assistant/agents.yaml +++ b/examples/enterprise-knowledge-assistant/agents.yaml @@ -19,6 +19,12 @@ mcps: local_knowledge_mcp: url: "http://127.0.0.1:8765/mcp" + deepwiki: + url: "https://mcp.deepwiki.com/mcp" + + context7: + url: "https://mcp.context7.com/mcp" + tools: @@ -58,7 +64,7 @@ hooks: before_mcp_request: - plugin: research_hooks - method: inject_mcp_auth + method: inject_context7_auth after_tool_call: - plugin: research_hooks @@ -139,7 +145,7 @@ agents: - preferred_language mcps: - - local_knowledge_mcp + - deepwiki documentation_agent: @@ -156,7 +162,7 @@ agents: - preferred_language mcps: - - local_knowledge_mcp + - context7 enterprise_docs_agent: @@ -170,6 +176,7 @@ agents: system: prompts/enterprise_docs_agent/system.md mcps: + - context7 - local_knowledge_mcp protected: true diff --git a/examples/enterprise-knowledge-assistant/plugins/hooks/research_hooks.py b/examples/enterprise-knowledge-assistant/plugins/hooks/research_hooks.py index ab21ee3a..0765bd80 100644 --- a/examples/enterprise-knowledge-assistant/plugins/hooks/research_hooks.py +++ b/examples/enterprise-knowledge-assistant/plugins/hooks/research_hooks.py @@ -5,12 +5,12 @@ * ``on_engine_start`` → ``validate_environment``: fail fast at build time if a required credential is missing. -* ``before_mcp_request`` → ``inject_mcp_auth``: attach an auth header — and - **only** for servers that declare one. Servers absent from the map stay +* ``before_mcp_request`` → ``inject_context7_auth``: attach Context7's auth header + — and **only** for the ``context7`` server. DeepWiki is public and stays unauthenticated. * ``after_tool_call`` → ``audit_tool_call``: best-effort audit of *safe* tool metadata (declared ``failure_policy: warn`` in YAML). -* ``transform_tool_result`` → ``truncate_tool_result``: cap oversized MCP +* ``transform_tool_result`` → ``truncate_tool_result``: cap oversized DeepWiki results before they enter the agent conversation. * ``on_run_error`` → ``record_run_failure``: record a run failure by type. @@ -36,24 +36,20 @@ logger = logging.getLogger("research_hooks") -# Cap large MCP results before they are appended to the agent conversation, to -# keep prompts from blowing up. -_KNOWLEDGE_SERVER = "local_knowledge_mcp" +# Cap large DeepWiki MCP results before they are appended to the agent +# conversation, to keep prompts from blowing up. +_DEEPWIKI_SERVER = "deepwiki" _MAX_MCP_RESULT_CHARS = 8000 # Environment variable NAMES the system needs. These are names, not secrets; -# the values are read from os.environ only at runtime and never logged. Keep -# this in step with `defaults.model.provider` in agents.yaml. -_REQUIRED_ENV: tuple[str, ...] = ("ANTHROPIC_API_KEY",) +# the values are read from os.environ only at runtime and never logged. +_REQUIRED_ENV: tuple[str, ...] = ("ANTHROPIC_API_KEY", "CONTEXT7_API_KEY") -# MCP servers that get an auth header, and where each credential comes from. -# Servers absent from this map stay unauthenticated. The local knowledge server -# needs no credential, so its token is optional: set LOCAL_MCP_TOKEN to watch -# the header get attached, leave it unset and the request passes through. -_MCP_AUTH: dict[str, tuple[str, str]] = { - # server_id: (env var holding the credential, header to send it in) - _KNOWLEDGE_SERVER: ("LOCAL_MCP_TOKEN", "Authorization"), -} +# The single authenticated MCP server, its credential env var, and Context7's +# documented header name. DeepWiki is public and intentionally absent here. +_CONTEXT7_SERVER = "context7" +_CONTEXT7_KEY_ENV = "CONTEXT7_API_KEY" +_CONTEXT7_HEADER = "CONTEXT7_API_KEY" # Context7 reads the key from this header class ResearchHooksHook: @@ -77,22 +73,21 @@ async def validate_environment(self, event: HookInvocation) -> None: raise RuntimeError("Missing required environment variables: " + ", ".join(missing)) # -- before_mcp_request ------------------------------------------------- - async def inject_mcp_auth(self, event: HookInvocation) -> McpRequestContext: - """Attach an auth header — only for servers that declare one. + async def inject_context7_auth(self, event: HookInvocation) -> McpRequestContext: + """Attach Context7's auth header — only for the context7 server. - The hook runs for every MCP server, so we gate on ``server_id``: servers - absent from ``_MCP_AUTH`` pass through untouched. Credentials come from - the environment, never from YAML. + The hook runs for every MCP server, so we gate on ``server_id``: DeepWiki + (public) passes through untouched; context7 gets its key from the + environment, never from YAML. """ request = event.payload_as(McpRequestContext) - auth = _MCP_AUTH.get(request.server_id) - if auth is None: - return request # public server — leave unauthenticated - key_env, header_name = auth - credential = os.environ.get(key_env) - if not credential: - return request # optional credential, not configured - return request.with_headers({header_name: credential}) + if request.server_id != _CONTEXT7_SERVER: + return request # e.g. deepwiki — leave unauthenticated + api_key = os.environ.get(_CONTEXT7_KEY_ENV) + if not api_key: + # on_engine_start already validated this; stay safe if it changed. + return request + return request.with_headers({_CONTEXT7_HEADER: api_key}) # -- after_tool_call (failure_policy: warn) ----------------------------- async def audit_tool_call(self, event: HookInvocation) -> None: @@ -119,8 +114,8 @@ async def audit_tool_call(self, event: HookInvocation) -> None: async def truncate_tool_result(self, event: HookInvocation) -> ToolResultContext: """Cap oversized MCP results before they reach the agent conversation. - Knowledge documents can be very large and blow up the prompt. We - truncate oversized results from that MCP server and log only **safe + DeepWiki pages can be very large and blow up the prompt. We truncate + oversized results from the ``deepwiki`` MCP server and log only **safe metadata** (sizes, names, ids) — never the result content. Other MCP servers, local tools, and small results pass through unchanged. Returning the (possibly modified) context is what the engine appends to @@ -130,10 +125,10 @@ async def truncate_tool_result(self, event: HookInvocation) -> ToolResultContext text = result.result if ( result.provider != "mcp" - or result.server_id != _KNOWLEDGE_SERVER + or result.server_id != _DEEPWIKI_SERVER or len(text) <= _MAX_MCP_RESULT_CHARS ): - return result # other tools / small results: unchanged + return result # non-DeepWiki tools / small DeepWiki results: unchanged run_id = event.run_context.run_id if event.run_context else None logger.info( "tool_result truncated run_id=%s tool=%s server=%s original_chars=%d kept_chars=%d", diff --git a/tests/cli/test_validate_command.py b/tests/cli/test_validate_command.py index 9453f54a..64f912a8 100644 --- a/tests/cli/test_validate_command.py +++ b/tests/cli/test_validate_command.py @@ -58,17 +58,18 @@ def test_validate_passes_on_enterprise_knowledge_assistant_demo() -> None: """Smoke test for the richest example (`examples/enterprise-knowledge-assistant/agents.yaml`). This is the Enterprise Knowledge Assistant reference demo — multi-level - orchestration, a local MCP, local tools, shared/agent-scoped resolvers, a - protected agent, and all five hook lifecycle points. Nothing else in the - test suite parses it, so it can silently drift out of sync with the schema. - This is intentionally the same offline check `agentctl validate` runs: no - LLM calls, no MCP network, no tool execution — hooks are - imported/instantiated but their methods are never invoked. + orchestration, remote + authenticated + local MCPs, local tools, + shared/agent-scoped resolvers, and all five hook lifecycle points. Nothing + else in the test suite parses it, so it can silently drift out of sync with + the schema. This + is intentionally the same offline check `agentctl validate` runs: no LLM + calls, no MCP network, no tool execution — hooks are imported/instantiated + but their methods are never invoked. """ result = validate_spec(_ex("enterprise-knowledge-assistant/agents.yaml")) assert result.ok, result.errors assert result.agents == 5 - assert result.mcp_servers == 1 + assert result.mcp_servers == 3 assert result.hooks == 5 assert result.import_roots # "." resolved relative to the spec file diff --git a/tests/runtime/hooks/test_enterprise_knowledge_assistant_research_hooks.py b/tests/runtime/hooks/test_enterprise_knowledge_assistant_research_hooks.py index 6fead466..9f94d456 100644 --- a/tests/runtime/hooks/test_enterprise_knowledge_assistant_research_hooks.py +++ b/tests/runtime/hooks/test_enterprise_knowledge_assistant_research_hooks.py @@ -44,19 +44,19 @@ def _load_research_hooks() -> ModuleType: return module -async def test_research_hook_truncates_large_knowledge_results( +async def test_research_hook_truncates_large_deepwiki_results( caplog: pytest.LogCaptureFixture, ) -> None: module = _load_research_hooks() hook = module.ResearchHooksHook() - raw_result = "Knowledge body " + ("X" * 9000) + raw_result = "DeepWiki body " + ("X" * 9000) event = HookInvocation( hook_point="transform_tool_result", payload=ToolResultContext( agent_id="repository_agent", - tool_name="search_internal_documents", + tool_name="ask_question", provider="mcp", - server_id="local_knowledge_mcp", + server_id="deepwiki", result=raw_result, ), run_context=RunContext(run_id="run-1"), @@ -70,17 +70,17 @@ async def test_research_hook_truncates_large_knowledge_results( assert "[truncated to 8000" in transformed.result assert "original_chars=" in caplog.text assert "kept_chars=8000" in caplog.text - assert "Knowledge body" not in caplog.text + assert "DeepWiki body" not in caplog.text assert "XXXXX" not in caplog.text -async def test_research_hook_leaves_other_results_unchanged() -> None: +async def test_research_hook_leaves_non_deepwiki_results_unchanged() -> None: module = _load_research_hooks() hook = module.ResearchHooksHook() result = "Z" * 9000 cases: tuple[tuple[Literal["mcp", "local"], str | None], ...] = ( - ("mcp", "some_other_mcp"), + ("mcp", "context7"), ("local", None), ) for provider, server_id in cases: @@ -98,23 +98,23 @@ async def test_research_hook_leaves_other_results_unchanged() -> None: assert transformed is original -async def test_mcp_auth_logs_only_when_header_is_attached( +async def test_context7_auth_logs_only_when_header_is_attached( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: module = _load_research_hooks() hook = module.ResearchHooksHook() - credential = "secret-local-mcp-token" - monkeypatch.setenv("LOCAL_MCP_TOKEN", credential) + api_key = "secret-context7-key" + monkeypatch.setenv("CONTEXT7_API_KEY", api_key) manager = HookManager( { "before_mcp_request": [ LoadedHook( point="before_mcp_request", - ref="research_hooks:inject_mcp_auth", - func=hook.inject_mcp_auth, + ref="research_hooks:inject_context7_auth", + func=hook.inject_context7_auth, plugin="research_hooks", - method="inject_mcp_auth", + method="inject_context7_auth", event_mode=True, ) ] @@ -123,33 +123,19 @@ async def test_mcp_auth_logs_only_when_header_is_attached( caplog.clear() with caplog.at_level(logging.DEBUG, logger="agent_engine.runtime.hooks.manager"): - public = McpRequestContext(server_id="public_mcp", url="https://public.test/mcp") - unchanged = await manager.run_before_mcp_request(None, public) + deepwiki = McpRequestContext(server_id="deepwiki", url="https://deepwiki.test/mcp") + unchanged = await manager.run_before_mcp_request(None, deepwiki) - assert unchanged is public + assert unchanged is deepwiki assert caplog.text == "" with caplog.at_level(logging.INFO, logger="agent_engine.runtime.hooks.manager"): - knowledge = McpRequestContext( - server_id="local_knowledge_mcp", url="http://127.0.0.1:8765/mcp" - ) - updated = await manager.run_before_mcp_request(None, knowledge) - - assert updated.headers["Authorization"] == credential - assert "hook applied point=before_mcp_request ref=research_hooks:inject_mcp_auth" in caplog.text - assert credential not in caplog.text - - -async def test_mcp_auth_passes_through_when_credential_is_unset( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The local knowledge server needs no credential, so an unset token is not - an error — the request goes out unauthenticated.""" - module = _load_research_hooks() - hook = module.ResearchHooksHook() - monkeypatch.delenv("LOCAL_MCP_TOKEN", raising=False) + context7 = McpRequestContext(server_id="context7", url="https://context7.test/mcp") + updated = await manager.run_before_mcp_request(None, context7) - request = McpRequestContext(server_id="local_knowledge_mcp", url="http://127.0.0.1:8765/mcp") - event = HookInvocation(hook_point="before_mcp_request", payload=request) - - assert await hook.inject_mcp_auth(event) is request + assert updated.headers["CONTEXT7_API_KEY"] == api_key + assert ( + "hook applied point=before_mcp_request ref=research_hooks:inject_context7_auth" + in caplog.text + ) + assert api_key not in caplog.text From d9ee9be0ddcbebd52352be2c0623cb0ef7bea1b1 Mon Sep 17 00:00:00 2001 From: Amit Avital Date: Sat, 25 Jul 2026 23:15:28 +0300 Subject: [PATCH 4/4] refactor(starter): default to a local Ollama model, trim the YAML comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example now runs with no paid API key: provider `openai` pointed at a local Ollama server through the OpenAI-compatible API. `.env.example` ships working defaults, including host.docker.internal so the containers reach Ollama on the host. Comments in agents.yaml are cut back to the places where the config does something a reader cannot infer: * what `auto: true` costs — it skips the human approval prompt * what the MCP block becomes in production, and where the credential goes (a before_mcp_request hook, never the YAML) * when a local tool is the right choice instead of an MCP * that `failure_policy: warn` is the exception, not the default * that graph indentation is what a node is allowed to reach Everything that merely restated the key names is gone. Co-Authored-By: Claude Opus 5 --- examples/starter/.env.example | 32 ++++++++++--------- examples/starter/README.md | 38 +++++++++++++--------- examples/starter/agents.yaml | 59 ++++++++++++----------------------- 3 files changed, 62 insertions(+), 67 deletions(-) diff --git a/examples/starter/.env.example b/examples/starter/.env.example index 2704d8a0..5e8733aa 100644 --- a/examples/starter/.env.example +++ b/examples/starter/.env.example @@ -1,19 +1,23 @@ -# Copy to .env in this directory and fill in. .env is gitignored. +# Copy to .env in this directory. .env is gitignored. # -# The only required value: the credential for the provider set in -# `defaults.model` in agents.yaml. -ANTHROPIC_API_KEY=your-anthropic-key-here - -# Switching provider is one edit in agents.yaml plus the matching key here: +# Defaults to a local Ollama model, so the example runs with no paid API key: +# +# ollama pull qwen2.5:14b # -# provider: openai -> OPENAI_API_KEY -# provider: gemini -> GEMINI_API_KEY -# provider: bedrock -> AWS credentials + region +# 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: # -# A local OpenAI-compatible server (Ollama, LM Studio, vLLM) works too — set -# provider: openai in agents.yaml and point the base URL here: -# OPENAI_API_KEY=ollama -# OPENAI_BASE_URL=http://127.0.0.1:11434/v1 +# 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 — used by the bank_name resolver. +# Optional — read by the bank_name resolver. # BANK_NAME=Northwind Bank diff --git a/examples/starter/README.md b/examples/starter/README.md index 60d5e4c0..a40bce4b 100644 --- a/examples/starter/README.md +++ b/examples/starter/README.md @@ -4,18 +4,24 @@ 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. -One API key is the only thing you need. +It runs against a local Ollama model by default, so it needs no paid API key at +all. ## Run it -From the repository root: +```bash +ollama pull qwen2.5:14b +``` + +Then from the repository root: ```bash -make up EXAMPLE=examples/starter +make up ``` -First run creates `.env` from `.env.example` and stops so you can add your key. -Then: +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 @@ -33,8 +39,8 @@ agentctl chat --config examples/starter/agents.yaml Offline checks need no key and no MCP: ```bash -make validate EXAMPLE=examples/starter -make inspect EXAMPLE=examples/starter +make validate # every example, offline +make inspect # agents, MCPs, hooks, tools ``` ## Try these @@ -83,24 +89,28 @@ 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 references a provider, so it's one edit: +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: anthropic # anthropic | openai | gemini | bedrock - name: claude-haiku-4-5 + provider: openai # anthropic | openai | gemini | bedrock + name: qwen2.5:14b temperature: 0.0 ``` -A local OpenAI-compatible server works too — set `provider: openai`, the model -name Ollama reports, and in `.env`: - ``` OPENAI_API_KEY=ollama -OPENAI_BASE_URL=http://127.0.0.1:11434/v1 +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. diff --git a/examples/starter/agents.yaml b/examples/starter/agents.yaml index 8d6ae459..a1f09df7 100644 --- a/examples/starter/agents.yaml +++ b/examples/starter/agents.yaml @@ -1,22 +1,15 @@ -# Starter example — a small bank concierge. -# -# Everything here is fake: three accounts, five transactions, no database and no -# internet. The point is the wiring, not the banking. Read it top to bottom and -# you have seen every part of a system definition. - system: name: Bank Concierge -# The model is declared once, here. Every orchestrator and agent inherits it, -# so switching provider is a single edit. Any node may override it by adding its -# own `model:` block. +# 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: anthropic - name: claude-haiku-4-5 + provider: openai + name: qwen2.5:14b temperature: 0.0 -# Guard rails for a single request. Without these a confused model can loop. execution: max_iterations: 12 max_tool_calls: 8 @@ -24,22 +17,22 @@ execution: max_child_agent_calls: 4 allow_duplicate_tool_calls: false -# Tools served over MCP. This one ships with the example (see mcps/bank) and -# speaks the same Streamable HTTP protocol a remote MCP would. 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 implemented in Python, in plugins/tools/.py. Each takes one dict -# and returns a string. The description is what the model sees when deciding -# whether to call it — write it for the model, not for you. 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. -# Values injected into prompts as {{name}}. `shared` resolvers are defined once -# in plugins/resolvers/shared.py; `agent` scope means the value is resolved per -# agent, so two agents can see different values for the same name. +# Injected into prompts as {{name}}. Implemented in plugins/resolvers/. resolvers: customer_name: scope: shared @@ -48,15 +41,12 @@ resolvers: today: scope: shared -# Where Python plugins are imported from, relative to this file. plugins: import_roots: ["."] -# Hooks run at fixed points in the request lifecycle. These only log, so you can -# watch the sequence in the terminal and see where your own code would go. -# -# failure_policy: warn -> a raising hook is logged and the run continues. -# (default) fail -> a raising hook fails the run. Use it for real checks. +# 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 @@ -75,9 +65,6 @@ hooks: method: run_failed failure_policy: warn -# Orchestrators route. They never answer the user themselves — they pick a child -# and pass the work down. `orchestrator` is the routing prompt, `system` is the -# persona. orchestrators: concierge: name: Concierge @@ -100,8 +87,6 @@ orchestrators: orchestrator: prompts/accounts_router/orchestrator.md system: prompts/accounts_router/system.md -# Agents do the work. Each has a prompt, and optionally tools, MCP servers and -# resolvers. agents: faq_agent: name: FAQ @@ -109,9 +94,6 @@ agents: prompts: system: prompts/faq_agent/system.md resolvers: [bank_name] - # auto: true means this agent's tool calls run without a human approval - # prompt. Only set it when every tool it can reach is safe to run - # unattended — this one has no tools at all. auto: true card_agent: @@ -122,9 +104,9 @@ agents: resolvers: [customer_name] tools: [block_card] - # Both account agents only read. Reading is safe to do unattended, so they - # set auto: true. card_agent above changes something, so it deliberately does - # not — watch the difference when you ask to block a 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. @@ -143,8 +125,7 @@ agents: mcps: [bank_core] auto: true -# The graph. Indentation is the hierarchy: a node's children are the only nodes -# it can route to. The root is the entry point for every request. +# Indentation is the hierarchy: a node can only route to its own children. graph: concierge: support_router: