diff --git a/.github/workflows/quickstart-tabs-check.yml b/.github/workflows/quickstart-tabs-check.yml new file mode 100644 index 00000000..73a6b408 --- /dev/null +++ b/.github/workflows/quickstart-tabs-check.yml @@ -0,0 +1,49 @@ +name: Quick-start Tabs Drift Check + +# AAASM-4513: the per-framework tab block in docs/quick-start.md §3 is generated +# from the vendored quickstart_snippets/ (a committed copy of the examples repo's +# quick-start regions — examples is a separate repo, so the doc build stays +# hermetic). This gate re-runs the generator and fails if the committed doc no +# longer matches the vendored snippets + manifest, so the tabs can never drift. + +on: + pull_request: + paths: + - "quickstart_snippets/**" + - "scripts/generate_quickstart_tabs.py" + - "docs/quick-start.md" + - ".github/workflows/quickstart-tabs-check.yml" + push: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + drift-check: + name: quick-start tabs drift + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + + - name: Regenerate quick-start framework tabs + run: python scripts/generate_quickstart_tabs.py + + - name: Fail if regeneration produced a diff + run: | + if ! git diff --exit-code; then + echo "::error::docs/quick-start.md §3 is out of sync with quickstart_snippets/." + echo "::error::Snippet source: quickstart_snippets/ (vendored from ai-agent-assembly/examples, AAASM-4512)." + echo "::error::Run the generator locally and commit the result:" + echo "::error:: python scripts/generate_quickstart_tabs.py" + exit 1 + fi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b3b8152b..531239b1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,9 @@ -exclude: '.github/|docs/' +# quickstart_snippets/ holds verbatim, vendored copies of the examples repo's +# quick-start regions (AAASM-4513); several are partial governance slices (e.g. +# a trailing `try:`/`with … as ctx:` with no body) that are valid as doc +# excerpts but not as standalone modules. Formatting/parsing them would rewrite +# the vendored copy and break the drift check, so exclude the dir from all hooks. +exclude: '.github/|docs/|quickstart_snippets/' repos: - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/design/validation/AAASM-4513/00-tab-strip.png b/design/validation/AAASM-4513/00-tab-strip.png new file mode 100644 index 00000000..6d942705 Binary files /dev/null and b/design/validation/AAASM-4513/00-tab-strip.png differ diff --git a/design/validation/AAASM-4513/01-langchain-panel.png b/design/validation/AAASM-4513/01-langchain-panel.png new file mode 100644 index 00000000..e8baff77 Binary files /dev/null and b/design/validation/AAASM-4513/01-langchain-panel.png differ diff --git a/design/validation/AAASM-4513/02-pydantic-ai-panel.png b/design/validation/AAASM-4513/02-pydantic-ai-panel.png new file mode 100644 index 00000000..956d9ac9 Binary files /dev/null and b/design/validation/AAASM-4513/02-pydantic-ai-panel.png differ diff --git a/design/validation/AAASM-4513/03-crewai-panel.png b/design/validation/AAASM-4513/03-crewai-panel.png new file mode 100644 index 00000000..3cf895a0 Binary files /dev/null and b/design/validation/AAASM-4513/03-crewai-panel.png differ diff --git a/design/validation/AAASM-4513/04-custom-panel.png b/design/validation/AAASM-4513/04-custom-panel.png new file mode 100644 index 00000000..6094b994 Binary files /dev/null and b/design/validation/AAASM-4513/04-custom-panel.png differ diff --git a/docs/quick-start.md b/docs/quick-start.md index d926435d..9c7a2c9f 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -1,8 +1,9 @@ # Quick Start -Govern your first agent in about five minutes. By the end you'll have a LangChain agent whose -tool calls pass through the Agent Assembly policy gate — and it runs **offline**, against a -mock LLM, so you need no API keys and no network access to the outside world. +Govern your first agent in about five minutes. By the end you'll have an agent — in whichever +framework you already use — whose tool calls pass through the Agent Assembly policy gate, and it +runs **offline** against a local policy, so you need no API keys and no network access to the +outside world. ## 1. Install @@ -61,60 +62,393 @@ local default port). ## 3. Govern your first agent -This example imports LangChain alongside the SDK, so install both: - -```bash -{{ aa.commands.install_pip }} langchain langchain-classic langchain-community -``` - -Then run: - -```python -from langchain_classic.agents import AgentExecutor, create_react_agent -from langchain_classic.tools import Tool -from langchain_community.llms import FakeListLLM -from langchain_core.prompts import PromptTemplate - -from agent_assembly import init_assembly - -with init_assembly( - gateway_url="http://localhost:7391", - api_key="dev-key", - agent_id="quickstart-agent", - mode="sdk-only", -): - llm = FakeListLLM(responses=[ - "Thought: I should look up the user.\nAction: whoami\nAction Input: alice\n", - "Thought: I have the answer.\nFinal Answer: alice is in engineering\n", - ]) - tools = [Tool(name="whoami", func=lambda name: f"{name} is in engineering", description="who")] - prompt = PromptTemplate.from_template( - "Use the tools.\n{tools}\nTool names: {tool_names}\nQ: {input}\n{agent_scratchpad}" - ) - executor = AgentExecutor(agent=create_react_agent(llm, tools, prompt), tools=tools, max_iterations=2) - print(executor.invoke({"input": "Which team is alice on?"})["output"]) -``` +Agent Assembly governs whichever agent framework you already use. Pick your framework below — +each tab is the **governance-wiring slice** (`init_assembly()` plus that framework's adapter +hookup) taken verbatim from that framework's runnable example in the +[examples repo](https://github.com/ai-agent-assembly/examples/tree/master/python). Copy the +full, runnable script — imports, tools, and the agent run — from the linked example; the slice +below is the part that wires in governance. + +Every example runs **offline** in `mode="sdk-only"` against a local policy, so you can try it +with no API keys and no outbound network. + + + +=== "Agno" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="agno-demo-agent", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + + print("Policy rules (local simulation of gateway policy):") + print(" DENY — execute_sql, run_shell_command (arbitrary execution)") + print(" ALLOW — everything else") + print() + + # In production init_assembly() auto-detects Agno and wires the live + # runtime as the interceptor automatically. In this offline sdk-only demo + # there is no live runtime, so init_assembly() installs a no-op hook; we + # revert it and re-apply the hook wired to our local policy so the demo + # shows real allow/deny decisions without a gateway. (The patch is + # idempotent, so we must revert the no-op hook before installing ours.) + AgnoPatch(policy).revert() + patch = AgnoPatch(policy) + assert patch.apply(), ( + "Agno governance hook did not install — is agno importable?" + ) + ``` + +=== "AutoGen" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="autogen-demo-agent", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + ``` + +=== "CrewAI" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="crewai-research-crew", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} ({mode_label})") + print() + + print("Crew members:") + for member in CREW: + print(f" • {member.name:<11} — {member.role}") + print() + + print("Crew policy (local simulation of gateway policy):") + print(" APPROVAL — any agent attempting a file write must be approved") + print(f" BUDGET — ${DAILY_BUDGET_USD:.2f} / day, shared across all agents") + print(" TRACK — every call recorded with its delegation call stack") + print() + + policy = CrewPolicyEngine(approver=MockApprover(auto_approve=False)) + handler = AssemblyCallbackHandler(interceptor=policy) + ``` + +=== "Custom (no framework)" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="custom-tool-demo-agent", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + + raw_fns = { + "compute_sum": compute_sum, + "fetch_stock_price": fetch_stock_price, + "send_http_request": send_http_request, + "write_to_disk": write_to_disk, + } + tools = {name: governed(name, fn, policy) for name, fn in raw_fns.items()} + ``` + +=== "Google ADK" + + ```python + # Govern the concrete demo tool class BEFORE init_assembly so the offline + # LocalPolicyEngine stays wired as the interceptor (the patch is idempotent). + govern_tool_class(DemoTool, LocalPolicyEngine()) + + try: + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="google-adk-demo-agent", + mode="sdk-only", + ) as ctx: + ``` + +=== "Haystack" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="haystack-demo-agent", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + print("Policy rules (local simulation of gateway policy):") + print(" DENY — execute_sql, run_shell_command (arbitrary execution)") + print(" ALLOW — everything else") + print() + + # init_assembly() has already auto-detected Haystack and patched + # Tool.invoke — but in offline sdk-only mode it wires a no-op interceptor + # (there is no live gateway/runtime to answer policy). For this *offline* + # demo we revert that and re-install the same native adapter against a + # LocalPolicyEngine so a real allow/deny is visible without a gateway. In + # production you would instead point init_assembly() at a gateway and let + # its auto-detected adapter enforce real policy — no manual re-install. + print("Installing the native Haystack adapter against the demo policy...") + HaystackPatch(LocalPolicyEngine()).revert() # drop the auto-applied no-op patch + patch = HaystackPatch(LocalPolicyEngine()) + installed = patch.apply() + ``` + +=== "LangChain" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="langchain-demo-agent", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + handler = AssemblyCallbackHandler(interceptor=policy) + ``` + +=== "LangChain (Research Agent)" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="langchain-research-agent", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} ({mode_label})") + print() + + policy = BalancedPolicyEngine(daily_budget_usd=DAILY_BUDGET_USD) + handler = AssemblyCallbackHandler(interceptor=policy) + ``` + +=== "LangGraph" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="langgraph-demo-agent", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + handler = AssemblyCallbackHandler(interceptor=policy) + + # Install LangGraph node-level governance hooks. The adapter wraps the + # compiled graph's nodes so tool calls inside each node are governed. + adapter = LangGraphAdapter() + adapter.set_process_agent_id(ctx.client.agent_id) + adapter.register_hooks(handler) + ``` + +=== "LlamaIndex" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="llamaindex-demo-agent", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + print("Policy rules (local simulation of gateway policy):") + print(" DENY — execute_sql, run_shell_command (arbitrary execution)") + print(" ALLOW — everything else") + print() + + # Register the native LlamaIndex adapter against the local policy engine. + # This patches FunctionTool.call so every tool call below is governed + # automatically — no per-tool wrapper needed. + # + # init_assembly() in sdk-only mode already auto-detected LlamaIndex and + # patched FunctionTool.call against a no-op interceptor (there is no + # gateway offline). Revert that first so this example's LocalPolicyEngine + # is the live interceptor; in production init_assembly wires the adapter + # to the gateway and this manual step is unnecessary. + print("Registering the native LlamaIndex governance adapter...") + LlamaIndexPatch(callback_handler=None).revert() + adapter = LlamaIndexAdapter() + adapter.register_hooks(LocalPolicyEngine()) + ``` + +=== "Microsoft Agent Framework" + + ```python + policy = LocalPolicyEngine() + + # Live path: install the governance hooks BEFORE init_assembly. The adapter + # patches `agent_framework.FunctionTool.invoke`; because the patch is + # idempotent, registering first makes init_assembly's auto-detection a no-op + # and keeps the offline `LocalPolicyEngine` wired as the interceptor (rather + # than the no-op interceptor auto-detection would install). + adapter: MicrosoftAgentFrameworkAdapter | None = None + if not mock: + adapter = MicrosoftAgentFrameworkAdapter() + adapter.set_process_agent_id("microsoft-agent-framework-demo-agent") + adapter.register_hooks(policy) + + try: + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="microsoft-agent-framework-demo-agent", + mode="sdk-only", + ) as ctx: + ``` + +=== "OpenAI Agents SDK" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="openai-agents-demo", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + handler = AssemblyCallbackHandler(interceptor=policy) + ``` + +=== "Pydantic AI" + + ```python + adapter = PydanticAIAdapter() + adapter.set_process_agent_id("pydantic-ai-demo-agent") + adapter.register_hooks(LocalPolicyEngine()) + + try: + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="pydantic-ai-demo-agent", + mode="sdk-only", + ) as ctx: + ``` + +=== "Semantic Kernel" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="semantic-kernel-demo-agent", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + kernel = build_kernel() + ``` + +=== "smolagents" + + ```python + policy = LocalPolicyEngine() + patch = SmolagentsPatch(policy) + patch.apply() + + print(f"Initializing Agent Assembly (gateway: {gateway_url}, sdk-only mode)...") + + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="smolagents-demo-agent", + mode="sdk-only", + ) as ctx: + ``` + +=== "Strands Agents" + + ```python + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="strands-demo-agent", + mode="sdk-only", + ) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + ``` + + ## What just happened -1. **`init_assembly()` wired in governance.** It registered the agent (`quickstart-agent`) - with the gateway and auto-loaded the LangChain adapter — every tool call from this point - on is routed through the policy gate. -2. **The `FakeListLLM` replays canned responses**, so the agent runs entirely offline with no - real LLM. -3. **The `whoami` tool call was governed.** The adapter intercepted `BaseTool._run` and asked - the gateway for a verdict before the tool actually ran. +1. **`init_assembly()` wired in governance.** It registered the agent with the gateway and + auto-loaded the adapter for your framework — every tool call from this point on is routed + through the policy gate. +2. **`mode="sdk-only"` kept it offline.** The in-process adapter enforces on tool calls with no + network sidecar, so the example runs deterministically with no real LLM or gateway + round-trip. +3. **Tool calls were governed.** The adapter intercepts the framework's tool-invocation path and + asks the policy engine for an allow/deny verdict before the tool actually runs. 4. **The `with` block tore everything down on exit** — adapter hooks were unwound and the gateway connection closed, leaving the process exactly as it was before. -### Expected output - -```text -alice is in engineering -``` - -If instead you see a `ToolExecutionBlockedError`, that is not a bug — the gateway's policy -denied the `whoami` call. That's the product working. See +If a tool call raises a `ToolExecutionBlockedError`, that is not a bug — the policy denied the +call. That's the product working. See [Handling allow/deny decisions](guides/handling-decisions.md) for how to catch and respond to those, and [Troubleshooting](troubleshooting.md) if `init_assembly()` itself raised. diff --git a/quickstart_snippets/README.md b/quickstart_snippets/README.md new file mode 100644 index 00000000..fd1898a0 --- /dev/null +++ b/quickstart_snippets/README.md @@ -0,0 +1,33 @@ +# Vendored quick-start snippets + +These files are a **committed, drift-checked copy** of the per-framework +"govern your first agent" snippets produced by the `ai-agent-assembly/examples` +repo (`scripts/extract_snippets.py`, AAASM-4512 / PR examples#267). Each `.py` +file is the `region: quickstart` slice — `init_assembly()` plus that framework's +adapter wiring — cut verbatim from the matching runnable example's entrypoint. + +## Why vendored, not fetched at build time + +`examples` is a **separate repository**; the MkDocs build here must not reach +across repos at render time. Vendoring keeps the docs build hermetic while the +snippets stay honest: `scripts/generate_quickstart_tabs.py` turns them into the +`pymdownx.tabbed` block in `docs/quick-start.md` §3, and a CI drift check +(`.github/workflows/quickstart-tabs-check.yml`) fails if the committed doc no +longer matches these inputs. + +## Refreshing after the examples change + +When the upstream examples' quick-start regions change, re-copy the Python +snippet files and the `python` slice of `examples/snippets/manifest.json` into +this directory (`manifest.json` here keeps only the `python` entries), then run: + +```bash +python scripts/generate_quickstart_tabs.py +``` + +and commit the regenerated `docs/quick-start.md` alongside the updated snippets. + +`manifest.json` is the data-driven tab index: `frameworks[]` ordered as the tab +list should render, each with `framework_id` (also the `.py` +filename), `label` (tab text), `status`, `lang`, and `source_example` +(provenance in the examples repo). diff --git a/quickstart_snippets/agno-tool-policy.py b/quickstart_snippets/agno-tool-policy.py new file mode 100644 index 00000000..454b0894 --- /dev/null +++ b/quickstart_snippets/agno-tool-policy.py @@ -0,0 +1,29 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="agno-demo-agent", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + + print("Policy rules (local simulation of gateway policy):") + print(" DENY — execute_sql, run_shell_command (arbitrary execution)") + print(" ALLOW — everything else") + print() + + # In production init_assembly() auto-detects Agno and wires the live + # runtime as the interceptor automatically. In this offline sdk-only demo + # there is no live runtime, so init_assembly() installs a no-op hook; we + # revert it and re-apply the hook wired to our local policy so the demo + # shows real allow/deny decisions without a gateway. (The patch is + # idempotent, so we must revert the no-op hook before installing ours.) + AgnoPatch(policy).revert() + patch = AgnoPatch(policy) + assert patch.apply(), ( + "Agno governance hook did not install — is agno importable?" + ) diff --git a/quickstart_snippets/autogen-tool-policy.py b/quickstart_snippets/autogen-tool-policy.py new file mode 100644 index 00000000..82b71661 --- /dev/null +++ b/quickstart_snippets/autogen-tool-policy.py @@ -0,0 +1,12 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="autogen-demo-agent", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() diff --git a/quickstart_snippets/crewai-research-crew.py b/quickstart_snippets/crewai-research-crew.py new file mode 100644 index 00000000..ae693944 --- /dev/null +++ b/quickstart_snippets/crewai-research-crew.py @@ -0,0 +1,24 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="crewai-research-crew", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} ({mode_label})") + print() + + print("Crew members:") + for member in CREW: + print(f" • {member.name:<11} — {member.role}") + print() + + print("Crew policy (local simulation of gateway policy):") + print(" APPROVAL — any agent attempting a file write must be approved") + print(f" BUDGET — ${DAILY_BUDGET_USD:.2f} / day, shared across all agents") + print(" TRACK — every call recorded with its delegation call stack") + print() + + policy = CrewPolicyEngine(approver=MockApprover(auto_approve=False)) + handler = AssemblyCallbackHandler(interceptor=policy) diff --git a/quickstart_snippets/custom-tool-policy.py b/quickstart_snippets/custom-tool-policy.py new file mode 100644 index 00000000..3c83bf48 --- /dev/null +++ b/quickstart_snippets/custom-tool-policy.py @@ -0,0 +1,20 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="custom-tool-demo-agent", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + + raw_fns = { + "compute_sum": compute_sum, + "fetch_stock_price": fetch_stock_price, + "send_http_request": send_http_request, + "write_to_disk": write_to_disk, + } + tools = {name: governed(name, fn, policy) for name, fn in raw_fns.items()} diff --git a/quickstart_snippets/google-adk.py b/quickstart_snippets/google-adk.py new file mode 100644 index 00000000..1efd391b --- /dev/null +++ b/quickstart_snippets/google-adk.py @@ -0,0 +1,11 @@ +# Govern the concrete demo tool class BEFORE init_assembly so the offline +# LocalPolicyEngine stays wired as the interceptor (the patch is idempotent). +govern_tool_class(DemoTool, LocalPolicyEngine()) + +try: + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="google-adk-demo-agent", + mode="sdk-only", + ) as ctx: diff --git a/quickstart_snippets/haystack-tool-policy.py b/quickstart_snippets/haystack-tool-policy.py new file mode 100644 index 00000000..f0424de3 --- /dev/null +++ b/quickstart_snippets/haystack-tool-policy.py @@ -0,0 +1,27 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="haystack-demo-agent", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + print("Policy rules (local simulation of gateway policy):") + print(" DENY — execute_sql, run_shell_command (arbitrary execution)") + print(" ALLOW — everything else") + print() + + # init_assembly() has already auto-detected Haystack and patched + # Tool.invoke — but in offline sdk-only mode it wires a no-op interceptor + # (there is no live gateway/runtime to answer policy). For this *offline* + # demo we revert that and re-install the same native adapter against a + # LocalPolicyEngine so a real allow/deny is visible without a gateway. In + # production you would instead point init_assembly() at a gateway and let + # its auto-detected adapter enforce real policy — no manual re-install. + print("Installing the native Haystack adapter against the demo policy...") + HaystackPatch(LocalPolicyEngine()).revert() # drop the auto-applied no-op patch + patch = HaystackPatch(LocalPolicyEngine()) + installed = patch.apply() diff --git a/quickstart_snippets/langchain-basic-agent.py b/quickstart_snippets/langchain-basic-agent.py new file mode 100644 index 00000000..74b9afcf --- /dev/null +++ b/quickstart_snippets/langchain-basic-agent.py @@ -0,0 +1,13 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="langchain-demo-agent", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + handler = AssemblyCallbackHandler(interceptor=policy) diff --git a/quickstart_snippets/langchain-research-agent.py b/quickstart_snippets/langchain-research-agent.py new file mode 100644 index 00000000..ff179593 --- /dev/null +++ b/quickstart_snippets/langchain-research-agent.py @@ -0,0 +1,13 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="langchain-research-agent", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} ({mode_label})") + print() + + policy = BalancedPolicyEngine(daily_budget_usd=DAILY_BUDGET_USD) + handler = AssemblyCallbackHandler(interceptor=policy) diff --git a/quickstart_snippets/langgraph.py b/quickstart_snippets/langgraph.py new file mode 100644 index 00000000..ccffca96 --- /dev/null +++ b/quickstart_snippets/langgraph.py @@ -0,0 +1,19 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="langgraph-demo-agent", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + handler = AssemblyCallbackHandler(interceptor=policy) + + # Install LangGraph node-level governance hooks. The adapter wraps the + # compiled graph's nodes so tool calls inside each node are governed. + adapter = LangGraphAdapter() + adapter.set_process_agent_id(ctx.client.agent_id) + adapter.register_hooks(handler) diff --git a/quickstart_snippets/llamaindex-tool-policy.py b/quickstart_snippets/llamaindex-tool-policy.py new file mode 100644 index 00000000..672538dc --- /dev/null +++ b/quickstart_snippets/llamaindex-tool-policy.py @@ -0,0 +1,29 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="llamaindex-demo-agent", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + print("Policy rules (local simulation of gateway policy):") + print(" DENY — execute_sql, run_shell_command (arbitrary execution)") + print(" ALLOW — everything else") + print() + + # Register the native LlamaIndex adapter against the local policy engine. + # This patches FunctionTool.call so every tool call below is governed + # automatically — no per-tool wrapper needed. + # + # init_assembly() in sdk-only mode already auto-detected LlamaIndex and + # patched FunctionTool.call against a no-op interceptor (there is no + # gateway offline). Revert that first so this example's LocalPolicyEngine + # is the live interceptor; in production init_assembly wires the adapter + # to the gateway and this manual step is unnecessary. + print("Registering the native LlamaIndex governance adapter...") + LlamaIndexPatch(callback_handler=None).revert() + adapter = LlamaIndexAdapter() + adapter.register_hooks(LocalPolicyEngine()) diff --git a/quickstart_snippets/manifest.json b/quickstart_snippets/manifest.json new file mode 100644 index 00000000..db69a595 --- /dev/null +++ b/quickstart_snippets/manifest.json @@ -0,0 +1,119 @@ +{ + "schema_version": 1, + "source": "ai-agent-assembly/examples: snippets/manifest.json (AAASM-4512, PR examples#267)", + "sdk": "python", + "frameworks": [ + { + "framework_id": "agno-tool-policy", + "label": "Agno", + "status": "validated", + "lang": "python", + "source_example": "python/agno-tool-policy/src/main.py" + }, + { + "framework_id": "autogen-tool-policy", + "label": "AutoGen", + "status": "validated", + "lang": "python", + "source_example": "python/autogen-tool-policy/src/main.py" + }, + { + "framework_id": "crewai-research-crew", + "label": "CrewAI", + "status": "validated", + "lang": "python", + "source_example": "python/crewai-research-crew/src/main.py" + }, + { + "framework_id": "custom-tool-policy", + "label": "Custom (no framework)", + "status": "validated", + "lang": "python", + "source_example": "python/custom-tool-policy/src/main.py" + }, + { + "framework_id": "google-adk", + "label": "Google ADK", + "status": "validated", + "lang": "python", + "source_example": "python/google-adk/src/main.py" + }, + { + "framework_id": "haystack-tool-policy", + "label": "Haystack", + "status": "validated", + "lang": "python", + "source_example": "python/haystack-tool-policy/src/main.py" + }, + { + "framework_id": "langchain-basic-agent", + "label": "LangChain", + "status": "validated", + "lang": "python", + "source_example": "python/langchain-basic-agent/src/main.py" + }, + { + "framework_id": "langchain-research-agent", + "label": "LangChain (Research Agent)", + "status": "validated", + "lang": "python", + "source_example": "python/langchain-research-agent/src/main.py" + }, + { + "framework_id": "langgraph", + "label": "LangGraph", + "status": "validated", + "lang": "python", + "source_example": "python/langgraph/src/main.py" + }, + { + "framework_id": "llamaindex-tool-policy", + "label": "LlamaIndex", + "status": "validated", + "lang": "python", + "source_example": "python/llamaindex-tool-policy/src/main.py" + }, + { + "framework_id": "microsoft-agent-framework-tool-policy", + "label": "Microsoft Agent Framework", + "status": "validated", + "lang": "python", + "source_example": "python/microsoft-agent-framework-tool-policy/src/main.py" + }, + { + "framework_id": "openai-agents-sdk", + "label": "OpenAI Agents SDK", + "status": "validated", + "lang": "python", + "source_example": "python/openai-agents-sdk/src/main.py" + }, + { + "framework_id": "pydantic-ai", + "label": "Pydantic AI", + "status": "validated", + "lang": "python", + "source_example": "python/pydantic-ai/src/main.py" + }, + { + "framework_id": "semantic-kernel-tool-policy", + "label": "Semantic Kernel", + "status": "validated", + "lang": "python", + "source_example": "python/semantic-kernel-tool-policy/src/main.py" + }, + { + "framework_id": "smolagents-tool-policy", + "label": "smolagents", + "status": "validated", + "lang": "python", + "source_example": "python/smolagents-tool-policy/src/main.py" + }, + { + "framework_id": "strands-agents-tool-policy", + "label": "Strands Agents", + "status": "validated", + "lang": "python", + "source_example": "python/strands-agents-tool-policy/src/main.py" + } + ] +} diff --git a/quickstart_snippets/microsoft-agent-framework-tool-policy.py b/quickstart_snippets/microsoft-agent-framework-tool-policy.py new file mode 100644 index 00000000..6874538b --- /dev/null +++ b/quickstart_snippets/microsoft-agent-framework-tool-policy.py @@ -0,0 +1,20 @@ +policy = LocalPolicyEngine() + +# Live path: install the governance hooks BEFORE init_assembly. The adapter +# patches `agent_framework.FunctionTool.invoke`; because the patch is +# idempotent, registering first makes init_assembly's auto-detection a no-op +# and keeps the offline `LocalPolicyEngine` wired as the interceptor (rather +# than the no-op interceptor auto-detection would install). +adapter: MicrosoftAgentFrameworkAdapter | None = None +if not mock: + adapter = MicrosoftAgentFrameworkAdapter() + adapter.set_process_agent_id("microsoft-agent-framework-demo-agent") + adapter.register_hooks(policy) + +try: + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="microsoft-agent-framework-demo-agent", + mode="sdk-only", + ) as ctx: diff --git a/quickstart_snippets/openai-agents-sdk.py b/quickstart_snippets/openai-agents-sdk.py new file mode 100644 index 00000000..076c3bf9 --- /dev/null +++ b/quickstart_snippets/openai-agents-sdk.py @@ -0,0 +1,13 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="openai-agents-demo", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + handler = AssemblyCallbackHandler(interceptor=policy) diff --git a/quickstart_snippets/pydantic-ai.py b/quickstart_snippets/pydantic-ai.py new file mode 100644 index 00000000..20043aa5 --- /dev/null +++ b/quickstart_snippets/pydantic-ai.py @@ -0,0 +1,11 @@ +adapter = PydanticAIAdapter() +adapter.set_process_agent_id("pydantic-ai-demo-agent") +adapter.register_hooks(LocalPolicyEngine()) + +try: + with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="pydantic-ai-demo-agent", + mode="sdk-only", + ) as ctx: diff --git a/quickstart_snippets/semantic-kernel-tool-policy.py b/quickstart_snippets/semantic-kernel-tool-policy.py new file mode 100644 index 00000000..7d5feb30 --- /dev/null +++ b/quickstart_snippets/semantic-kernel-tool-policy.py @@ -0,0 +1,13 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="semantic-kernel-demo-agent", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() + kernel = build_kernel() diff --git a/quickstart_snippets/smolagents-tool-policy.py b/quickstart_snippets/smolagents-tool-policy.py new file mode 100644 index 00000000..e83897ad --- /dev/null +++ b/quickstart_snippets/smolagents-tool-policy.py @@ -0,0 +1,12 @@ +policy = LocalPolicyEngine() +patch = SmolagentsPatch(policy) +patch.apply() + +print(f"Initializing Agent Assembly (gateway: {gateway_url}, sdk-only mode)...") + +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="smolagents-demo-agent", + mode="sdk-only", +) as ctx: diff --git a/quickstart_snippets/strands-agents-tool-policy.py b/quickstart_snippets/strands-agents-tool-policy.py new file mode 100644 index 00000000..11f82087 --- /dev/null +++ b/quickstart_snippets/strands-agents-tool-policy.py @@ -0,0 +1,12 @@ +with init_assembly( + gateway_url=gateway_url, + api_key=api_key, + agent_id="strands-demo-agent", + mode="sdk-only", +) as ctx: + print(f" Agent: {ctx.client.agent_id}") + print(f" Gateway: {ctx.client.gateway_url}") + print(f" Mode: {ctx.network_mode} (offline demo)") + print() + + policy = LocalPolicyEngine() diff --git a/scripts/generate_quickstart_tabs.py b/scripts/generate_quickstart_tabs.py new file mode 100644 index 00000000..6e29e28d --- /dev/null +++ b/scripts/generate_quickstart_tabs.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Generate the per-framework quick-start tab block in ``docs/quick-start.md``. + +WHY THIS EXISTS +--------------- +Quick-start §3 "Govern your first agent" (AAASM-4513) shows one ``pymdownx.tabbed`` +tab per supported Python framework, and every tab's code must be the *governance +slice of a runnable example* — not a hand-maintained copy that silently rots (the +AAASM-4451 stale-sample class). The snippets are vendored from the +``ai-agent-assembly/examples`` repo into ``quickstart_snippets/`` (committed, +because ``examples`` is a separate repo and the MkDocs build must stay hermetic). +This script is the single point that turns those vendored inputs into the tab +block, written into a bounded ``BEGIN/END GENERATED`` region of the doc so the tab +list is data-driven from ``quickstart_snippets/manifest.json`` — never hand-listed. + +A CI drift check (``.github/workflows/quickstart-tabs-check.yml``) re-runs this +script and fails if the committed ``docs/quick-start.md`` differs, so the doc can +never drift from the vendored snippets + manifest. + +DESIGN CONSTRAINTS (mirrors the examples ``extract_snippets.py`` gate) +---------------------------------------------------------------------- +* Standard library only — the CI drift check needs no install step. +* Idempotent: running twice produces no diff. The drift check enforces this. +* Data-driven: the tab list is exactly ``manifest.json``'s ``frameworks`` array, + in listed order, so a framework added upstream appears once it is re-vendored. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# Region markers delimiting the generated block inside docs/quick-start.md. The +# markers must already exist in the doc; this script only rewrites what is +# between them, leaving the surrounding prose under human control. +BEGIN_MARKER = "" +END_MARKER = "" + + +class GenerateError(RuntimeError): + """A missing input or a malformed target region — fail the drift check loudly.""" + + +def _repo_root_from_script() -> Path: + # This file lives at /scripts/generate_quickstart_tabs.py. + return Path(__file__).resolve().parent.parent + + +def _indent_tab_body(line: str) -> str: + """Indent one tab-body line by 4 spaces, keeping blank lines empty. + + ``pymdownx.tabbed`` treats 4-space-indented content as the tab body (the same + convention the pip/uv install tabs in this file already use). Blank lines stay + empty so the output carries no trailing whitespace. + """ + + return f" {line}" if line else "" + + +def render_tab(label: str, code: str) -> str: + """Render one ``=== "