All skills
launchdarkly avatar

/migrate

@f1bea2f official

Migrate an application with hardcoded LLM prompts to a full LaunchDarkly AgentControl implementation in five stages: audit the code, wrap the call, move the tools, add tracking, attach evaluators. Use when the user wants to externalize model/prompt configuration, move from direct provider calls (OpenAI, Anthropic, Bedrock, Gemini, Strands) to a managed config, or stage a full hardcoded-to-LaunchDarkly migration.

Use this Skill: https://skilld.dev/gh/launchdarkly/agent-skills/migrate

This session only. Nothing lands on disk.

referencesagent-mode-frameworks.md

≈12k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Agent-Mode Frameworks

How to wire a config in agent mode into the frameworks that take a goal/instructions string: LangGraph, CrewAI, Strands, and custom ReAct loops. Also covers the dynamic tool loading pattern from the devrel-agents-tutorial — how to extract tool names from config.tools at runtime and instantiate the actual tool implementations without hardcoding.

When to pick agent mode

Completion mode is the default and covers direct provider calls (OpenAI, Anthropic, Bedrock) where the app assembles a messages array. Pick agent mode when:

Signal Framework Example
Takes a system_prompt / prompt / instructions string as a single argument LangGraph prebuilt agent Python: create_agent(llm, tools, system_prompt="You are...") (langchain.agents); Node: createReactAgent({ llm, tools, prompt: "You are..." }) (@langchain/langgraph/prebuilt)
Takes role, goal, backstory CrewAI Agent Agent(role="researcher", goal="...", backstory="...")
Custom ReAct loop with a system instruction separated from messages hand-rolled system = "You can use search..."; while not done: ...
Multi-step tool use with persistent instructions across turns LangGraph / LangChain AgentExecutor The system prompt stays stable across a long interaction
Provider-agnostic agent with @tool decorators and invoke_async Strands Agent Agent(model=OpenAIModel(...), system_prompt="You are...", tools=[search])

Agent mode returns an instructions string. Completion mode returns a messages array. Both modes support tools, parameters, and the same tracker — the only difference is the input shape the SDK returns to you.

Caveat: judges cannot be attached to agent-mode variations via the LaunchDarkly UI. Agent mode evaluations must go through the programmatic judge API (create_judge(...).evaluate(input, output)). See online-evals for the programmatic path.

Model construction for LangChain / LangGraph. When the framework runs on top of LangChain (which includes LangGraph's prebuilt agent and most custom graphs), build the chat model with create_langchain_model(ai_config) (Python) or createLangChainModel(aiConfig) (Node). These helpers forward every variation parameter (temperature, max_tokens, top_p, …) and handle LaunchDarkly→LangChain provider-name mapping internally. Do not hand-roll init_chat_model(model=..., model_provider=...) — it silently drops every variation parameter. See langchain-tracking.md for the canonical single-model and LangGraph patterns, including the SDK helpers sum_token_usage_from_messages / get_tool_calls_from_response (Python, ldai_langchain) used inside the track_metrics_of_async / trackMetricsOf extractor.

Framework-agnostic invariants for the run-scoped pattern

The concrete examples below use specific frameworks (LangGraph, CrewAI, Strands) and specific node names (setup_run, call_model, finalize). Treat those as incidentals. The three invariants below apply to any agent framework — DSPy, AutoGen, Pydantic AI, Haystack, LlamaIndex agents, or a hand-rolled tool loop in pure Python/TypeScript. If the framework has its own idioms, translate these three rules onto them:

  1. Resolve agent_config() / agentConfig() once per user turn. Every call is a flag evaluation and emits a $ld:ai:agent:config event. Re-fetching inside a loop step or a tool body amplifies the event count per turn and lets a mid-turn targeting change swap the variation between LLM calls. Do the resolve at the highest scope that corresponds to "one user-input-to-final-response cycle" — a handler function, a LangGraph entry node, a CrewAI kickoff, whatever the framework exposes.
  2. Mint one tracker via create_tracker() / createTracker() per user turn. Same scope as the agent_config call. The runId ties every event from one turn together; per-step factory calls fragment the correlation and reset the SDK's at-most-once guards. If the framework has a multi-turn session (chat thread), each turn inside the session still gets its own fresh tracker — sessions share a thread_id, not a runId.
  3. Emit the five at-most-once methods once at the end of the turn. track_duration / track_tokens / track_success / track_error / track_time_to_first_token each fire at most once per tracker. Accumulate inside the loop body (sum token usage across steps, stash a perf_counter_ns timer up top), emit once after the loop exits or in a dedicated finalize node. track_tool_calls / track_feedback / track_judge_result are per-event — call them as many times as the agent does those things.

What "one user turn" means differs by app shape:

App shape "One turn" =
Request/response HTTP handler One request
Chat loop (one session across many user inputs) One user input (not the whole session)
LangGraph app.ainvoke(...) / create_agent().invoke(...) (Python) / createReactAgent().invoke(...) (Node) One call to ainvoke / invoke
Custom ReAct loop with its own for iteration The full loop run, not one iteration
Batch job / dataset walk One row — each sample is its own run
Streaming response (SSE / WebSocket) The full stream (open → last chunk), not one chunk

If you can answer "what's the smallest unit at which I'd want to see a single 'execution' in the Monitoring tab?" — that's the turn. Mint exactly one tracker there.

Tool-scoped and app-scoped knobs go in model.custom

If the Stage 1 audit identified configuration that isn't a native model parameter — the kind of thing a provider SDK will reject with unexpected keyword argument if you forward it — these fields belong in ModelConfig(custom={...}), not ModelConfig(parameters={...}). Typical examples:

  • max_search_results (tool behavior — how many hits the search tool returns)
  • chunk_size / chunk_overlap (RAG preprocessing knobs)
  • retry_budget / retry_backoff (app-level retry policy)
  • enable_reranking, use_cache, any boolean feature toggle the agent consumes
  • any value that governs tool behavior or app behavior rather than model behavior

create_langchain_model (Python) / createLangChainModel (Node) forwards every key in parameters wholesale to the provider SDK. Anthropic, OpenAI, and Gemini all raise on unknown kwargs — a max_search_results entry in parameters crashes the request with AsyncMessages.create() got an unexpected keyword argument 'max_search_results'. Put the same field in custom and the helper leaves it alone; the app reads it where it's needed.

# Fallback: mirror the hardcoded knob shape using custom
FALLBACK = AIAgentConfigDefault(
    enabled=True,
    model=ModelConfig(
        name="claude-sonnet-4-5-20250929",
        parameters={"temperature": 0.3, "max_tokens": 2000},  # provider-bound
        custom={"max_search_results": 10},                    # app-scoped
    ),
    provider=ProviderConfig(name="anthropic"),
    instructions="You are a helpful assistant.",
)

# In the tool or app code that needs the knob:
def search(query: str) -> dict:
    ai_config = get_current_agent_config()
    max_results = ai_config.model.get_custom("max_search_results") or 10
    return TavilySearch(max_results=max_results).invoke({"query": query})

Mirror the same shape on the LaunchDarkly variation. MCP caveat. The update-ai-config-variation MCP tool does not currently expose the custom field — to populate model.custom on an existing variation, PATCH it through the REST API directly:

curl -X PATCH \
  "https://app.launchdarkly.com/api/v2/projects/$PROJECT/ai-configs/$CONFIG_KEY/variations/$VARIATION_ID" \
  -H "Authorization: {api_token}" \
  -H "Content-Type: application/json" \
  -d '{"patch":[{"op":"add","path":"/model/custom","value":{"max_search_results":10}}]}'

Getting knobs into tools: tool factories that close over per-run config (default)

Tools need read access to model.custom at call time. The correct pattern is a tool factory that takes the per-run ai_config resolved in setup_run (or at the top of a custom ReAct loop) and returns a tool callable that closes over whatever knobs it needs:

# tools.py
def make_search(ai_config) -> Callable[..., Any]:
    max_results = ai_config.model.get_custom("max_search_results") or 10

    async def search(query: str) -> dict:
        """Search the web for current information on a given topic."""
        return await TavilySearch(max_results=max_results).ainvoke({"query": query})

    return search

TOOL_FACTORIES = {"search": make_search}

# graph.py setup_run
built = {name: fn(ai_config) for name, fn in TOOL_FACTORIES.items()}
tools = build_structured_tools(ai_config, built)

This is the default because it has three properties nothing else preserves all at once:

  1. Turn-level atomicity. A mid-turn flag change doesn't swap max_search_results between the first tool call and the second — the factory captured it once, at setup_run.
  2. No extra LD evaluations. agent_config() is a flag evaluation and an emitted event. Calling it from a tool once per user turn is fine; calling it per tool invocation on a chatty agent inflates $ld:ai:agent:config counts by a factor of however many tool calls run per turn.
  3. Tools don't take a dependency on LD. The tool function is a plain callable. Testing is substitution, not monkeypatching get_agent_config.

Do not call get_agent_config() (or ai_client.agent_config(...)) from inside a tool body. The alternatives — re-resolving from inside the tool, or reaching into runtime.context from inside the tool — either break turn atomicity or require plumbing LangGraph's context through every tool signature. Tool factories sidestep both problems and are also how ldai_langchain.langchain_helper.build_structured_tools expects its registry to be shaped (a dict of {name: Callable} ready to bind).

The only legitimate reason to re-fetch inside a tool is if the tool's behavior needs to follow a flag change within a single turn. That's vanishingly rare; default to factories and treat re-fetch as an exception that requires a concrete reason.

Wiring agent_config into each framework

LangGraph prebuilt agent (Python — langchain.agents.create_agent)

API note. Use from langchain.agents import create_agent. The earlier from langgraph.prebuilt import create_react_agent is deprecated in LangGraph 1.0 and removed in 2.0. Same return shape; the only call-site rename is prompt= → system_prompt=. Node.js still uses createReactAgent from @langchain/langgraph/prebuilt.

from langchain.agents import create_agent
from ldai_langchain import create_langchain_model
from ldai.client import LDAIClient, AIAgentConfigDefault, ModelConfig, ProviderConfig

FALLBACK = AIAgentConfigDefault(
    enabled=True,
    model=ModelConfig(name="gpt-4o", parameters={"temperature": 0.3}),
    provider=ProviderConfig(name="openai"),
    instructions="You are a helpful assistant.",
)

def build_agent(ai_client: LDAIClient, user_id: str, tools: list):
    context = Context.builder(user_id).kind("user").build()
    config = ai_client.agent_config("support-agent", context, FALLBACK)

    if not config.enabled:
        return None, None

    # create_langchain_model forwards every variation parameter — do NOT hand-roll
    # ChatOpenAI(model=..., temperature=...). It silently drops unnamed parameters.
    llm = create_langchain_model(config)

    agent = create_agent(
        llm,
        tools,
        system_prompt=config.instructions,
    )
    return agent, config.create_tracker()

Key points:

  • system_prompt=config.instructions — the instructions string replaces the hardcoded prompt
  • Model + parameters come from create_langchain_model(config) — forwards the whole model.parameters dict
  • A fresh tracker is minted via config.create_tracker() and returned alongside the agent so the caller can wire Stage 4 tracking around agent.invoke(...). Each call to create_tracker() produces a new runId; the caller should treat the returned tracker as owning the execution.

CrewAI Agent

from crewai import Agent
from ldai.client import LDAIClient, AIAgentConfigDefault

def build_crew_agent(ai_client, user_id: str):
    context = Context.builder(user_id).kind("user").build()
    config = ai_client.agent_config("researcher-agent", context, FALLBACK)

    if not config.enabled:
        return None

    # CrewAI expects role/goal/backstory — split the instructions or store them
    # in the Config as three variables and pipe them in at runtime.
    return Agent(
        role="Research Analyst",
        goal="Produce a summary of recent config adoption patterns.",
        backstory=config.instructions,
        llm=config.model.name,  # CrewAI accepts a string or a LangChain model
    )

Pattern note: CrewAI's Agent takes three separate fields. If you want to drive all three from LaunchDarkly, either:

  • Use prompt variables on the config ({{role}}, {{goal}}, {{backstory}}) and pass them as the variables argument to agent_config(...)
  • Or store a structured JSON blob in instructions and parse it in the app

Prompt variables are cleaner and keep the config human-readable in the UI.

Strands Agent

Strands is a provider-agnostic, async-first agent SDK. The same Agent class runs against Anthropic, OpenAI, and Bedrock by swapping the model argument; tools are plain @tool-decorated Python functions passed through the constructor; and SlidingWindowConversationManager keeps short-term memory across invoke_async turns without external state. Agent-mode instructions maps directly to Agent(system_prompt=...).

Strands does not ship a first-party LaunchDarkly provider package. To serve multiple providers from a single config key, dispatch on agent_config.provider.name and construct the matching Strands model class.

Provider dispatcher. Drop parameters.tools before passing params into the Strands model class — LaunchDarkly surfaces attached tools via a flat parameters.tools shape in the variation payload, but Strands receives tools via the Agent constructor. Passing tools through a second time via model params is an error.

from strands.models.anthropic import AnthropicModel
from strands.models.openai import OpenAIModel


def create_strands_model(agent_config):
    """Map an LDAIAgentConfig to the matching Strands model class by provider."""
    provider = (agent_config.provider.name if agent_config.provider else "").lower()
    model_id = agent_config.model.name
    params = dict(agent_config.model.to_dict().get("parameters") or {})
    # LD surfaces attached tools via parameters.tools; Strands takes tools via
    # Agent(tools=[...]). Drop the key before passing params to the model class.
    params.pop("tools", None)

    if provider == "anthropic":
        # AnthropicModel requires max_tokens as a kwarg, not inside 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":
        # Pass parameters through as-is — gpt-5 wants max_completion_tokens,
        # gpt-4o wants max_tokens. Keep that choice in the LD variation.
        return OpenAIModel(model_id=model_id, params=params)
    raise ValueError(f"Unsupported provider for Strands: {provider!r}")

Call site. Build the agent once per request, pull the tracker off the config, and wrap invoke_async with track_duration_of — Strands is Tier 3 (custom extractor) because there is no provider package.

from strands import Agent, tool
from strands.agent.conversation_manager.sliding_window_conversation_manager import (
    SlidingWindowConversationManager,
)
from ldai.client import AIAgentConfigDefault, ModelConfig, ProviderConfig
from ldai.tracker import TokenUsage
from ldclient import Context


@tool
def get_order_status(order_id: str) -> str:
    """Look up the status of a customer order by order ID."""
    ...


FALLBACK = AIAgentConfigDefault(
    enabled=True,
    model=ModelConfig(name="gpt-5", parameters={"max_completion_tokens": 2000}),
    provider=ProviderConfig(name="openai"),
    instructions="You are a helpful assistant.",
)


def track_strands_metrics(tracker, result):
    """Record token usage from a Strands AgentResult on the LD tracker.

    accumulated_usage aggregates tokens across every provider call in the turn,
    including tool-calling round trips — unlike the single-response shape from
    Anthropic or OpenAI direct.
    """
    usage = getattr(result.metrics, "accumulated_usage", {}) or {}
    input_tokens = usage.get("inputTokens", 0)
    output_tokens = usage.get("outputTokens", 0)
    total = usage.get("totalTokens", 0) or (input_tokens + output_tokens)
    if total > 0:
        tracker.track_tokens(TokenUsage(input=input_tokens, output=output_tokens, total=total))


async def run_turn(ai_client, user_id: str, user_input: str):
    context = Context.builder(user_id).kind("user").build()
    agent_config = ai_client.agent_config("strands-agent", context, FALLBACK)

    if not agent_config.enabled:
        return disabled_response()

    agent = Agent(
        name="order-assistant",
        model=create_strands_model(agent_config),
        system_prompt=agent_config.instructions,
        tools=[get_order_status],
        conversation_manager=SlidingWindowConversationManager(window_size=40),
    )
    tracker = agent_config.create_tracker()

    try:
        result = await tracker.track_duration_of(lambda: agent.invoke_async(user_input))
        tracker.track_success()
        track_strands_metrics(tracker, result)
        return result.message["content"][0]["text"]
    except Exception:
        tracker.track_error()
        raise

Key points:

  • system_prompt=agent_config.instructions — the instructions string replaces the hardcoded system prompt.
  • create_strands_model(agent_config) is the provider-dispatch seam. Add a branch per provider the variation can serve.
  • The tracker is Tier 3: tracker.track_duration_of(...) + an explicit track_tokens call fed by track_strands_metrics. See strands-tracking.md for the single-call track_metrics_of_async variant and the per-field breakdown of accumulated_usage.
  • Always ldclient.get().flush() before process exit in short-lived scripts — trailing events can otherwise be lost.

TypeScript caveat. The Strands TypeScript SDK ships BedrockModel and OpenAIModel only — it cannot run Anthropic-backed variations. If the app needs to serve both OpenAI and Anthropic from a single config, use the Python SDK.

Custom StateGraph (bind_tools + ToolNode)

The most common LangGraph pattern in the wild is not the prebuilt agent — it's a custom StateGraph with a call_model node that does model.bind_tools(TOOLS), a separate "tools" node that runs ToolNode(TOOLS), and a conditional edge between them. This is the shape of the langchain-ai/react-agent template.

Two things make it different from the prebuilt create_agent:

  1. Tools appear in two places — bind_tools(TOOLS) (so the LLM knows which tools exist) and ToolNode(TOOLS) (so the executor knows how to run them). Both must read from the same source.
  2. The system prompt is injected manually in the call_model node body (usually as the first message in the ainvoke([{"role": "system", ...}, *state.messages]) call), not passed as a constructor argument.

Before — the typical template shape (src/react_agent/graph.py, src/react_agent/tools.py, src/react_agent/context.py):

# tools.py
TOOLS: List[Callable[..., Any]] = [search]

# context.py
@dataclass(kw_only=True)
class Context:
    system_prompt: str = field(default=prompts.SYSTEM_PROMPT)
    model: str = field(default="anthropic/claude-sonnet-4-5-20250929")

# graph.py
from .tools import TOOLS

async def call_model(state: State, runtime: Runtime[Context]):
    model = load_chat_model(runtime.context.model).bind_tools(TOOLS)
    system_message = runtime.context.system_prompt.format(system_time=now_iso())
    response = await model.ainvoke(
        [{"role": "system", "content": system_message}, *state.messages]
    )
    return {"messages": [response]}

builder = StateGraph(State, context_schema=Context)
builder.add_node(call_model)
builder.add_node("tools", ToolNode(TOOLS))
builder.add_edge("__start__", "call_model")
builder.add_conditional_edges("call_model", route_model_output)
builder.add_edge("tools", "call_model")
graph = builder.compile()

After — run-scoped architecture. The critical shape for the tracker factory is that one user turn = one runId = one tracker, not one LLM call = one tracker. A ReAct loop that calls call_model three times in a single turn must not mint three trackers, or billing and the Monitoring tab will treat the turn as three separate executions. The fix is to resolve the config and mint the tracker once, in a dedicated entry node, and thread both through graph state for every subsequent node.

Three nodes, in order:

  1. setup_run (entry) — resolves agent_config, mints the tracker with create_tracker(), builds model with create_langchain_model(ai_config), builds tools via the factory pattern below, starts a perf_counter_ns() timer, and stashes all of it on State. Runs exactly once per turn.
  2. call_model — reads model / tools / tracker / accumulator from State, runs model.ainvoke(...), accumulates token usage, calls tracker.track_tool_calls([...]) per step. Does not call track_metrics_of_async here — that wrapper records duration + success on every invocation and would fire once per iteration. On exception: call tracker.track_duration + tracker.track_error and re-raise (the finalize node will not run).
  3. finalize (terminal) — runs exactly once at the end of the turn on the success path. Calls tracker.track_duration(elapsed_ms) + tracker.track_tokens(accumulated) + tracker.track_success(). Each of these fires exactly once per run, which is what the at-most-once guards enforce.
# tools.py — tool factories close over per-run config, so tools never re-fetch
from typing import Any, Callable, Dict
from langchain_core.tools import StructuredTool
from langchain_tavily import TavilySearch

def make_search(ai_config) -> Callable[..., Any]:
    """Closure: capture `max_search_results` once at setup_run time."""
    max_results = ai_config.model.get_custom("max_search_results") or 10

    async def search(query: str) -> dict:
        """Search the web for current information on a given topic."""
        return await TavilySearch(max_results=max_results).ainvoke({"query": query})

    return search

# Registry of factories keyed by LD tool name. Each factory takes the
# per-run ai_config and returns a ready-to-bind callable. This decouples
# the Config (tool metadata) from the app (implementations) and
# means tools never call get_agent_config() themselves.
TOOL_FACTORIES: Dict[str, Callable[[Any], Callable[..., Any]]] = {
    "search": make_search,
}


# graph.py
import time
from typing import Any, Dict, List, TypedDict
from uuid import uuid4
from ldai_langchain import create_langchain_model, get_ai_metrics_from_response
from ldai_langchain.langchain_helper import build_structured_tools
from ldai.tracker import TokenUsage
from ldclient import Context as LDContext
from langchain_core.runnables import RunnableConfig
from .tools import TOOL_FACTORIES


# Run-scoped State. Fields below messages[] are non-serializable and only
# meaningful inside one turn — if you add a LangGraph checkpointer, exclude
# them from the checkpoint.
class State(TypedDict, total=False):
    messages: List[Any]                 # standard LangGraph messages reducer applies
    tracker: Any                        # LDAIConfigTracker, minted in setup_run
    model: Any                          # Bound chat model with per-run tools
    tools: List[Any]                    # StructuredTool list for this turn
    start_perf_ns: int                  # time.perf_counter_ns() at setup_run
    token_accumulator: TokenUsage       # Sum of usage_metadata across loop iterations
    disabled_message: str               # Set iff ai_config.enabled is False

# If the repo's State is a @dataclass (e.g., langchain-ai/react-agent),
# attribute access works the same — `state.tools` instead of `state["tools"]`.
# Same field set, same reducers (wrap the messages field with
# `Annotated[List[Any], add_messages]`). Don't convert during migration.


async def setup_run(state: State, config: RunnableConfig) -> Dict[str, Any]:
    """Runs once per user turn. Resolves the config, mints the tracker,
    builds the model and tools, and stashes everything on state.

    Node signature takes `config: RunnableConfig` rather than
    `runtime: Runtime[Context]` so the example works with an empty Context
    dataclass and reads `thread_id` from LangGraph's standard
    `config["configurable"]["thread_id"]` plumbing. If your app has a typed
    Context schema with fields beyond thread_id, use the Runtime[Context]
    signature instead and keep those fields on Context.
    """
    configurable = config.get("configurable") or {}
    ld_key = configurable.get("thread_id") or f"anon-{uuid4()}"
    ld_context = LDContext.builder(ld_key).kind("user").build()
    ai_config = get_ai_client().agent_config(
        "react-agent",
        ld_context,
        FALLBACK,
    )

    if not ai_config.enabled:
        # Return BOTH a flag the router can short-circuit on AND an AIMessage
        # appended to state["messages"]. Downstream consumers (UI, tests, the
        # caller that invoked the graph) read the last message — setting only
        # `disabled_message` without touching `messages` produces a graph
        # whose last-message shape depends on state entry, which surprises
        # readers. Every node return should leave `messages` in a valid shape.
        return {
            "disabled_message": "Feature is currently unavailable.",
            "messages": [AIMessage(content="Feature is currently unavailable.")],
        }

    # Three-tier tool-registry contract. Don't conflate these:
    #   1. TOOL_FACTORIES          — {name: factory}   at module scope (static, never changes)
    #   2. built_callables         — {name: callable}  per-run (factories applied to ai_config)
    #   3. tools (StructuredTool)  — per-run list, ready for bind_tools + ToolNode dispatch
    # build_structured_tools reads ai_config.model.parameters.tools to decide
    # which entries from built_callables to wrap — the LLM only sees the subset
    # the variation attached, even if the registry has more callables.
    built_callables = {name: fn(ai_config) for name, fn in TOOL_FACTORIES.items()}
    tools = build_structured_tools(ai_config, built_callables)

    return {
        "tracker": ai_config.create_tracker(),
        "model": create_langchain_model(ai_config).bind_tools(tools),
        "tools": tools,
        "start_perf_ns": time.perf_counter_ns(),
        "token_accumulator": TokenUsage(input=0, output=0, total=0),
        # Cache instructions so call_model doesn't touch ai_config again.
        "instructions": ai_config.instructions or "",
    }


async def call_model(state: State) -> Dict[str, Any]:
    """Reads model / tracker / tools from state. No LD access, no config fetch."""
    tracker = state["tracker"]
    model = state["model"]
    messages = [{"role": "system", "content": state["instructions"]}, *state["messages"]]

    try:
        response = cast(AIMessage, await model.ainvoke(messages))
    except Exception:
        elapsed_ms = (time.perf_counter_ns() - state["start_perf_ns"]) // 1_000_000
        tracker.track_duration(elapsed_ms)
        tracker.track_error()
        raise

    # Accumulate token usage across loop iterations; finalize emits the sum.
    acc = state["token_accumulator"]
    if getattr(response, "usage_metadata", None):
        um = response.usage_metadata
        state["token_accumulator"] = TokenUsage(
            input=acc.input + um.get("input_tokens", 0),
            output=acc.output + um.get("output_tokens", 0),
            total=acc.total + um.get("total_tokens", 0),
        )

    if response.tool_calls:
        tracker.track_tool_calls([call["name"] for call in response.tool_calls])

    return {"messages": [response]}


async def finalize(state: State) -> Dict[str, Any]:
    """Runs exactly once at the end of a successful turn. Emits the three
    once-per-run tracker events: duration, tokens, success."""
    tracker = state["tracker"]
    elapsed_ms = (time.perf_counter_ns() - state["start_perf_ns"]) // 1_000_000
    tracker.track_duration(elapsed_ms)
    acc = state["token_accumulator"]
    if acc.total > 0:
        tracker.track_tokens(acc)
    tracker.track_success()
    return {}


def route_after_setup(state: State) -> Literal["call_model", "__end__"]:
    return "__end__" if "disabled_message" in state else "call_model"


def route_model_output(state: State) -> Literal["tools", "finalize"]:
    last = state["messages"][-1]
    return "tools" if getattr(last, "tool_calls", None) else "finalize"


async def tools_node(state: State) -> Dict[str, Any]:
    """Dynamic ToolNode wrapper — rebuilds dispatch per invocation.

    `ToolNode` builds its `{name: callable}` dispatch dict at construction
    time, so `ToolNode([])` cannot execute anything (dispatch returns
    "Error: <tool> is not a valid tool, try one of []." verbatim). Because
    our tool callables close over per-run `ai_config` (see TOOL_FACTORIES),
    we cannot pre-build the ToolNode at compile time — the concrete
    callables differ each turn. Construct a fresh ToolNode from
    state["tools"] per invocation.

    Two things to get right in the body:
    - Use `ainvoke`, not `invoke` — the enclosing nodes in this example
      are async and LangGraph will raise if a sync node is awaited.
    - Pass an explicit `{"messages": [...]}` payload rather than the full
      State dict — ToolNode's contract is that input shape, and passing
      extra fields has surprised users on some LangGraph versions.
    """
    return await ToolNode(list(state["tools"])).ainvoke(
        {"messages": list(state["messages"])}
    )


# No context_schema=Context here — Context can be empty or dropped entirely.
# thread_id and any other per-invocation plumbing flows through RunnableConfig
# via config["configurable"] (see setup_run). Add context_schema=Context only
# if your app truly has typed per-request context fields beyond thread_id.
builder = StateGraph(State)
builder.add_node(setup_run)
builder.add_node(call_model)
builder.add_node("tools", tools_node)  # NOT ToolNode([]) — see tools_node docstring
builder.add_node(finalize)
builder.add_edge("__start__", "setup_run")
builder.add_conditional_edges("setup_run", route_after_setup)
builder.add_conditional_edges("call_model", route_model_output)
builder.add_edge("tools", "call_model")
builder.add_edge("finalize", "__end__")
graph = builder.compile()

Why this shape:

  • One create_tracker() per turn — setup_run is the only caller. Each factory call mints a fresh runId; per-iteration calls fragment the run downstream and reset the at-most-once guards.
  • One agent_config(...) per turn — setup_run resolves once. Re-fetching in a loop step inflates agent-config event counts and lets a mid-turn targeting change swap variations between LLM calls.
  • track_duration / track_tokens / track_success fire in finalize, not call_model — these are at-most-once; per-iteration calls are silently dropped.
  • track_tool_calls in call_model is fine — it's per-event metadata, not at-most-once.
  • Tools close over per-run config — make_search(ai_config) captures max_search_results at setup time. A mid-turn variation change doesn't affect the in-flight turn; the next turn picks up the new value.
  • tools node uses the tools_node wrapper, not ToolNode([]) — ToolNode builds its dispatch dict at construction, so an empty list produces empty dispatch and every tool call fails with is not a valid tool. The wrapper rebuilds per invocation from state.
  • Fallback instructions use Mustache ("...{{ system_time }}"), not .format() — the SDK's Mustache renderer runs on both the LD-served path and the fallback path; single-brace placeholders ship a stale literal.

Gotchas:

  • Never call track_metrics_of_async in a loop node. trackMetricsOf is designed for single-call shapes (one provider call, one success event). In a ReAct loop it would re-fire track_success per iteration and trip the at-most-once guard. Use manual track_tool_calls per step and explicit track_duration + track_tokens + track_success in finalize.
  • Lazy-init the ai_client. Avoid ai_client = LDAIClient(...) at module import time — it couples test collection to LD initialization and makes the per-turn runId story harder to mock. Wrap with def get_ai_client(): ... and cache on first use.
  • Per-run LDContext keys — and their MAU cost. A shared literal like "anonymous" collapses every request into one targeting/billing segment, which breaks experimentation and per-run segmentation. The obvious fix is to use the LangGraph thread_id if present and fall back to uuid4() per setup_run. Before doing this in production, check the MAU impact: LaunchDarkly bills by distinct context keys per month, so a production agent serving 100k anonymous runs/day will register 3M+ MAU even though there's no real user. Three shapes, pick the one that fits:
    1. Known user identity (preferred). If the caller knows who the user is (session cookie, auth token, SSO ID), use that as the context key. No anonymous keys at all. Targeting, segmentation, and MAU all work correctly.
    2. Session-scoped key. Use the LangGraph thread_id, or the chat-session ID, or whatever the longest-lived identifier is below "real user." MAU scales with session count, not turn count.
    3. Per-turn uuid4(). Only in demos, or if you genuinely need per-run isolation for experiment targeting and are willing to pay the MAU. Document the decision in the repo README so the next migrator doesn't swap it out without reading the tradeoff.
  • State field serialization. If you add a LangGraph checkpointer, exclude the run-scoped fields (tracker, model, tools, start_perf_ns) from the checkpoint — they're non-serializable and only meaningful inside one turn anyway.

Node.js — LangGraph.js createReactAgent (prebuilt)

For apps built on @langchain/langgraph's prebuilt createReactAgent, the loop happens inside one agent.invoke(...) call — you don't own the nodes. That makes the run-scoped pattern simpler than the Python custom-StateGraph shape above: resolve agentConfig once, mint the tracker once, wrap the whole agent.invoke in one tracker.trackMetricsOf(...) call per user turn.

import { init } from '@launchdarkly/node-server-sdk';
import { initAi, type LDAIAgentConfig, type LDAIMetrics } from '@launchdarkly/server-sdk-ai';
import { createLangChainModel } from '@launchdarkly/server-sdk-ai-langchain';
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import { MemorySaver } from '@langchain/langgraph';

const ldClient = init(process.env.LD_SDK_KEY!);
await ldClient.waitForInitialization({ timeout: 10 });
const aiClient = initAi(ldClient);

// Sum token usage across every message the agent produced in one turn.
// Multiple fallback field names cover the provider variation LangChain
// normalizes over (OpenAI, Anthropic, Bedrock, Gemini, …).
function langgraphMetrics(result: any): LDAIMetrics {
  let input = 0, output = 0, total = 0;
  for (const msg of result.messages ?? []) {
    const usage = msg.response_metadata?.token_usage ?? msg.usage_metadata;
    if (!usage) continue;
    input += usage.input_tokens ?? usage.prompt_tokens ?? usage.promptTokens ?? 0;
    output += usage.output_tokens ?? usage.completion_tokens ?? usage.completionTokens ?? 0;
    total += usage.total_tokens ?? usage.totalTokens ?? 0;
  }
  if (total === 0) total = input + output;
  return { success: true, tokens: total > 0 ? { input, output, total } : undefined };
}

async function runTurn(userInput: string, threadId: string): Promise<string | null> {
  const context = { kind: 'user' as const, key: threadId };
  const agentConfig: LDAIAgentConfig = await aiClient.agentConfig(
    'react-agent',
    context,
    FALLBACK,                            // LDAIAgentConfigDefault literal
  );

  if (!agentConfig.enabled) return null;

  // Build everything once per user turn.
  const llm = await createLangChainModel(agentConfig);
  const agent = createReactAgent({
    llm,
    tools: buildTools(agentConfig),      // factory pattern — see below
    prompt: agentConfig.instructions,
    checkpointer: new MemorySaver(),     // reuse across turns if you want chat memory
  });

  // One tracker per user turn. Fresh runId. At-most-once guards reset.
  const tracker = agentConfig.createTracker();

  // Exceptions are tracked automatically — trackMetricsOf catches
  // exceptions, records tracker.trackError(), and re-throws.
  const result = await tracker.trackMetricsOf(
    langgraphMetrics,
    () => agent.invoke(
      { messages: [{ role: 'user', content: userInput }] },
      { configurable: { thread_id: threadId } },
    ),
  );
  const messages = result.messages ?? [];
  return messages.length ? String(messages[messages.length - 1].content) : null;
}

Tool factories in TypeScript. The same pattern as Python — a record of (agentConfig) => tool factories, applied per-turn so each tool closes over the live variation's model.custom knobs:

import { tool } from '@langchain/core/tools';
import { z } from 'zod';

type ToolFactory = (agentConfig: LDAIAgentConfig) => ReturnType<typeof tool>;

function makeSearch(agentConfig: LDAIAgentConfig) {
  const maxResults =
    (agentConfig.model?.custom?.max_search_results as number | undefined) ?? 10;
  return tool(
    async ({ query }: { query: string }) => {
      const res = await fetch(`https://api.tavily.com/search?q=${encodeURIComponent(query)}&n=${maxResults}`);
      return await res.json();
    },
    {
      name: 'search',
      description: 'Search the web for current information on a given topic.',
      schema: z.object({ query: z.string() }),
    },
  );
}

const TOOL_FACTORIES: Record<string, ToolFactory> = {
  search: makeSearch,
};

function buildTools(agentConfig: LDAIAgentConfig) {
  const attached = ((agentConfig.model?.parameters?.tools as Array<{ name: string }>) ?? [])
    .map((t) => t.name);
  return attached
    .filter((name) => name in TOOL_FACTORIES)
    .map((name) => TOOL_FACTORIES[name](agentConfig));
}

Key differences from Python custom-StateGraph:

  • Because LangGraph.js's createReactAgent is prebuilt, you don't write a setup_run / call_model / finalize graph — agent.invoke(...) is the single call that represents the turn, so wrapping it in one trackMetricsOf call is sufficient. The at-most-once guards are naturally satisfied by the single wrapping call.
  • No dynamic ToolNode wrapper needed. createReactAgent takes a tool list at construction; construct the agent per-turn (inside runTurn) so the tools can close over the current agentConfig.
  • Multi-turn chat: if you want runTurn to be called three times in a row for one session (shared threadId / MemorySaver), each call still mints its own tracker inside runTurn. The threadId threads conversation memory through checkpointer; the runId identifies each turn independently.

Custom StateGraph in Node.js. If the app uses a hand-rolled StateGraph with its own call_model / tool nodes (uncommon in Node compared to Python, but possible), the same run-scoped architecture from the Python section above applies — setup_run as an entry node, tools_node wrapper around new ToolNode(state.tools).invoke(...), finalize as a terminal node. The TypeScript syntax differs but the node responsibilities are identical. Prefer createReactAgent unless the app genuinely needs graph-level control.

Custom ReAct loop

The same run-scoped shape applies: one config fetch + one create_tracker() at the top of the turn, accumulate tokens across the loop, emit track_duration / track_tokens / track_success exactly once after the loop exits.

import time
from ldai.tracker import TokenUsage

def run_turn(ai_client, user_id: str, user_input: str):
    context = Context.builder(user_id).kind("user").build()
    config = ai_client.agent_config("custom-react", context, FALLBACK)

    if not config.enabled:
        return disabled_response()

    # Resolve everything needed for the turn once, up top.
    system_prompt = config.instructions
    model_name = config.model.name
    tracker = config.create_tracker()
    tool_callables = build_tools_from_config(config)  # closes over config
    start_ns = time.perf_counter_ns()
    acc = TokenUsage(input=0, output=0, total=0)

    try:
        history = [{"role": "user", "content": user_input}]
        for step in range(MAX_TURNS):
            response = my_provider.complete(
                model=model_name,
                system=system_prompt,
                messages=history,
                tools=config.model.get_parameter("tools") or [],
            )

            # Accumulate token usage across the loop; finalize emits the sum.
            usage = extract_tokens(response)  # returns TokenUsage
            acc = TokenUsage(
                input=acc.input + usage.input,
                output=acc.output + usage.output,
                total=acc.total + usage.total,
            )

            tool_calls = extract_tool_calls(response)
            if tool_calls:
                tracker.track_tool_calls([c["name"] for c in tool_calls])
                history.extend(run_tools(tool_calls, tool_callables))
                continue

            # Done — fall through to the success emit block.
            break

        elapsed_ms = (time.perf_counter_ns() - start_ns) // 1_000_000
        tracker.track_duration(elapsed_ms)
        if acc.total > 0:
            tracker.track_tokens(acc)
        tracker.track_success()
        return response
    except Exception:
        elapsed_ms = (time.perf_counter_ns() - start_ns) // 1_000_000
        tracker.track_duration(elapsed_ms)
        tracker.track_error()
        raise

The call site stays in your control; the config just delivers instructions, model.name, model.parameters, and tools. Everything that's stable across the turn (model name, instructions, tool bindings, tracker) is hoisted out of the loop body — the loop itself only does message passing and tool dispatch.

Do not call track_duration / track_tokens / track_success inside the for body. The at-most-once guards will warn and drop the second-and-later calls on the same tracker, so per-step tracker calls will silently lose data. Accumulate inside the loop, emit once after.

Dynamic tool loading — the "tools factory" pattern

The devrel-agents-tutorial uses a dynamic tool factory that reads tool names from config.tools and instantiates the actual tool implementations at runtime. This decouples the config (which holds tool metadata) from the application (which holds the executable code).

The pattern

# tools_impl/dynamic_tool_factory.py — adapted from devrel-agents-tutorial

def extract_tool_names(config) -> list[str]:
    """Read the list of tool names from the config."""
    if not hasattr(config, "tools") or not config.tools:
        return []
    return [tool.name if hasattr(tool, "name") else tool.get("name") for tool in config.tools]


def create_dynamic_tools_from_launchdarkly(config) -> list:
    """Instantiate tool implementations for every tool name on the config."""
    tool_names = extract_tool_names(config)
    instances = []
    for name in tool_names:
        tool = _create_tool_instance(name)
        if tool is not None:
            instances.append(tool)
    return instances


def _create_tool_instance(tool_name: str):
    """Map a tool name to an actual implementation. Add one branch per tool."""
    if tool_name == "search_kb":
        from my_tools.search import SearchKBTool
        return SearchKBTool()
    elif tool_name == "calculator":
        from my_tools.calc import CalculatorTool
        return CalculatorTool()
    # ... etc
    return None

Then at the call site:

config = ai_client.agent_config("support-agent", context, FALLBACK)
tools = create_dynamic_tools_from_launchdarkly(config)

agent = create_agent(
    build_llm(config),
    tools,
    system_prompt=config.instructions,
)

What this gives you:

  • Toggle a tool on/off by editing the config in LaunchDarkly — no redeploy needed to remove a tool from production
  • Roll out a new tool to 5% of users by editing targeting rules (combined with configs-targeting)
  • Keep the actual tool implementation code in the repo; only metadata lives in LaunchDarkly

Extracting schemas from existing hardcoded tools

If your tools are defined with LangChain @tool or Pydantic BaseModel schemas, extract the JSON schema programmatically to pass to tools during Stage 3:

from my_tools import search_kb  # a @tool-decorated function

schema = search_kb.args_schema.model_json_schema()
# schema is a dict ready to pass as the tool's parameters field

Do not hand-write the schema — LangChain already generated it from the function signature, and Pydantic will keep it in sync. The tools delegate accepts raw JSON Schema for the parameters field.

Dynamic schemas from LaunchDarkly

The devrel tutorial also shows the reverse: reading a JSON schema from the config and constructing a Pydantic model at runtime:

from pydantic import BaseModel, Field, create_model

def _create_dynamic_tool_input(tool_config: dict) -> type[BaseModel]:
    """Build a Pydantic input schema from a config tool's parameters."""
    properties = tool_config.get("properties", {})
    fields = {}
    for name, cfg in properties.items():
        py_type = {"string": str, "number": float, "integer": int, "boolean": bool}.get(
            cfg.get("type"), str
        )
        default = ... if name in tool_config.get("required", []) else None
        fields[name] = (py_type, Field(default=default, description=cfg.get("description", "")))
    return create_model("DynamicToolInput", **fields)

This lets LaunchDarkly change a tool's parameter schema without redeploying the app. Use it when the tool implementation is generic enough to accept any parameter shape (e.g. a proxy that forwards requests to an external API). For most tools, a static Pydantic schema in the repo is simpler.

Routing / multi-node hint

If the framework needs to pick between multiple downstream agents (e.g. a supervisor that routes to a security agent or a support agent based on the user input), do not roll your own routing. Use LaunchDarkly agent graphs — the graph's edges carry the routing contract, and the supervisor's instructions can be auto-injected with the valid routes.

The devrel tutorial's generic_agent.py shows a minimal version of this:

# If this node has outgoing edges with routes, inject them into instructions
if self.valid_routes:
    route_instruction = (
        f"\n\nYou must select one of these routes: {self.valid_routes}. "
        f'Return your choice in JSON format: {{"route": "<selected_route>"}}'
    )
    instructions = instructions + route_instruction

For the full graph pattern, read agent-graph-reference.md — but again, single-agent migration comes first, and agent graphs are currently Python-only in the SDK.

Keep the provider call in the repo

One rule that applies across all three frameworks: the provider SDK call (OpenAI, Anthropic, Bedrock) stays in your code. The config only changes the inputs to that call — model name, instructions, parameters, tool list. It does not replace the provider SDK. That means:

  • You keep full control of error handling, retries, timeouts, custom headers
  • You keep full control of streaming logic and backpressure
  • You keep full control of authentication (API keys, IAM roles, Bedrock Converse sessions)
  • The config is additive — removing it gets you back to the original hardcoded app, provided the fallback mirrors the old values

Source: SKILL.md on GitHub

No alerts2d3 checks · Risk SAFE
  • Gen Agent Trust Hub2d

    A legitimate orchestration skill for migrating hardcoded AI prompts to LaunchDarkly AgentControl. It follows a structured, human-in-the-loop workflow with clear instructions and fallback patterns.

  • Socket2d

    No alerts

  • Snyk2d

    Risk: LOW · No issues

Signed by skilld at f1bea2f. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 days ago.

Activeupdated 2 weeks ago
compatibility
Requires the remotely hosted LaunchDarkly MCP server
metadata
{
  "author": "launchdarkly",
  "version": "0.1.0"
}
  • Python
  • TypeScript
  • launchdarkly
  • migration
  • llm
  • prompt-management
  • agent
  • openai
  • anthropic
  • bedrock

README badge

README badge for launchdarkly/agent-skills/migrate

Guides you through migrating a hardcoded LLM application to LaunchDarkly AgentControl in five stages: audit the code, wrap the call, move the tools, add tracking, attach evaluators. Targets Python and Node.js applications calling OpenAI, Anthropic, Bedrock, Gemini, or agent frameworks like LangGraph and CrewAI.

Generated from the current SKILL.md.

Does this skill work with my LLM provider?
The skill has worked examples for OpenAI, Anthropic, Bedrock, Gemini, and Strands. For LangChain, LangGraph, CrewAI, and custom ReAct loops, coverage varies by language — Python has deep examples; Node.js has partial coverage with ⚠️ annotations. See the coverage table in the SKILL.md for your framework.
What if my app uses a framework not listed in the coverage table?
Apply the three framework-agnostic invariants: one `agent_config` per turn, one tracker per turn, and fire at-most-once methods once at turn end. These rules apply regardless of framework; see agent-mode-frameworks.md § Framework-agnostic invariants.
Can I skip stages or do them out of order?
Stages must be ordered — audit, wrap, tools, tracking, then evaluators. You can ship after Stage 4 (tracking) is complete; Stage 5 (evaluators) is optional. Skipping ahead produces configs without traffic or metrics without context.
Does this skill handle streaming responses?
Streaming support is delegated to the built-in-metrics skill's streaming-tracking.md reference. Use `trackStreamMetricsOf` plus manual TTFT tracking.
What environment setup is required?
You need `LD_SDK_KEY` (server-side SDK key) set, the LaunchDarkly MCP server configured, and the target application already calling an LLM provider with hardcoded prompts and model values. Check the SDK CHANGELOG before starting to ensure no breaking changes post-date this skill.

Generated from the current SKILL.md. These answers refresh after source changes.