diff --git a/skills/ai-configs/aiconfig-create/SKILL.md b/skills/ai-configs/aiconfig-create/SKILL.md index 845f491..46f271e 100644 --- a/skills/ai-configs/aiconfig-create/SKILL.md +++ b/skills/ai-configs/aiconfig-create/SKILL.md @@ -35,7 +35,7 @@ You're using a skill that will guide you through setting up AI configuration in Before creating, identify what you're building: -- **What framework?** LangGraph, LangChain, CrewAI, OpenAI SDK, Anthropic SDK, custom +- **What framework?** LangGraph, LangChain, CrewAI, Strands ([references/strands.md](references/strands.md)), OpenAI SDK, Anthropic SDK, custom - **What does the AI need?** Just text, or tools/function calling? - **Agent or completion?** See decision below @@ -44,7 +44,7 @@ Before creating, identify what you're building: | Your Need | Mode | |-----------|------| | Persistent instructions across interactions | **Agent** | -| LangGraph, CrewAI, AutoGen | **Agent** | +| LangGraph, CrewAI, AutoGen, Strands | **Agent** | | Direct OpenAI/Anthropic API calls | **Completion** | | Full control of message structure | **Completion** | | One-off text generation | **Completion** | diff --git a/skills/ai-configs/aiconfig-create/references/strands.md b/skills/ai-configs/aiconfig-create/references/strands.md new file mode 100644 index 0000000..70180ff --- /dev/null +++ b/skills/ai-configs/aiconfig-create/references/strands.md @@ -0,0 +1,122 @@ +# Strands Agents Integration + +Strands ([strandsagents.com](https://strandsagents.com)) builds agents with pluggable model classes (`OpenAIModel`, `AnthropicModel`, `BedrockModel`). LaunchDarkly picks *which* variation to serve at runtime; Strands instantiates the matching model class. The result: swap providers from the LaunchDarkly UI without changing application code. + +## Provider dispatch + +Map an `AIAgentConfig` to the right Strands model class with a single helper. Dispatch on `config.provider.name` (set by `modelConfigKey` on the variation) and fall back to model-id prefixes for Bedrock variations, which intentionally omit `modelConfigKey`: + +```python +from strands.models.openai import OpenAIModel +from strands.models.anthropic import AnthropicModel +from strands.models.bedrock import BedrockModel + +def create_strands_model(cfg): + provider = (cfg.provider.name if cfg.provider else "").lower() + model_id = cfg.model.name + params = dict(cfg.model.to_dict().get("parameters") or {}) + # Tools surface via parameters.tools — Strands takes them through the + # Agent constructor, not the model. Drop them here. + params.pop("tools", None) + + is_bedrock = provider == "bedrock" or model_id.startswith( + ("us.", "eu.", "apac.", "anthropic.", "amazon.", "meta.") + ) + if is_bedrock: + # BedrockModel takes flat kwargs; route known inference fields out of params. + known = {k: params.pop(k) for k in ("max_tokens", "temperature", "top_p", "stop_sequences") if k in params} + if "max_tokens" not in known: + known["max_tokens"] = 1024 + return BedrockModel(model_id=model_id, additional_request_fields=params or None, **known) + if provider == "anthropic": + # AnthropicModel requires max_tokens as a kwarg, not in params. + max_tokens = int(params.pop("max_tokens", None) or params.pop("maxTokens", None) or 1024) + return AnthropicModel(model_id=model_id, max_tokens=max_tokens, params=params or None) + if provider == "openai": + # gpt-5 wants max_completion_tokens; gpt-4o wants max_tokens. Keep that + # choice in the LD variation parameters and pass through as-is. + return OpenAIModel(model_id=model_id, params=params) + raise ValueError(f"Unsupported provider for Strands: {provider!r}") +``` + +## Variation parameter conventions + +LaunchDarkly stores parameters under `model.parameters`. Per-provider gotchas: + +| Provider | `modelConfigKey` | Key parameter | +|---|---|---| +| OpenAI gpt-5 | `OpenAI.gpt-5` | `max_completion_tokens` (NOT `max_tokens`; non-default temperature also rejected) | +| OpenAI gpt-4o / gpt-4 | `OpenAI.gpt-4o` | `max_tokens`, `temperature` | +| Anthropic | `Anthropic.claude-sonnet-4-6` | `max_tokens` (extracted as kwarg, not passed in `params`) | +| Bedrock-hosted Anthropic | *(omit)* | `model.modelName` like `us.anthropic.claude-sonnet-4-6`; requires AWS credentials | + +## Build the agent + +```python +from strands import Agent +from strands.agent.conversation_manager.sliding_window_conversation_manager import SlidingWindowConversationManager +from ldai.client import LDAIClient +import ldclient + +ldclient.set_config(ldclient.config.Config(SDK_KEY)) +ai_client = LDAIClient(ldclient.get()) +context = ldclient.Context.builder("user-123").kind("user").build() +config = ai_client.agent_config("strands-agent", context) + +agent = Agent( + name="order-assistant", + model=create_strands_model(config), + system_prompt=config.instructions, + tools=resolved_tools, # see aiconfig-tools/references/strands.md + conversation_manager=SlidingWindowConversationManager(window_size=40), + callback_handler=None, # suppress default stdout streaming +) +``` + +## Tracking async invocations correctly + +`tracker.track_duration_of(...)` is **synchronous-only**. Feeding it `lambda: agent.invoke_async(...)` only times the coroutine factory, not the awaited execution — duration is recorded as ~0ms and metrics look broken. Use `track_metrics_of_async` instead: + +```python +from ldai.providers.types import LDAIMetrics +from ldai.tracker import TokenUsage + +def strands_metrics_extractor(result): + usage = getattr(result.metrics, "accumulated_usage", {}) or {} + inp = usage.get("inputTokens", 0) + out = usage.get("outputTokens", 0) + total = usage.get("totalTokens", 0) or (inp + out) + return LDAIMetrics( + success=True, + usage=TokenUsage(input=inp, output=out, total=total) if total > 0 else None, + duration_ms=None, # SDK uses wall-clock elapsed + ) + +tracker = config.create_tracker() +result = await tracker.track_metrics_of_async( + strands_metrics_extractor, + lambda: agent.invoke_async(user_input), +) +``` + +This fires `track_duration`, `track_success`/`track_error`, and `track_tokens` atomically with the real elapsed time. Tool-call tracking stays in the `@tool` body (see `aiconfig-tools` Strands reference). + +## Self-heal pattern for re-runs + +`ldclient.is_initialized()` is a one-way latch: it stays True even after `close()`. In notebooks where the cleanup cell calls `close()`, re-running a downstream cell evaluates against the closed client and returns stale cached state with `[WARN] evaluation attempted before client has initialized`. Either drop the `close()` call (let kernel shutdown handle it) or self-heal at entry: + +```python +async def run_turn(user_input): + global ai_client, agent_config + _ld = ldclient.get() + closed = getattr(_ld, "_closed", False) or getattr(_ld, "_LDClient__closed", False) + if (not _ld.is_initialized()) or closed: + ldclient.set_config(Config(SDK_KEY)) + ai_client = LDAIClient(ldclient.get()) + agent_config = ai_client.agent_config(CONFIG_KEY, context) + # ...proceed +``` + +## Reference implementation + +The full pattern (3 provider variations + governed tools + agent graph + dispatcher) is published as a sample at [strands-agents/samples/python/03-integrate/runtime-control/launchdarkly](https://github.com/strands-agents/samples/tree/main/python/03-integrate/runtime-control/launchdarkly) and as a cookbook at [launchdarkly-labs/agentcontrol-cookbooks/strands.ipynb](https://github.com/launchdarkly-labs/agentcontrol-cookbooks/blob/main/strands.ipynb). diff --git a/skills/ai-configs/aiconfig-tools/SKILL.md b/skills/ai-configs/aiconfig-tools/SKILL.md index a8ff757..a934a6e 100644 --- a/skills/ai-configs/aiconfig-tools/SKILL.md +++ b/skills/ai-configs/aiconfig-tools/SKILL.md @@ -20,7 +20,7 @@ You're using a skill that will guide you through adding capabilities to your AI ## Core Principles 1. **Start with Capabilities**: Think about what your AI needs to do before creating tools -2. **Framework Matters**: LangGraph/CrewAI often auto-generate schemas; OpenAI SDK needs manual schemas +2. **Framework Matters**: LangGraph/CrewAI often auto-generate schemas; OpenAI SDK and Strands need manual schemas. For Strands, see [strands.md](references/strands.md) for the `TOOL_REGISTRY` runtime-resolution pattern that lets LaunchDarkly drive the active tool list per variation. 3. **Create Before Attach**: Tools must exist before you can attach them to variations 4. **Verify**: The agent fetches tools and config to confirm attachment @@ -38,7 +38,7 @@ What should the AI be able to do? - Query databases, call APIs, perform calculations, send notifications - Check what exists in the codebase (API clients, functions) -- Consider framework: LangGraph/LangChain auto-generate schemas; direct SDK needs manual schemas +- Consider framework: LangGraph/LangChain auto-generate schemas; direct SDK + Strands (`@tool` decorator with manual schema in LD) need manual schemas — for Strands see [references/strands.md](references/strands.md) ### Step 2: Create Tools diff --git a/skills/ai-configs/aiconfig-tools/references/strands.md b/skills/ai-configs/aiconfig-tools/references/strands.md new file mode 100644 index 0000000..9474b54 --- /dev/null +++ b/skills/ai-configs/aiconfig-tools/references/strands.md @@ -0,0 +1,70 @@ +# Strands Tools + +Strands ([strandsagents.com](https://strandsagents.com)) wires tools through the `Agent(tools=[...])` constructor from application code; LaunchDarkly governs the tool list (schema, version, attachment per variation). Detaching a tool from a variation in the LaunchDarkly UI takes effect on the next agent invocation, with no code change. + +## Pattern: LD-driven tool list + local handlers + +1. **Register tool schema in LD** — `POST /projects/{project}/ai-tools` (see [API Quick Start](api-quickstart.md)) +2. **Attach to variation** — `PATCH /ai-configs/{config}/variations/{variation}` with `{"tools": [{"key": "...", "version": 1}]}` +3. **Define handler in app code** — Strands `@tool`-decorated Python function +4. **Resolve at runtime** — match LD-attached tool names against a local `TOOL_REGISTRY` + +```python +from strands import tool + +# Module-level reference reassigned per invocation so @tool body can fire +# track_tool_call on the right tracker (SDK 0.18+ is at-most-once per tracker). +_tracker = None + +@tool +def get_order_status(order_id: str) -> str: + """Look up the status of a customer order by order ID.""" + if _tracker is not None: + _tracker.track_tool_call("get_order_status") + orders = {"ORD-123": "Shipped", "ORD-456": "Processing"} + return orders.get(order_id, f"No order found with ID {order_id}") + +# Map LD tool *key* -> local Strands tool object. +TOOL_REGISTRY = {"get_order_status": get_order_status} +``` + +## Resolve at runtime + +Read the attached tools from the variation and look them up in `TOOL_REGISTRY`: + +```python +config = ai_client.agent_config("strands-agent", context) +ld_tool_params = (config.model.to_dict().get("parameters") or {}).get("tools") or [] +tool_names = [t["name"] for t in ld_tool_params] +resolved_tools = [TOOL_REGISTRY[n] for n in tool_names if n in TOOL_REGISTRY] +missing = [n for n in tool_names if n not in TOOL_REGISTRY] +if missing: + print(f"[WARN] LD attached tools {missing} have no local handler") + +agent = Agent(model=..., system_prompt=config.instructions, tools=resolved_tools, ...) +``` + +The list is recomputed per agent build, so detaching a tool in LD propagates within the SDK's streaming window (~1s). + +## Per-invocation tool-call tracking + +The `@tool` body fires `_tracker.track_tool_call(name)`. The dispatcher publishes a fresh tracker to the module global before each invocation: + +```python +global _tracker +_tracker = config.create_tracker() +result = await _tracker.track_metrics_of_async( + strands_metrics_extractor, # see aiconfig-create/references/strands.md + lambda: agent.invoke_async(user_input), +) +``` + +Don't pass `tool_calls=` in the `LDAIMetrics` extractor — that would double-count against the per-call `track_tool_call` already firing from the `@tool` body. + +## Schema format + +LaunchDarkly stores tool schemas in OpenAI function-calling format. For Strands, the schema lives in LD; the Python handler signature (`def get_order_status(order_id: str) -> str:`) is what Strands actually invokes. Keep the parameter names and types in sync between the LD schema and the Python function or the LLM's tool call will fail validation. + +## Reference implementation + +Full pattern (governed tool + Strands handler + per-invocation tracking) is in the sample at [strands-agents/samples/python/03-integrate/runtime-control/launchdarkly](https://github.com/strands-agents/samples/tree/main/python/03-integrate/runtime-control/launchdarkly).