diff --git a/python/03-integrate/runtime-control/launchdarkly/LaunchDarkly-AI-Configs-strands.ipynb b/python/03-integrate/runtime-control/launchdarkly/LaunchDarkly-AI-Configs-strands.ipynb new file mode 100644 index 00000000..10eb37ef --- /dev/null +++ b/python/03-integrate/runtime-control/launchdarkly/LaunchDarkly-AI-Configs-strands.ipynb @@ -0,0 +1,1146 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Strands Agents with LaunchDarkly AI Configs\n", + "\n", + "[LaunchDarkly AI Configs](https://launchdarkly.com/docs/home/ai-configs) externalize the model, prompt, parameters, and tools your agent uses, so you can change them in the LaunchDarkly UI without redeploying.\n", + "\n", + "The [Strands Agents SDK](https://strandsagents.com) builds agents with pluggable model classes (`OpenAIModel`, `AnthropicModel`, `BedrockModel`). LaunchDarkly picks *which* variation to serve at runtime; Strands just reads it back and instantiates the matching model class. The result: swap `gpt-5` for `claude-sonnet-4-6` (or Bedrock-hosted Claude) without changing a line of agent code.\n", + "\n", + "This sample creates an agent-mode AI Config with three variations (OpenAI, Anthropic, Bedrock), attaches a governed `get_order_status` tool, and runs three turns through a Strands `Agent` with `SlidingWindowConversationManager` for multi-turn memory. The final section builds an Agent Graph that wires a deep-reasoning specialist node onto the root agent.\n", + "\n", + "## What you'll learn\n", + "- Map a LaunchDarkly AI Config variation to a Strands model class (`create_strands_model`)\n", + "- Drive an agent's tool list from LaunchDarkly rather than hardcoded Python lists\n", + "- Track duration, tokens, success/error, and tool-call counts per turn with the LaunchDarkly AI SDK\n", + "- Compose multiple agents into an Agent Graph and read the topology back via the SDK\n", + "\n", + "## Prerequisites\n", + "- Python 3.10+\n", + "- A LaunchDarkly account with an API token (Writer role). The notebook creates the project, AI Config, variations, and tool for you, and fetches the SDK key from the API at runtime.\n", + "- `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` (Strands reads them from the environment)\n", + "- Optional: AWS credentials with Bedrock model access for the `bedrock-claude-agent` variation\n", + "- A `.env` file in this directory with the keys above" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:06:39.634009Z", + "iopub.status.busy": "2026-05-13T23:06:39.633830Z", + "iopub.status.idle": "2026-05-13T23:06:40.941001Z", + "shell.execute_reply": "2026-05-13T23:06:40.940278Z" + } + }, + "outputs": [], + "source": [ + "import sys\n", + "!{sys.executable} -m pip install -q --upgrade launchdarkly-server-sdk launchdarkly-server-sdk-ai strands-agents strands-agents-tools boto3 python-dotenv requests" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:06:40.942983Z", + "iopub.status.busy": "2026-05-13T23:06:40.942862Z", + "iopub.status.idle": "2026-05-13T23:06:41.799094Z", + "shell.execute_reply": "2026-05-13T23:06:41.798275Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "import json\n", + "import time\n", + "import requests\n", + "from dotenv import load_dotenv\n", + "\n", + "# Load .env from the directory containing this notebook.\n", + "load_dotenv()\n", + "\n", + "LD_API_TOKEN = os.environ[\"LAUNCHDARKLY_API_TOKEN\"]\n", + "BASE_URL = \"https://app.launchdarkly.com/api/v2\"\n", + "# Project + environment are overridable so this sample can run against any\n", + "# LaunchDarkly account. Defaults create a fresh project named below.\n", + "PROJECT_KEY = os.environ.get(\"LAUNCHDARKLY_PROJECT_KEY\", \"strands-launchdarkly-sample\")\n", + "PROJECT_NAME = os.environ.get(\"LAUNCHDARKLY_PROJECT_NAME\", \"Strands + LaunchDarkly Sample\")\n", + "ENVIRONMENT = os.environ.get(\"LAUNCHDARKLY_ENVIRONMENT\", \"production\")\n", + "CONFIG_KEY = \"strands-agent\"\n", + "TOOL_KEY = \"get_order_status\"\n", + "\n", + "# `LD-API-Version: beta` is required by the agent-graphs endpoints; harmless\n", + "# on the older endpoints used elsewhere in this notebook.\n", + "HEADERS = {\n", + " \"Authorization\": LD_API_TOKEN,\n", + " \"Content-Type\": \"application/json\",\n", + " \"LD-API-Version\": \"beta\",\n", + "}\n", + "PATCH_HEADERS = {\n", + " \"Authorization\": LD_API_TOKEN,\n", + " \"Content-Type\": \"application/json; domain-model=launchdarkly.semanticpatch\",\n", + " \"LD-API-Version\": \"beta\",\n", + "}\n", + "\n", + "# Ensure the LaunchDarkly project exists. Idempotent: 201 on first run, 409 on\n", + "# re-runs against an existing project.\n", + "r = requests.post(\n", + " f\"{BASE_URL}/projects\",\n", + " headers=HEADERS,\n", + " json={\"key\": PROJECT_KEY, \"name\": PROJECT_NAME},\n", + ")\n", + "if r.status_code == 201:\n", + " print(f\"[OK] Project created: {PROJECT_KEY}\")\n", + "elif r.status_code == 409:\n", + " print(f\"[INFO] Project already exists: {PROJECT_KEY}\")\n", + "else:\n", + " print(f\"[ERROR] Project {r.status_code}: {r.text[:200]}\")\n", + "\n", + "# Fetch the SDK key for this environment from the API rather than reading it\n", + "# from .env. If the project was deleted and recreated, an old SDK key in .env\n", + "# won't authenticate; this always pulls the live one.\n", + "r = requests.get(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/environments\",\n", + " headers={\"Authorization\": LD_API_TOKEN},\n", + ")\n", + "SDK_KEY = next(\n", + " (env[\"apiKey\"] for env in r.json().get(\"items\", []) if env[\"key\"] == ENVIRONMENT),\n", + " None,\n", + ")\n", + "if SDK_KEY:\n", + " print(f\"[OK] Retrieved SDK key for environment: {ENVIRONMENT}\")\n", + "else:\n", + " raise RuntimeError(f\"No SDK key found for environment: {ENVIRONMENT}\")\n", + "\n", + "print(f\"[OK] Targeting project: {PROJECT_KEY}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Create AI Config (agent mode)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:06:41.801571Z", + "iopub.status.busy": "2026-05-13T23:06:41.801369Z", + "iopub.status.idle": "2026-05-13T23:06:43.394127Z", + "shell.execute_reply": "2026-05-13T23:06:43.393236Z" + } + }, + "outputs": [], + "source": [ + "# Delete the existing config so re-runs start clean. NOTE: if section 8 has run\n", + "# before, an agent graph references this config as its root and LD will refuse\n", + "# the delete with 409 (\"AI Config cannot be deleted because it is used by the\n", + "# following agent graphs\"). That's fine \u2014 the variation/tool/targeting cells\n", + "# below all upsert correctly against an existing config. The same goes for the\n", + "# create call: when the config still exists, LD returns 400 with the misleading\n", + "# message \"could not find the 'enabled' variation\" instead of 409 \"already\n", + "# exists\" \u2014 we treat both as the existing-config case.\n", + "requests.delete(f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs/{CONFIG_KEY}\", headers=HEADERS)\n", + "time.sleep(0.5)\n", + "\n", + "r = requests.post(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs\",\n", + " headers=HEADERS,\n", + " json={\"key\": CONFIG_KEY, \"name\": \"Strands Agent\", \"mode\": \"agent\"},\n", + ")\n", + "if r.status_code == 201:\n", + " print(f\"[OK] AI Config created: {CONFIG_KEY}\")\n", + "elif r.status_code == 409 or (r.status_code == 400 and \"enabled\" in r.text):\n", + " print(f\"[INFO] AI Config already exists: {CONFIG_KEY} (keeping existing)\")\n", + "else:\n", + " print(f\"[ERROR] {r.status_code}: {r.text[:200]}\")\n", + "print(f\"[INFO] https://app.launchdarkly.com/projects/{PROJECT_KEY}/ai-configs/{CONFIG_KEY}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Create Variations\n", + "\n", + "Three variations, each pinned to a different provider. `create_strands_model` later dispatches on `agent_config.provider.name` (with a model-id fallback for Bedrock) to pick the right Strands model class.\n", + "\n", + "- `gpt-5-agent` \u2014 OpenAI gpt-5, `max_completion_tokens=1024` (gpt-5 does not accept `max_tokens` or non-default temperature)\n", + "- `claude-sonnet-agent` \u2014 Anthropic Claude Sonnet 4.6, standard parameters\n", + "- `bedrock-claude-agent` \u2014 Anthropic Claude Sonnet 4.6 *via AWS Bedrock*. Requires `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` (or AWS SSO) in the environment with Bedrock model access enabled.\n", + "\n", + "We set `gpt-5-agent` as the default via targeting so the notebook runs deterministically on the first turn." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:06:43.396495Z", + "iopub.status.busy": "2026-05-13T23:06:43.396299Z", + "iopub.status.idle": "2026-05-13T23:06:45.793558Z", + "shell.execute_reply": "2026-05-13T23:06:45.792659Z" + } + }, + "outputs": [], + "source": [ + "def create_variation(body, retries=5):\n", + " \"\"\"LD occasionally returns a transient 400 right after a sibling variation is created; retry.\"\"\"\n", + " for attempt in range(retries):\n", + " r = requests.post(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs/{CONFIG_KEY}/variations\",\n", + " headers=HEADERS, json=body,\n", + " )\n", + " if r.status_code in (201, 409):\n", + " return r\n", + " time.sleep(0.6)\n", + " return r\n", + "\n", + "r = create_variation({\n", + " \"key\": \"gpt-5-agent\",\n", + " \"name\": \"OpenAI GPT-5 Agent\",\n", + " \"modelConfigKey\": \"OpenAI.gpt-5\",\n", + " \"model\": {\"modelName\": \"gpt-5\", \"parameters\": {\"max_completion_tokens\": 4096}},\n", + " \"messages\": [],\n", + " \"instructions\": (\n", + " \"You are an order triage assistant. Use the get_order_status tool to look \"\n", + " \"up the order, then give a one-line status summary.\\n\\n\"\n", + " \"Two response modes:\\n\"\n", + " \"1) If the user only asked about status, answer directly and STOP. Do not \"\n", + " \"emit any JSON.\\n\"\n", + " \"2) If the user is asking for follow-up analysis (investigation, escalation, \"\n", + " \"customer communication, root-cause reasoning), give the one-line status, \"\n", + " \"then hand off by ending your response with this JSON envelope on its own \"\n", + " \"line: `{\\\"route\\\": \\\"\\\"}`. The available route names will be \"\n", + " \"listed in your context. Keep your own response brief \u2014 the specialist \"\n", + " \"produces the analysis.\"\n", + " ),\n", + "})\n", + "print(f\"[{'OK' if r.status_code == 201 else 'INFO'}] Variation: gpt-5-agent\")\n", + "\n", + "r = create_variation({\n", + " \"key\": \"claude-sonnet-agent\",\n", + " \"name\": \"Anthropic Claude Sonnet Agent\",\n", + " \"modelConfigKey\": \"Anthropic.claude-sonnet-4-6\",\n", + " \"model\": {\"modelName\": \"claude-sonnet-4-6\", \"parameters\": {\"max_tokens\": 4096, \"temperature\": 0.7}},\n", + " \"messages\": [],\n", + " \"instructions\": (\n", + " \"You are an order triage assistant. Use the get_order_status tool to look \"\n", + " \"up the order, then give a one-line status summary.\\n\\n\"\n", + " \"Two response modes:\\n\"\n", + " \"1) If the user only asked about status, answer directly and STOP. Do not \"\n", + " \"emit any JSON.\\n\"\n", + " \"2) If the user is asking for follow-up analysis (investigation, escalation, \"\n", + " \"customer communication, root-cause reasoning), give the one-line status, \"\n", + " \"then hand off by ending your response with this JSON envelope on its own \"\n", + " \"line: `{\\\"route\\\": \\\"\\\"}`. The available route names will be \"\n", + " \"listed in your context. Keep your own response brief \u2014 the specialist \"\n", + " \"produces the analysis.\"\n", + " ),\n", + "})\n", + "print(f\"[{'OK' if r.status_code == 201 else 'INFO'}] Variation: claude-sonnet-agent\")\n", + "\n", + "# Bedrock variation: optional. We always create it so the LD UI shows the\n", + "# three-provider story, but the variation will only serve successfully if the\n", + "# notebook environment has AWS credentials with Bedrock model access.\n", + "r = create_variation({\n", + " \"key\": \"bedrock-claude-agent\",\n", + " \"name\": \"Bedrock Claude Sonnet Agent\",\n", + " \"model\": {\"modelName\": \"us.anthropic.claude-sonnet-4-6\", \"parameters\": {\"max_tokens\": 4096, \"temperature\": 0.7}},\n", + " \"messages\": [],\n", + " \"instructions\": (\n", + " \"You are an order triage assistant. Use the get_order_status tool to look \"\n", + " \"up the order, then give a one-line status summary.\\n\\n\"\n", + " \"Two response modes:\\n\"\n", + " \"1) If the user only asked about status, answer directly and STOP. Do not \"\n", + " \"emit any JSON.\\n\"\n", + " \"2) If the user is asking for follow-up analysis (investigation, escalation, \"\n", + " \"customer communication, root-cause reasoning), give the one-line status, \"\n", + " \"then hand off by ending your response with this JSON envelope on its own \"\n", + " \"line: `{\\\"route\\\": \\\"\\\"}`. The available route names will be \"\n", + " \"listed in your context. Keep your own response brief \u2014 the specialist \"\n", + " \"produces the analysis.\"\n", + " ),\n", + "})\n", + "print(f\"[{'OK' if r.status_code == 201 else 'INFO'}] Variation: bedrock-claude-agent\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Attach the governed tool\n", + "\n", + "`get_order_status` was created centrally by `tools.ipynb`. We attach it to the `gpt-5-agent` variation (the default) so the agent has access to it when served. Attachment is via `PATCH /ai-configs/{key}/variations/{variation-id}`.\n", + "\n", + "The Strands Python SDK wires tools through the `Agent(tools=[...])` constructor from application code \u2014 LD's attachment is for governance (schema + version tracking), not dispatch.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:06:45.795847Z", + "iopub.status.busy": "2026-05-13T23:06:45.795658Z", + "iopub.status.idle": "2026-05-13T23:06:48.281855Z", + "shell.execute_reply": "2026-05-13T23:06:48.280725Z" + } + }, + "outputs": [], + "source": [ + "# Ensure the tool exists (tools.ipynb may or may not have run in this project)\n", + "tool_body = {\n", + " \"key\": TOOL_KEY,\n", + " \"name\": \"Get Order Status\",\n", + " \"description\": \"Look up the status of a customer order by order ID\",\n", + " \"schema\": {\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " \"order_id\": {\"type\": \"string\", \"description\": \"The order ID to look up\"}\n", + " },\n", + " \"required\": [\"order_id\"],\n", + " },\n", + "}\n", + "r = requests.post(f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-tools\", headers=HEADERS, json=tool_body)\n", + "if r.status_code == 201:\n", + " print(f\"[OK] Tool created: {TOOL_KEY}\")\n", + "elif r.status_code == 409:\n", + " print(f\"[INFO] Tool already exists: {TOOL_KEY}\")\n", + "else:\n", + " print(f\"[ERROR] {r.status_code}: {r.text[:200]}\")\n", + "\n", + "# PATCH /variations/{variation-key} \u2014 attach the governed tool to each variation.\n", + "# (Takes the variation KEY, not its _id.)\n", + "for variation_key in (\"gpt-5-agent\", \"claude-sonnet-agent\", \"bedrock-claude-agent\"):\n", + " r = requests.patch(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs/{CONFIG_KEY}/variations/{variation_key}\",\n", + " headers=HEADERS,\n", + " json={\"tools\": [{\"key\": TOOL_KEY, \"version\": 1}]},\n", + " )\n", + " ok = r.status_code in (200, 204)\n", + " print(f\"[{'OK' if ok else 'ERROR'}] Attached '{TOOL_KEY}' to {variation_key} ({r.status_code})\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Set default variation & enable targeting\n", + "\n", + "Point the production fallthrough at `gpt-5-agent` so the config serves a real (enabled) variation by default. Without this the config is reachable but disabled.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:06:48.284110Z", + "iopub.status.busy": "2026-05-13T23:06:48.283913Z", + "iopub.status.idle": "2026-05-13T23:06:49.669427Z", + "shell.execute_reply": "2026-05-13T23:06:49.668877Z" + } + }, + "outputs": [], + "source": [ + "# Read the targeting variation IDs (different from the AI Config variation _id)\n", + "r = requests.get(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs/{CONFIG_KEY}/targeting\",\n", + " headers=HEADERS,\n", + ")\n", + "tvid_map = {}\n", + "for v in r.json().get(\"variations\", []):\n", + " if v.get(\"name\") == \"disabled\":\n", + " continue\n", + " key = v.get(\"value\", {}).get(\"_ldMeta\", {}).get(\"variationKey\")\n", + " if key:\n", + " tvid_map[key] = v[\"_id\"]\n", + "\n", + "fallthrough_id = tvid_map.get(\"gpt-5-agent\")\n", + "if fallthrough_id:\n", + " r = requests.patch(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs/{CONFIG_KEY}/targeting\",\n", + " headers=PATCH_HEADERS,\n", + " json={\n", + " \"environmentKey\": ENVIRONMENT,\n", + " \"instructions\": [{\n", + " \"kind\": \"updateFallthroughVariationOrRollout\",\n", + " \"variationId\": fallthrough_id,\n", + " }],\n", + " },\n", + " )\n", + " print(f\"[{'OK' if r.status_code == 200 else 'ERROR'}] Default variation: gpt-5-agent\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Define the tool handler and registry\n", + "\n", + "The `@tool` decorator exposes a Python function as a callable tool for the Strands `Agent`. The handler stays in application code \u2014 LD governs the schema, the app owns execution.\n", + "\n", + "`TOOL_REGISTRY` maps tool *names* (matching the LD tool keys) to the local handlers. We resolve the actual list of tools to pass to the agent at runtime, from `agent_config.model.parameters['tools']` \u2014 so detaching `get_order_status` from the variation in the LaunchDarkly UI takes effect on the next agent invocation, with no code change.\n", + "\n", + "The handler also fires `tracker.track_tool_call(\"get_order_status\")` on every invocation so LD's tool-call metrics line up with what the agent actually did. (The tracker is created later in the notebook; we look it up via the module global at call time.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:06:49.671893Z", + "iopub.status.busy": "2026-05-13T23:06:49.671725Z", + "iopub.status.idle": "2026-05-13T23:06:50.481420Z", + "shell.execute_reply": "2026-05-13T23:06:50.480888Z" + } + }, + "outputs": [], + "source": [ + "from strands import tool\n", + "\n", + "\n", + "# Module-level reference; reassigned by `run_turn` (cell 17) to the per-turn\n", + "# tracker. The @tool body looks it up at call time, so as long as `_tracker` is\n", + "# bound before the agent invokes the tool, `track_tool_call` fires on the right\n", + "# tracker. SDK 0.18 enforces at-most-once tracking per tracker, which is why\n", + "# each turn needs a fresh `create_tracker()` rather than a single shared one.\n", + "_tracker = None\n", + "\n", + "\n", + "@tool\n", + "def get_order_status(order_id: str) -> str:\n", + " \"\"\"Look up the status of a customer order by order ID.\"\"\"\n", + " if _tracker is not None:\n", + " _tracker.track_tool_call(\"get_order_status\")\n", + " orders = {\n", + " \"ORD-123\": \"Shipped \u2014 arrives Thursday\",\n", + " \"ORD-456\": \"Processing \u2014 estimated ship date: tomorrow\",\n", + " \"ORD-789\": \"Delivered on Monday\",\n", + " }\n", + " return orders.get(order_id, f\"No order found with ID {order_id}\")\n", + "\n", + "\n", + "# Registry maps the LD tool *key* to the local Strands tool object.\n", + "# create_strands_model + the agent build cell below use this to resolve the\n", + "# tool list from `agent_config.model.parameters['tools']` at runtime.\n", + "TOOL_REGISTRY = {\"get_order_status\": get_order_status}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Initialize LaunchDarkly and build the Strands agent\n", + "\n", + "`create_strands_model` is the only provider-aware code: given an `LDAIAgentConfig`, it returns the matching Strands model class. Memory, invocation, and tracking are identical across providers. Tools are pulled from `agent_config.model.parameters['tools']` and resolved against `TOOL_REGISTRY`, so the LD UI is the source of truth for what the agent can call." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:06:50.483318Z", + "iopub.status.busy": "2026-05-13T23:06:50.483207Z", + "iopub.status.idle": "2026-05-13T23:06:51.618419Z", + "shell.execute_reply": "2026-05-13T23:06:51.617875Z" + } + }, + "outputs": [], + "source": [ + "import asyncio\n", + "import ldclient\n", + "from ldclient import Context\n", + "from ldclient.config import Config\n", + "from ldai.client import LDAIClient\n", + "from ldai.tracker import TokenUsage\n", + "from strands import Agent\n", + "from strands.models.anthropic import AnthropicModel\n", + "from strands.models.openai import OpenAIModel\n", + "from strands.models.bedrock import BedrockModel\n", + "from strands.agent.conversation_manager.sliding_window_conversation_manager import (\n", + " SlidingWindowConversationManager,\n", + ")\n", + "\n", + "ldclient.set_config(Config(SDK_KEY))\n", + "ai_client = LDAIClient(ldclient.get())\n", + "if not ldclient.get().is_initialized():\n", + " raise RuntimeError(\"LaunchDarkly SDK failed to initialize\")\n", + "\n", + "context = Context.builder(\"user-123\").kind(\"user\").name(\"Sandy\").build()\n", + "agent_config = ai_client.agent_config(CONFIG_KEY, context)\n", + "if not agent_config.enabled:\n", + " raise RuntimeError(\"Agent Config is disabled\")\n", + "\n", + "print(f\"Provider: {agent_config.provider.name if agent_config.provider else 'n/a'}\")\n", + "print(f\"Model: {agent_config.model.name}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:06:51.620387Z", + "iopub.status.busy": "2026-05-13T23:06:51.620233Z", + "iopub.status.idle": "2026-05-13T23:06:51.626140Z", + "shell.execute_reply": "2026-05-13T23:06:51.625550Z" + } + }, + "outputs": [], + "source": [ + "def create_strands_model(cfg):\n", + " \"\"\"Map an LDAIAgentConfig to the matching Strands model class.\n", + "\n", + " Dispatches on `provider.name` first; for Bedrock variations (which omit\n", + " `modelConfigKey` and so have no provider field) falls back to the standard\n", + " Bedrock model-id prefixes (`us.`, `eu.`, `apac.`, `anthropic.`, `amazon.`,\n", + " `meta.`).\n", + " \"\"\"\n", + " provider = (cfg.provider.name if cfg.provider else \"\").lower()\n", + " model_id = cfg.model.name\n", + " params = dict(cfg.model.to_dict().get(\"parameters\") or {})\n", + " # LD surfaces attached tools via `parameters.tools`; Strands gets tools\n", + " # through the Agent constructor, so drop it from model params.\n", + " params.pop(\"tools\", None)\n", + "\n", + " is_bedrock = provider == \"bedrock\" or model_id.startswith(\n", + " (\"us.\", \"eu.\", \"apac.\", \"anthropic.\", \"amazon.\", \"meta.\")\n", + " )\n", + "\n", + " if is_bedrock:\n", + " # BedrockModel takes flat kwargs (no `params` dict): pass model_id +\n", + " # known inference fields as kwargs, drop anything else into\n", + " # additional_request_fields so it still reaches the Converse API.\n", + " region = (\n", + " params.pop(\"region_name\", None)\n", + " or os.environ.get(\"AWS_REGION\")\n", + " or \"us-west-2\"\n", + " )\n", + " known = {\n", + " k: params.pop(k)\n", + " for k in (\"max_tokens\", \"temperature\", \"top_p\", \"stop_sequences\")\n", + " if k in params\n", + " }\n", + " if \"max_tokens\" not in known:\n", + " known[\"max_tokens\"] = 1024\n", + " return BedrockModel(\n", + " model_id=model_id,\n", + " region_name=region,\n", + " additional_request_fields=params or None,\n", + " **known,\n", + " )\n", + " if provider == \"anthropic\":\n", + " # AnthropicModel requires max_tokens as a kwarg, not in params.\n", + " max_tokens = int(params.pop(\"max_tokens\", None) or params.pop(\"maxTokens\", None) or 1024)\n", + " return AnthropicModel(model_id=model_id, max_tokens=max_tokens, params=params or None)\n", + " if provider == \"openai\":\n", + " # gpt-5 wants max_completion_tokens; gpt-4o wants max_tokens. Keep that\n", + " # choice in the LD variation's parameters and pass through as-is.\n", + " return OpenAIModel(model_id=model_id, params=params)\n", + " raise ValueError(f\"Unsupported provider for Strands: {provider!r}\")\n", + "\n", + "\n", + "model = create_strands_model(agent_config)\n", + "\n", + "# Resolve the agent's tool list from the LD variation, not from a hardcoded\n", + "# Python list. `agent_config.model.parameters['tools']` is the OpenAI-style\n", + "# function shape LD returns; we only need the names to look up the local\n", + "# handlers from TOOL_REGISTRY.\n", + "ld_tool_params = (agent_config.model.to_dict().get(\"parameters\") or {}).get(\"tools\") or []\n", + "ld_tool_names = [t[\"name\"] for t in ld_tool_params]\n", + "\n", + "resolved_tools = []\n", + "for name in ld_tool_names:\n", + " handler = TOOL_REGISTRY.get(name)\n", + " if handler is None:\n", + " print(f\"[WARN] LD attached tool '{name}' has no local handler \u2014 skipping\")\n", + " continue\n", + " resolved_tools.append(handler)\n", + "print(f\"[INFO] Tools from LD: {ld_tool_names}\")\n", + "\n", + "# SlidingWindowConversationManager gives the agent short-term memory \u2014 reuse\n", + "# the same Agent across invoke_async calls and it carries context across turns.\n", + "agent = Agent(\n", + " name=\"order-assistant\",\n", + " model=model,\n", + " system_prompt=agent_config.instructions,\n", + " tools=resolved_tools,\n", + " conversation_manager=SlidingWindowConversationManager(window_size=40),\n", + ")\n", + "print(\"[OK] Strands agent ready\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Run three turns\n", + "\n", + "Strands' `ConversationManager` carries context between `invoke_async` calls \u2014 no explicit thread id required. LaunchDarkly's tracker records duration, token usage, and success/error per turn.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:06:51.627599Z", + "iopub.status.busy": "2026-05-13T23:06:51.627495Z", + "iopub.status.idle": "2026-05-13T23:07:03.714469Z", + "shell.execute_reply": "2026-05-13T23:07:03.713475Z" + } + }, + "outputs": [], + "source": [ + "from ldai.providers.types import LDAIMetrics\n", + "\n", + "\n", + "def strands_metrics_extractor(result):\n", + " \"\"\"Pull token usage off a Strands AgentResult into an LDAIMetrics.\n", + "\n", + " Returned to track_metrics_of_async, which fires duration/success/tokens\n", + " atomically. Tool calls stay tracked from inside the @tool body so we don't\n", + " double-count.\n", + " \"\"\"\n", + " usage = getattr(result.metrics, \"accumulated_usage\", {}) or {}\n", + " input_tokens = usage.get(\"inputTokens\", 0)\n", + " output_tokens = usage.get(\"outputTokens\", 0)\n", + " total = usage.get(\"totalTokens\", 0) or (input_tokens + output_tokens)\n", + " return LDAIMetrics(\n", + " success=True,\n", + " usage=TokenUsage(input=input_tokens, output=output_tokens, total=total) if total > 0 else None,\n", + " duration_ms=None, # let the SDK use wall-clock elapsed\n", + " )\n", + "\n", + "\n", + "async def run_turn(user_input):\n", + " # Fresh tracker per turn (SDK 0.18+ enforces at-most-once per execution).\n", + " # Publish to module global so the @tool body fires track_tool_call on the\n", + " # same tracker the metrics extractor finalizes. Reinit the LDClient if a\n", + " # prior cleanup cell closed it.\n", + " global _tracker, ai_client, agent_config\n", + " _ld = ldclient.get()\n", + " # is_initialized() is a one-way latch; it stays True even after close().\n", + " # The private _closed flag is the only reliable signal of a dead client.\n", + " _closed = getattr(_ld, \"_closed\", False) or getattr(_ld, \"_LDClient__closed\", False)\n", + " if (not _ld.is_initialized()) or _closed:\n", + " ldclient.set_config(Config(SDK_KEY))\n", + " if not ldclient.get().is_initialized():\n", + " raise RuntimeError(\"LaunchDarkly SDK failed to initialize\")\n", + " ai_client = LDAIClient(ldclient.get())\n", + " agent_config = ai_client.agent_config(CONFIG_KEY, context)\n", + " print(\"[INFO] Reinitialized LaunchDarkly client (was closed or stale)\")\n", + " _tracker = agent_config.create_tracker()\n", + " try:\n", + " result = await _tracker.track_metrics_of_async(\n", + " strands_metrics_extractor,\n", + " lambda: agent.invoke_async(user_input),\n", + " )\n", + " print(f\"> {user_input}\")\n", + " print(f\" {result.message['content'][0]['text']}\\n\")\n", + " except Exception as e:\n", + " # track_metrics_of_async records track_duration + track_error on exceptions\n", + " # before re-raising; we just surface the failure here.\n", + " print(f\"[ERROR] {e}\")\n", + "\n", + "\n", + "async def main():\n", + " await run_turn(\"What's the status of order ORD-123?\")\n", + " await run_turn(\"What about ORD-456?\")\n", + " await run_turn(\"Summarize both orders for me.\")\n", + "\n", + "await main()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. Wire the agent into a graph\n", + "\n", + "LaunchDarkly Agent Graphs let you compose multiple AI Configs into a multi-agent system: one root config, one or more target configs, and named edges (with optional `handoff` metadata) describing the routing between them. The Python SDK exposes the result via `ai_client.agent_graph(GRAPH_KEY, context)` \u2014 your application code reads the topology and dispatches between agents accordingly.\n", + "\n", + "This section creates a second AI Config \u2014 `strands-specialist-agent` \u2014 and wires `strands-agent` \u2192 `strands-specialist-agent` as a one-edge graph. The actual orchestration (when to delegate to the specialist) stays in your application code; LaunchDarkly's job is to declare the graph and serve the right variation of each node per user.\n", + "\n", + "### Endpoints used\n", + "- `POST /projects/{project}/ai-configs` \u2014 create the specialist node\n", + "- `POST /ai-configs/{key}/variations` \u2014 give it a variation\n", + "- `PATCH /ai-configs/{key}/targeting` \u2014 enable + set fallthrough\n", + "- `POST /projects/{project}/agent-graphs` \u2014 create the graph with a `rootConfigKey`\n", + "- `PATCH /agent-graphs/{key}` \u2014 add edges (must include `rootConfigKey` *and* `edges`; edges sent alone are silently ignored)\n", + "- `PATCH /agent-graphs/{key}` with `instructions: [{kind: \"turnTargetingOn\"}]` \u2014 enable the graph in production" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:07:03.717445Z", + "iopub.status.busy": "2026-05-13T23:07:03.717231Z", + "iopub.status.idle": "2026-05-13T23:07:09.547774Z", + "shell.execute_reply": "2026-05-13T23:07:09.546836Z" + } + }, + "outputs": [], + "source": [ + "SPECIALIST_KEY = \"strands-specialist-agent\"\n", + "GRAPH_KEY = \"strands-agent-graph\"\n", + "\n", + "# 0. Delete the graph FIRST on re-runs. While a graph references a config as\n", + "# root or as an edge target, LD blocks `DELETE /ai-configs/{key}` with 409.\n", + "# Removing the graph first lets the specialist delete-then-create cycle work.\n", + "requests.delete(f\"{BASE_URL}/projects/{PROJECT_KEY}/agent-graphs/{GRAPH_KEY}\", headers=HEADERS)\n", + "time.sleep(0.5)\n", + "\n", + "# 1. Create the specialist AI Config (the second node in the graph).\n", + "requests.delete(f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs/{SPECIALIST_KEY}\", headers=HEADERS)\n", + "time.sleep(0.5)\n", + "r = requests.post(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs\",\n", + " headers=HEADERS,\n", + " json={\"key\": SPECIALIST_KEY, \"name\": \"Strands Specialist Agent\", \"mode\": \"agent\"},\n", + ")\n", + "if r.status_code == 201:\n", + " print(f\"[OK] Specialist Config: {SPECIALIST_KEY}\")\n", + "elif r.status_code == 409 or (r.status_code == 400 and \"enabled\" in r.text):\n", + " print(f\"[INFO] Specialist Config already exists: {SPECIALIST_KEY}\")\n", + "else:\n", + " print(f\"[ERROR] {r.status_code}: {r.text[:200]}\")\n", + "print(f\"[INFO] https://app.launchdarkly.com/projects/{PROJECT_KEY}/ai-configs/{SPECIALIST_KEY}\")\n", + "\n", + "# 2. Add a single variation so the specialist serves something when invoked.\n", + "r = requests.post(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs/{SPECIALIST_KEY}/variations\",\n", + " headers=HEADERS,\n", + " json={\n", + " \"key\": \"specialist-default\",\n", + " \"name\": \"Specialist Default\",\n", + " \"modelConfigKey\": \"OpenAI.gpt-5\",\n", + " \"model\": {\"modelName\": \"gpt-5\", \"parameters\": {\"max_completion_tokens\": 4096}},\n", + " \"messages\": [],\n", + " \"instructions\": (\n", + " \"You are a deep-reasoning order operations specialist. The triage agent \"\n", + " \"hands off to you for follow-up analysis: investigation steps, escalation \"\n", + " \"paths, communication templates, root-cause reasoning. The triage agent's \"\n", + " \"lookup result is included in your context \u2014 use it. Be thorough and \"\n", + " \"concrete. No further handoff is needed; produce the full analysis.\"\n", + " ),\n", + " },\n", + ")\n", + "print(f\"[{'OK' if r.status_code == 201 else 'INFO'}] Specialist variation: specialist-default ({r.status_code})\")\n", + "\n", + "# 3. Enable the specialist by pointing fallthrough at the variation we just made.\n", + "time.sleep(0.5)\n", + "r = requests.get(f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs/{SPECIALIST_KEY}/targeting\", headers=HEADERS)\n", + "spec_vmap = {\n", + " v.get(\"value\", {}).get(\"_ldMeta\", {}).get(\"variationKey\"): v[\"_id\"]\n", + " for v in r.json().get(\"variations\", [])\n", + " if v.get(\"name\") != \"disabled\"\n", + "}\n", + "spec_id = spec_vmap.get(\"specialist-default\")\n", + "if spec_id:\n", + " r = requests.patch(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs/{SPECIALIST_KEY}/targeting\",\n", + " headers=PATCH_HEADERS,\n", + " json={\n", + " \"environmentKey\": ENVIRONMENT,\n", + " \"instructions\": [{\"kind\": \"updateFallthroughVariationOrRollout\", \"variationId\": spec_id}],\n", + " },\n", + " )\n", + " print(f\"[{'OK' if r.status_code == 200 else 'INFO'}] Specialist fallthrough set ({r.status_code})\")\n", + "\n", + "# 4. Create the Agent Graph with strands-agent as the root.\n", + "r = requests.post(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/agent-graphs\",\n", + " headers=HEADERS,\n", + " json={\n", + " \"key\": GRAPH_KEY,\n", + " \"name\": \"Strands Agent Graph\",\n", + " \"description\": \"Root strands-agent with a deep-reasoning specialist.\",\n", + " \"rootConfigKey\": CONFIG_KEY,\n", + " },\n", + ")\n", + "print(f\"[{'OK' if r.status_code == 201 else 'INFO'}] Agent Graph: {GRAPH_KEY} ({r.status_code})\")\n", + "\n", + "# 5. Add the edge. NOTE: the API requires `rootConfigKey` to be re-sent with\n", + "# `edges`; edges sent alone are silently ignored.\n", + "r = requests.patch(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/agent-graphs/{GRAPH_KEY}\",\n", + " headers=HEADERS,\n", + " json={\n", + " \"rootConfigKey\": CONFIG_KEY,\n", + " \"edges\": [{\n", + " \"key\": \"edge-strands-specialist\",\n", + " \"sourceConfig\": CONFIG_KEY,\n", + " \"targetConfig\": SPECIALIST_KEY,\n", + " \"handoff\": {\"route\": \"specialist\", \"reason\": \"needs deep reasoning\"},\n", + " }],\n", + " },\n", + ")\n", + "print(f\"[{'OK' if r.status_code == 200 else 'ERROR'}] Edge added: {CONFIG_KEY} \u2192 {SPECIALIST_KEY}\")\n", + "\n", + "# 6. Turn the graph on in production. Without this the SDK reads it back as disabled.\n", + "r = requests.patch(\n", + " f\"{BASE_URL}/projects/{PROJECT_KEY}/agent-graphs/{GRAPH_KEY}\",\n", + " headers=HEADERS,\n", + " json={\"instructions\": [{\"kind\": \"turnTargetingOn\"}]},\n", + ")\n", + "print(f\"[{'OK' if r.status_code == 200 else 'ERROR'}] Graph enabled in {ENVIRONMENT}\")\n", + "print(f\"[INFO] https://app.launchdarkly.com/projects/{PROJECT_KEY}/ai/graphs?env={ENVIRONMENT}&selected-env={ENVIRONMENT}\")\n", + "\n", + "# 7. Read it back via the SDK and print the topology.\n", + "graph = ai_client.agent_graph(GRAPH_KEY, context)\n", + "print(f\"\\nGraph enabled: {graph.is_enabled()}\")\n", + "root = graph.root()\n", + "if root:\n", + " print(f\"Root: {root.get_key()}\")\n", + " for edge in root.get_edges():\n", + " print(f\" \u2192 {edge.target_config} (handoff: {edge.handoff})\")\n", + "print(\n", + " \"\\n[INFO] Application code reads `graph.root()` / `graph.get_child_nodes(...)` \"\n", + " \"and dispatches between agents itself; LaunchDarkly only declares the topology.\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. Graph-driven dispatcher\n", + "\n", + "Section 7 invokes the root agent directly; section 8 only *declares* the graph topology. To make the LaunchDarkly graph actually drive Strands behavior \u2014 so adding a node + edge in the LaunchDarkly UI changes which agents run, with no code change \u2014 fetch the graph at runtime and traverse it.\n", + "\n", + "Three pieces, all parameterized on whatever the graph contains:\n", + "\n", + "1. **`build_strands_agent(node_key, config, valid_routes)`** \u2014 generic Strands `Agent` builder. Reads model class, instructions, parameters, and the tool list from the AI Config. If the node has outgoing edges, appends a routing instruction so the LLM emits `{\"route\": \"...\"}` at the end of its response.\n", + "2. **`extract_route(text, valid_routes)`** \u2014 parses that JSON and validates against the edges' `handoff.route` values.\n", + "3. **`execute_graph(graph_key, user_input, context)`** \u2014 walks the graph: at each node, build the agent, invoke it with the previous node's output, parse the route, jump to the matching edge target. Terminates at the first node with no outgoing edges. Tracks per-node metrics with `config.create_tracker()` (duration / tokens / success+error / tool calls) **and** graph-level handoffs + final path with `graph.create_tracker()`.\n", + "\n", + "`GRAPH_KEY` is the only key the notebook hardcodes from here on \u2014 every agent is materialized from whatever the graph contains at runtime." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import re\n", + "\n", + "\n", + "def build_strands_agent(node_key, config, edges):\n", + " \"\"\"Build a Strands Agent generically from any AIAgentConfig.\n", + "\n", + " Reads model, instructions, parameters, and attached tools from the config.\n", + " The routing protocol (JSON envelope, when to hand off) lives in each AI\n", + " Config's `instructions` field in LaunchDarkly \u2014 this builder only surfaces\n", + " the runtime-derived *list* of available routes from the graph's edges, so\n", + " that adding/removing an edge in LD changes what the LLM sees, with no code\n", + " change.\n", + " \"\"\"\n", + " model = create_strands_model(config)\n", + " instructions = config.instructions or \"Process the input and respond helpfully.\"\n", + "\n", + " # Build the runtime route list from edges (LD-stored handoff metadata).\n", + " # `route` is the value the LLM emits; `reason` is the editable description.\n", + " route_lines = []\n", + " for e in edges:\n", + " if not e.handoff:\n", + " continue\n", + " route = e.handoff.get(\"route\")\n", + " if not route:\n", + " continue\n", + " reason = e.handoff.get(\"reason\")\n", + " route_lines.append(f\"- `{route}` \u2014 {reason}\" if reason else f\"- `{route}`\")\n", + "\n", + " if route_lines:\n", + " instructions += \"\\n\\nAvailable routes:\\n\" + \"\\n\".join(route_lines)\n", + "\n", + " # Tool list from LD, resolved against the local TOOL_REGISTRY.\n", + " ld_tool_params = (config.model.to_dict().get(\"parameters\") or {}).get(\"tools\") or []\n", + " tool_names = [t[\"name\"] for t in ld_tool_params]\n", + " resolved_tools = [TOOL_REGISTRY[n] for n in tool_names if n in TOOL_REGISTRY]\n", + " missing = [n for n in tool_names if n not in TOOL_REGISTRY]\n", + " if missing:\n", + " print(f\"[WARN] {node_key}: LD attached tools {missing} have no local handler\")\n", + "\n", + " return Agent(\n", + " name=node_key,\n", + " model=model,\n", + " system_prompt=instructions,\n", + " tools=resolved_tools,\n", + " conversation_manager=SlidingWindowConversationManager(window_size=40),\n", + " # Suppress Strands' default stdout streaming so the dispatcher prints\n", + " # each agent's response exactly once, under its banner.\n", + " callback_handler=None,\n", + " )\n", + "\n", + "\n", + "def extract_route(text, valid_routes):\n", + " \"\"\"Find a {\"route\": \"...\"} object in the LLM response and validate it.\"\"\"\n", + " if not valid_routes:\n", + " return None\n", + " patterns = [\n", + " r'```json\\s*(\\{[^`]+\\})\\s*```',\n", + " r'(\\{\"route\":\\s*\"[^\"]+\"\\s*\\})',\n", + " r'(\\{[^{}]*\"route\"\\s*:\\s*\"[^\"]+\"[^{}]*\\})',\n", + " ]\n", + " valid_lower = [r.lower() for r in valid_routes]\n", + " for pat in patterns:\n", + " m = re.search(pat, text, re.DOTALL)\n", + " if not m:\n", + " continue\n", + " try:\n", + " data = json.loads(m.group(1))\n", + " except json.JSONDecodeError:\n", + " continue\n", + " route = (data.get(\"route\") or \"\").lower().strip()\n", + " if route in valid_lower:\n", + " return valid_routes[valid_lower.index(route)]\n", + " return None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "async def execute_graph(graph_key, user_input, context):\n", + " \"\"\"Walk the LaunchDarkly Agent Graph, invoking each node's Strands agent.\n", + "\n", + " No hardcoded agent keys. Routing decisions come from each agent's response\n", + " (a JSON route), validated against the node's outgoing edges. If a node has\n", + " edges but the agent does not emit a route, the dispatcher terminates at that\n", + " node \u2014 handoffs are optional, governed by the agent's instructions.\n", + "\n", + " Self-heals across re-runs: if the LDClient was closed by a prior cleanup\n", + " cell (or never initialized in this kernel), reinitialize it + rebuild the\n", + " LDAIClient wrapper before fetching the graph. Once initialized, the SDK's\n", + " streaming connection keeps state fresh within ~1s of any LD UI change.\n", + " \"\"\"\n", + " global _tracker, ai_client\n", + " _ld = ldclient.get()\n", + " # is_initialized() is a one-way latch; it stays True even after close().\n", + " # The private _closed flag is the only reliable signal of a dead client.\n", + " _closed = getattr(_ld, \"_closed\", False) or getattr(_ld, \"_LDClient__closed\", False)\n", + " if (not _ld.is_initialized()) or _closed:\n", + " ldclient.set_config(Config(SDK_KEY))\n", + " if not ldclient.get().is_initialized():\n", + " raise RuntimeError(\"LaunchDarkly SDK failed to initialize\")\n", + " ai_client = LDAIClient(ldclient.get())\n", + " print(\"[INFO] Reinitialized LaunchDarkly client (was closed or stale)\")\n", + " graph = ai_client.agent_graph(graph_key, context)\n", + " if not graph.is_enabled():\n", + " raise RuntimeError(f\"Agent Graph '{graph_key}' is not enabled\")\n", + "\n", + " graph_tracker = graph.create_tracker()\n", + "\n", + " nodes = {}\n", + " graph.reverse_traverse(lambda n, _: nodes.update({n.get_key(): n}), {})\n", + " print(f\"[INFO] Graph '{graph_key}' has {len(nodes)} node(s): {list(nodes.keys())}\")\n", + "\n", + " current = graph.root()\n", + " visited = set()\n", + " path = []\n", + " current_input = user_input\n", + " prev_key = None\n", + "\n", + " try:\n", + " while current is not None:\n", + " key = current.get_key()\n", + " if key in visited:\n", + " raise RuntimeError(f\"Cycle detected at node: {key}\")\n", + " visited.add(key)\n", + " path.append(key)\n", + "\n", + " config = current.get_config()\n", + " edges = current.get_edges()\n", + " valid_routes = [e.handoff[\"route\"] for e in edges if e.handoff and e.handoff.get(\"route\")]\n", + "\n", + " if prev_key is not None:\n", + " graph_tracker.track_handoff_success(prev_key, key)\n", + "\n", + " agent = build_strands_agent(key, config, edges)\n", + " print(f\"\\n\u250c\u2500 INVOKED agent: {key} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\")\n", + " # Show the input this agent receives. For the root that's the\n", + " # user's original query; for downstream agents it's the prior\n", + " # agent's full response (the conversation hop).\n", + " preview = current_input if len(current_input) <= 600 else current_input[:600] + \"...\"\n", + " print(f\"\u2502 input ({len(current_input)} chars):\")\n", + " for line in preview.splitlines() or [\"\"]:\n", + " print(f\"\u2502 {line}\")\n", + " print(\"\u251c\u2500 response \u2500\")\n", + "\n", + " _tracker = config.create_tracker()\n", + " try:\n", + " result = await _tracker.track_metrics_of_async(\n", + " strands_metrics_extractor,\n", + " lambda: agent.invoke_async(current_input),\n", + " )\n", + " except Exception as e:\n", + " print(f\"\u2514\u2500 [ERROR] {key}: {e}\")\n", + " raise\n", + "\n", + " response_text = result.message[\"content\"][0][\"text\"]\n", + " print(response_text)\n", + " print(f\"\u2514\u2500 done: {key}\")\n", + "\n", + " if not edges:\n", + " break\n", + "\n", + " route = extract_route(response_text, valid_routes)\n", + " if route is None:\n", + " # Agent omitted the JSON envelope \u2014 that's the \"stop here\" signal.\n", + " # No handoff attempted, no failure event.\n", + " print(f\"[INFO] {key} omitted route JSON \u2014 terminating here.\")\n", + " break\n", + "\n", + " next_key = None\n", + " for edge in edges:\n", + " if edge.handoff and edge.handoff.get(\"route\") == route:\n", + " next_key = edge.target_config\n", + " break\n", + " if next_key is None:\n", + " # Emitted an unrecognized route \u2014 record a real handoff failure\n", + " # against the intended target (first edge) and stop.\n", + " target_guess = edges[0].target_config\n", + " graph_tracker.track_handoff_failure(key, target_guess)\n", + " print(f\"[INFO] {key} emitted unrecognized route '{route}'; stopping.\")\n", + " break\n", + "\n", + " print(f\"[INFO] {key} chose route '{route}' \u2192 {next_key}\")\n", + " prev_key = key\n", + " current = nodes.get(next_key)\n", + " current_input = response_text # downstream agent sees prior agent's output\n", + "\n", + " graph_tracker.track_path(path)\n", + " graph_tracker.track_invocation_success()\n", + " print(f\"\\n[OK] Path invoked: {' \u2192 '.join(path)}\")\n", + " except Exception:\n", + " graph_tracker.track_invocation_failure()\n", + " raise\n", + "\n", + "\n", + "# Two queries demonstrating both branches:\n", + "# - Simple status lookup: triage answers, no handoff (only strands-agent fires)\n", + "# - Complex follow-up: triage hands off to specialist (both fire)\n", + "\n", + "print(\"\\n========== Query 1: status only ==========\")\n", + "await execute_graph(GRAPH_KEY, \"What's the status of order ORD-789?\", context)\n", + "\n", + "print(\"\\n========== Query 2: needs analysis ==========\")\n", + "await execute_graph(\n", + " GRAPH_KEY,\n", + " \"Order ORD-456 has been stuck in 'Processing' for 5 days. The customer is asking why. \"\n", + " \"Walk me through how to investigate and what to say to the customer.\",\n", + " context,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:07:09.550988Z", + "iopub.status.busy": "2026-05-13T23:07:09.550705Z", + "iopub.status.idle": "2026-05-13T23:07:09.647445Z", + "shell.execute_reply": "2026-05-13T23:07:09.646385Z" + } + }, + "outputs": [], + "source": [ + "# Flush buffered events so they reach LaunchDarkly. We intentionally do NOT\n", + "# call ldclient.get().close() here \u2014 close() puts the singleton into a state\n", + "# where subsequent ai_client.agent_graph(...) calls hit a cached snapshot\n", + "# (and emit \"evaluation attempted before client has initialized\" warnings)\n", + "# instead of the live LD state. Kernel shutdown releases the connection.\n", + "ldclient.get().flush()\n", + "print(\"[OK] Done\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-13T23:07:09.650241Z", + "iopub.status.busy": "2026-05-13T23:07:09.650045Z", + "iopub.status.idle": "2026-05-13T23:07:09.652678Z", + "shell.execute_reply": "2026-05-13T23:07:09.651986Z" + } + }, + "outputs": [], + "source": [ + "# Uncomment to delete resources\n", + "# requests.delete(f\"{BASE_URL}/projects/{PROJECT_KEY}/ai-configs/{CONFIG_KEY}\", headers=HEADERS)\n", + "# requests.delete(f\"{BASE_URL}/projects/{PROJECT_KEY}\", headers=HEADERS)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## What this sample showed\n", + "\n", + "- **One agent config, three providers.** `create_strands_model` picks the Strands model class from `config.provider.name` (with a Bedrock model-id fallback). No per-provider branching in your code.\n", + "- **Tools driven by LaunchDarkly.** Each agent's tool list comes from `config.model.parameters['tools']`, resolved against a local `TOOL_REGISTRY`. Detach `get_order_status` from a variation in the LaunchDarkly UI and the next invocation has no tools \u2014 no code change.\n", + "- **Governed tools.** `get_order_status` is registered centrally via `POST /ai-tools` and attached per-variation via `PATCH /ai-configs/.../variations/{id}`. The Python handler stays local; LaunchDarkly tracks the schema + version.\n", + "- **Multi-turn memory.** Strands' `SlidingWindowConversationManager` plus a reused `Agent` instance \u2014 no thread IDs, no explicit state store. Section 7 demonstrates this on the root agent.\n", + "- **Per-config tracking.** `config.create_tracker().track_duration_of` + `track_success/error` + `track_tokens`, plus `track_tool_call` from inside the `@tool` body, feed the AI Config's Monitoring tab.\n", + "- **Graph-driven multi-agent dispatch.** Section 9's `execute_graph` is the load-bearing piece: it reads the topology from LaunchDarkly at runtime, builds a Strands `Agent` per node generically from `node.get_config()`, and routes between agents based on `edge.handoff.route` values that each LLM selects from the valid set. The graph tracker records handoffs + the final path.\n", + "- **The only key in the runtime path is `GRAPH_KEY`.** Add a node + edge in the LaunchDarkly UI, re-run section 9, and the new agent participates \u2014 no code change.\n", + "\n", + "## Try it\n", + "\n", + "- **Swap providers.** Set the default variation on `strands-agent` to `claude-sonnet-agent` or `bedrock-claude-agent` and re-run section 9 \u2014 same agent code, different provider.\n", + "- **Add an agent.** Create a third AI Config (e.g. `strands-fraud-agent`), add it as a node in the `strands-agent-graph`, and add an edge from `strands-agent` with `handoff: {route: \"fraud\"}`. Re-run section 9 with a fraud-flavored query and watch the new node light up in the Agent Graph view.\n", + "- **Detach a tool.** Detach `get_order_status` from the served variation in the UI and re-run \u2014 the root agent stops calling it.\n", + "\n", + "## Additional resources\n", + "- [LaunchDarkly AI Configs documentation](https://launchdarkly.com/docs/home/ai-configs)\n", + "- [LaunchDarkly Agent Graphs](https://launchdarkly.com/docs/home/ai-configs/agent-graphs)\n", + "- [LaunchDarkly AI SDK for Python](https://github.com/launchdarkly/python-server-sdk-ai)\n", + "- [Strands Agents SDK](https://github.com/strands-agents/sdk-python)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/python/03-integrate/runtime-control/launchdarkly/README.md b/python/03-integrate/runtime-control/launchdarkly/README.md new file mode 100644 index 00000000..40fa2729 --- /dev/null +++ b/python/03-integrate/runtime-control/launchdarkly/README.md @@ -0,0 +1,110 @@ +# Strands Agents with LaunchDarkly AI Configs + +This sample shows how to drive a [Strands](https://strandsagents.com) agent entirely from [LaunchDarkly AI Configs](https://launchdarkly.com/docs/home/ai-configs): the model, the instructions, the parameters, the tool list, **and** the multi-agent graph topology all live in LaunchDarkly. The notebook builds a multi-provider order-status triage agent (OpenAI, Anthropic, and Bedrock-hosted Claude), attaches a governed tool, composes a second "specialist" agent into an Agent Graph, then runs a generic graph-driven dispatcher that materializes Strands `Agent` instances from each node at runtime. + +Provider, tool list, agent instructions, and graph shape can all be changed from the LaunchDarkly UI — no code change, no redeploy. + +## Prerequisites + +* Python 3.10+ +* A [LaunchDarkly account](https://launchdarkly.com/start-trial) with an API token that has the **Writer** role (the notebook creates the project, AI Configs, variations, tool, and graph for you) +* `OPENAI_API_KEY` for the `gpt-5-agent` variation and the specialist +* `ANTHROPIC_API_KEY` for the `claude-sonnet-agent` variation +* *(Optional)* AWS credentials with Bedrock model access for the `bedrock-claude-agent` variation. The variation is created either way; serving it only succeeds if Bedrock is reachable. + +## Setup Instructions + +1. Clone the repository and change into this directory: + + ```bash + git clone https://github.com/strands-agents/samples.git + cd samples/python/03-integrate/runtime-control/launchdarkly + ``` + +2. Install dependencies (the notebook's first cell does this for you if you'd rather skip it): + + ```bash + pip install -r requirements.txt + ``` + +3. Create a `.env` file in this directory: + + ```bash + LAUNCHDARKLY_API_TOKEN=api-... + OPENAI_API_KEY=sk-... + ANTHROPIC_API_KEY=sk-ant-... + + # Optional overrides + # LAUNCHDARKLY_PROJECT_KEY=strands-launchdarkly-sample + # LAUNCHDARKLY_PROJECT_NAME=Strands + LaunchDarkly Sample + # LAUNCHDARKLY_ENVIRONMENT=production + + # Optional: only required if you want the Bedrock variation to serve + # AWS_ACCESS_KEY_ID=... + # AWS_SECRET_ACCESS_KEY=... + # AWS_REGION=us-west-2 + ``` + +4. Run the notebook: + + ```bash + jupyter notebook LaunchDarkly-AI-Configs-strands.ipynb + ``` + + The notebook is self-contained: it creates the LaunchDarkly project, the `strands-agent` triage AI Config (three variations), a governed `get_order_status` tool, default targeting, a `strands-specialist-agent` AI Config, and an Agent Graph that wires them together with a `handoff` edge. Re-running is idempotent. + +5. After the run, open the AI Config in LaunchDarkly and switch to the **Monitoring** tab to see invocation count, token usage, duration, tool-call counts, and error rate per variation. The **Agent Graph** view shows per-node metrics + the handoff edges. + +## What You'll Learn + +* **Map an `AIAgentConfig` to a Strands model class.** `create_strands_model` dispatches on `config.provider.name` (with a Bedrock model-id fallback) — no per-provider branching in your code. +* **Drive an agent's tool list from LaunchDarkly.** Tools come from `config.model.parameters['tools']`, resolved against a local `TOOL_REGISTRY` at runtime. Detach a tool in the UI and the next invocation has no tools. +* **Govern tool schemas centrally.** Register tools with `POST /ai-tools` and attach per-variation via `PATCH /ai-configs/.../variations/{key}`. +* **Track per-agent metrics correctly for async work.** `tracker.track_metrics_of_async(extractor, lambda: agent.invoke_async(...))` atomically fires duration + success/error + tokens; tool calls are tracked from the `@tool` body via a fresh per-invocation tracker. +* **Compose multiple AI Configs into an Agent Graph.** Each edge carries `handoff` metadata (a `route` key the LLM emits + a human-readable `reason`). +* **Drive multi-agent behavior from the graph at runtime.** `execute_graph` walks `graph.root().get_edges()`, builds a Strands `Agent` per node from `node.get_config()`, parses the LLM's `{"route": "..."}` envelope, and jumps to the matching edge target — *or terminates cleanly when no handoff is needed*. The dispatcher also records graph-level handoff success/failure + the final path via `graph.create_tracker()`. +* **Adding a node + edge in LaunchDarkly changes runtime behavior without code changes.** The only key the dispatcher hardcodes is `GRAPH_KEY`. + +## What the notebook prints + +Section 9 runs two queries that exercise both branches of the graph: + +``` +========== Query 1: status only ========== +┌─ INVOKED agent: strands-agent ─ +│ input (40 chars): +│ What's the status of order ORD-789? +├─ response ─ +Order ORD-789 was delivered on Monday. +└─ done: strands-agent +[INFO] strands-agent omitted route JSON — terminating here. +[OK] Path invoked: strands-agent + +========== Query 2: needs analysis ========== +┌─ INVOKED agent: strands-agent ─ +[one-line status + {"route": "specialist"}] +└─ done: strands-agent +[INFO] strands-agent chose route 'specialist' → strands-specialist-agent +┌─ INVOKED agent: strands-specialist-agent ─ +[full investigation + comms templates + escalation path] +└─ done: strands-specialist-agent +[OK] Path invoked: strands-agent → strands-specialist-agent +``` + +## Changing the graph at runtime + +Add a node + edge in LaunchDarkly's Agent Graph UI, save, and re-run the section 9 cell. The dispatcher re-fetches the live topology on every call and self-heals the SDK client if a prior cleanup closed it, so new nodes show up without restarting the kernel. The most reliable refresh after any LD UI change is still **Kernel → Restart and Run All**, which guarantees a fresh streaming connection. + +## Monitoring in LaunchDarkly + +After running the agent, view metrics on the AI Config's **Monitoring** tab, or open **Insights** under **AI** in the left navigation for aggregated cost, latency, error rate, and model-distribution metrics across every AI Config in your project. The **Agent Graph** view (same nav) shows the same metrics laid out by node + the edges between them. + +![LaunchDarkly AI Insights overview showing cost, latency, error rate, and invocation metrics for a Strands AI Config](images/launchdarkly-ai-insights.png) + +## Additional Resources + +* [LaunchDarkly + Strands guide](https://launchdarkly.com/docs/guides/ai-configs/strands) — the canonical walkthrough, including a Node.js example +* [LaunchDarkly AI Configs documentation](https://launchdarkly.com/docs/home/ai-configs) +* [LaunchDarkly Agent Graphs](https://launchdarkly.com/docs/home/ai-configs/agent-graphs) +* [LaunchDarkly Python AI SDK reference](https://launchdarkly.com/docs/sdk/ai/python) +* [Strands Agents documentation](https://strandsagents.com) diff --git a/python/03-integrate/runtime-control/launchdarkly/images/launchdarkly-ai-insights.png b/python/03-integrate/runtime-control/launchdarkly/images/launchdarkly-ai-insights.png new file mode 100644 index 00000000..4d6171cc Binary files /dev/null and b/python/03-integrate/runtime-control/launchdarkly/images/launchdarkly-ai-insights.png differ diff --git a/python/03-integrate/runtime-control/launchdarkly/requirements.txt b/python/03-integrate/runtime-control/launchdarkly/requirements.txt new file mode 100644 index 00000000..a7ff7f66 --- /dev/null +++ b/python/03-integrate/runtime-control/launchdarkly/requirements.txt @@ -0,0 +1,9 @@ +launchdarkly-server-sdk +launchdarkly-server-sdk-ai>=0.18.0 +strands-agents>=1.8.0 +strands-agents-tools>=0.2.0 +anthropic +openai +boto3 +python-dotenv +requests