Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions skills/ai-configs/aiconfig-create/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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** |
Expand Down
122 changes: 122 additions & 0 deletions skills/ai-configs/aiconfig-create/references/strands.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 2 additions & 2 deletions skills/ai-configs/aiconfig-tools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
70 changes: 70 additions & 0 deletions skills/ai-configs/aiconfig-tools/references/strands.md
Original file line number Diff line number Diff line change
@@ -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).