From 8e7c9608b7fd9f869af513cbf107bbda757838f0 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Thu, 15 Oct 2026 13:49:20 -0700 Subject: [PATCH 1/2] fix(AIC-3590): build Python langchain-agents with langchain.agents.create_agent langgraph.prebuilt.create_react_agent is deprecated in LangGraph 1.0 and removed in 2.0, and every agent run emitted the deprecation warning. Use create_agent from langchain.agents in both the blocking and streaming paths, passing system_prompt= (create_react_agent's prompt=). This matches the TypeScript handler, which already uses createAgent. Declare langchain>=1.0. The lockfile adds only langchain 1.3.14; no other locked version changes. The section 2.x.0 test now asserts create_agent is called with system_prompt on both paths and create_react_agent is never called. The output-format test asserted nothing when no call was captured; it now requires one. Co-Authored-By: Claude Opus 5.5 --- packages/langchain-agents/agents.md | 11 +- packages/langchain-agents/pyproject.toml | 1 + .../handler.py | 18 ++-- .../launchdarkly_ai_langchain_agents/spans.py | 4 +- .../langchain-agents/tests/test_handler.py | 102 +++++++----------- uv.lock | 16 +++ 6 files changed, 70 insertions(+), 82 deletions(-) diff --git a/packages/langchain-agents/agents.md b/packages/langchain-agents/agents.md index 2e4187ec..8c632287 100644 --- a/packages/langchain-agents/agents.md +++ b/packages/langchain-agents/agents.md @@ -12,7 +12,7 @@ This document tells an agent exactly how this package is implemented so it can b ## Role and Routing -This is a **Tier 1 handler package**. It wraps LangGraph's `create_react_agent` (`langgraph.prebuilt`) and exposes a `ProviderHandler` that routes to flag variations where: +This is a **Tier 1 handler package**. It wraps LangChain's `create_agent` (`langchain.agents`) and exposes a `ProviderHandler` that routes to flag variations where: ``` provides_for = ['*', 'agent'] @@ -94,11 +94,11 @@ The `schema` field accepts a JSON Schema dict. Tool executor receives parsed arg ### 3. Agent Construction and Invocation ```python -agent = create_react_agent(model=base_model, tools=tools, prompt=system_prompt) +agent = create_agent(model=base_model, tools=tools, system_prompt=system_prompt) result = await agent.ainvoke({"messages": initial_messages}) ``` -`create_react_agent` from `langgraph.prebuilt` builds a production-ready ReAct-style agent. The handler passes the initial messages and lets LangGraph manage all tool calls, retries, and re-prompting internally. +`create_agent` from `langchain.agents` builds the agent; it replaces LangGraph's `create_react_agent`, deprecated in LangGraph 1.0 and removed in 2.0. The handler passes the initial messages and lets LangGraph manage all tool calls, retries, and re-prompting internally. ### 4. Extracting Output @@ -188,7 +188,8 @@ Span names and attributes are described in [Implementation Details → Telemetry |---|---| | `langchain-core` | `BaseChatModel`, `HumanMessage`, `SystemMessage`, `AIMessage`, `BaseMessage`, `tool()` | | `langchain-openai` | `ChatOpenAI` (default model) | -| `langgraph` | `StateGraph`, `ToolNode`, `tools_condition`, `create_react_agent` | +| `langchain` | `create_agent` | +| `langgraph` | `StateGraph`, `ToolNode`, `tools_condition` | | `launchdarkly-ai-server` | `AiConfigRep`, `Tool`, `ProviderHandler`, `parse_template` | | `opentelemetry-api` | `StatusCode`, `trace.get_tracer().start_span()` for span creation | @@ -196,7 +197,7 @@ Span names and attributes are described in [Implementation Details → Telemetry ## Common Pitfalls -- **No manual tool loop**: `create_react_agent` manages the entire reasoning loop internally. Do not add a manual tool-call loop on top — it would be redundant and would interfere with LangGraph's state graph. +- **No manual tool loop**: `create_agent` manages the entire reasoning loop internally. Do not add a manual tool-call loop on top — it would be redundant and would interfere with LangGraph's state graph. - **`result["messages"]` is the full history**: the agent appends every intermediate AI message, tool call, and tool result. Always take the **last** message as the final output. - **`usage_metadata` may be sparse**: not every message carries usage. The accumulation loop silently skips messages where `usage_metadata` is absent. If total tokens are `0`, the underlying model doesn't report per-message usage. - **`add_messages` and all annotation-reducer symbols must be module-level imports.** `from __future__ import annotations` (present at the top of `native_graph.py`) causes Python to store all annotations as strings. When LangGraph calls `get_type_hints(WorkflowState)`, Python evaluates those strings in the **module's global namespace** — not in the local scope of the `invoke()` function. Any symbol that is only a local variable (e.g. imported inside `invoke()`) will raise `NameError: name 'add_messages' is not defined` at `StateGraph(WorkflowState)` time. See Appendix A.3 in `TESTING.md`. diff --git a/packages/langchain-agents/pyproject.toml b/packages/langchain-agents/pyproject.toml index f0bef6e7..a2297eeb 100644 --- a/packages/langchain-agents/pyproject.toml +++ b/packages/langchain-agents/pyproject.toml @@ -5,6 +5,7 @@ requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server>=0.3.1", "opentelemetry-api>=1.25", + "langchain>=1.0", "langchain-core>=0.3", "langgraph>=0.2", ] diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py index 4f14b1ae..a830ddf1 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -1,5 +1,5 @@ """ -LangChain Agents handler — uses LangGraph's create_react_agent / StateGraph. +LangChain Agents handler — uses LangChain's create_agent / LangGraph's StateGraph. Mirrors the TypeScript @launchdarkly/ai-langchain-agents handler. """ @@ -259,7 +259,7 @@ def _run_usage_from_messages(messages: list[Any]) -> Any: def create_langchain_agents_handler( llm: Any = None, *, capture_content: bool | None = None ) -> ProviderHandler: - """Creates a ``ProviderHandler`` for LangChain via ``create_react_agent``. + """Creates a ``ProviderHandler`` for LangChain via ``langchain.agents.create_agent``. Pass *llm* as a chat model instance, or as a function ``(config) -> model`` that is called after flag evaluation so ``model.parameters`` can be applied unchanged. @@ -332,13 +332,12 @@ async def _call_impl( ) base_model = await _resolve_base_model(config, llm) - langgraph_prebuilt = importlib.import_module("langgraph.prebuilt") - create_react_agent = langgraph_prebuilt.create_react_agent + create_agent = importlib.import_module("langchain.agents").create_agent tools = _build_agent_tools(config.get("tools") or {}, th) - agent = create_react_agent( + agent = create_agent( base_model, tools, - **({"prompt": system_prompt} if system_prompt else {}), + **({"system_prompt": system_prompt} if system_prompt else {}), ) result = await agent.ainvoke( {"messages": initial_messages}, @@ -489,13 +488,12 @@ async def _stream_gen( ) base_model = await _resolve_base_model(config, llm) - langgraph_prebuilt = importlib.import_module("langgraph.prebuilt") - create_react_agent = langgraph_prebuilt.create_react_agent + create_agent = importlib.import_module("langchain.agents").create_agent tools = _build_agent_tools(config.get("tools") or {}, tool_handlers) - agent = create_react_agent( + agent = create_agent( base_model, tools, - **({"prompt": system_prompt} if system_prompt else {}), + **({"system_prompt": system_prompt} if system_prompt else {}), ) run_usage = create_run_usage() diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/spans.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/spans.py index 3621de06..6d42b700 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/spans.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/spans.py @@ -8,8 +8,8 @@ children of it: both take the same parent context, which is the root's. See TELEMETRY-CONTRACT.md section 1. -Unlike the Claude and OpenAI handlers, this one does not drive the provider call itself: LangGraph's -``create_react_agent`` does, and the only hook into its lifecycle is LangChain's callback protocol. +Unlike the Claude and OpenAI handlers, this one does not drive the provider call itself: LangChain's +``create_agent`` does, and the only hook into its lifecycle is LangChain's callback protocol. :func:`build_span_callbacks` is therefore the center of this module: it returns a ``BaseCallbackHandler`` that opens and closes ``chat`` and ``execute_tool`` spans as LangChain dispatches ``on_chat_model_start`` / ``on_llm_end`` / ``on_tool_start`` / ``on_tool_end`` events, diff --git a/packages/langchain-agents/tests/test_handler.py b/packages/langchain-agents/tests/test_handler.py index 07bf45a2..ba321fb4 100644 --- a/packages/langchain-agents/tests/test_handler.py +++ b/packages/langchain-agents/tests/test_handler.py @@ -63,14 +63,14 @@ def _make_langchain_mock(response: str = "answer") -> Any: mock_agent = AsyncMock() mock_agent.ainvoke = AsyncMock(return_value={"messages": [ai_msg]}) - mock_langgraph_prebuilt = MagicMock() - mock_langgraph_prebuilt.create_react_agent = MagicMock(return_value=mock_agent) + mock_langchain_agents = MagicMock() + mock_langchain_agents.create_agent = MagicMock(return_value=mock_agent) lc_tools = MagicMock() lc_tools.tool = MagicMock(side_effect=lambda name, fn=None, **kw: fn) return { - "langgraph.prebuilt": mock_langgraph_prebuilt, + "langchain.agents": mock_langchain_agents, "langchain_core.messages": lc_msgs, "langchain_core.tools": lc_tools, "langchain_openai": MagicMock(ChatOpenAI=MagicMock(return_value=mock_llm)), @@ -100,63 +100,42 @@ def _side_effect(name: str) -> Any: # --------------------------------------------------------------------------- -# §2.x.0 Agent creation API (Python: create_react_agent from langgraph.prebuilt) +# §2.x.0 Agent creation API (Python: create_agent from langchain.agents) # --------------------------------------------------------------------------- class TestAgentCreationAPI: @pytest.mark.asyncio - async def test_calls_create_react_agent_not_createAgent(self) -> None: - """§2.x.0 — Python handler must call create_react_agent from langgraph.prebuilt.""" - captured: dict[str, Any] = {"called": False, "args": None, "kwargs": None} - - mock_agent = AsyncMock() - ai_msg = _make_ai_msg("answer") - mock_agent.ainvoke = AsyncMock(return_value={"messages": [ai_msg]}) - - def _fake_create_react_agent(*args: Any, **kwargs: Any) -> Any: - captured["called"] = True - captured["args"] = args - captured["kwargs"] = kwargs - return mock_agent - - mock_langgraph_prebuilt = MagicMock() - mock_langgraph_prebuilt.create_react_agent = _fake_create_react_agent - - lc_msgs = MagicMock() - lc_msgs.HumanMessage = MagicMock( - side_effect=lambda c: MagicMock(content=c, type="human") + @pytest.mark.parametrize("streaming", [False, True]) + async def test_calls_create_agent_not_create_react_agent( + self, streaming: bool + ) -> None: + """§2.x.0 — both paths call create_agent from langchain.agents with system_prompt=, + never LangGraph's deprecated create_react_agent.""" + mocks = _make_langchain_mock("answer") + mocks["_agent"].astream = MagicMock(return_value=_empty_astream()) + create_agent = mocks["langchain.agents"].create_agent + prebuilt = MagicMock() + prebuilt.create_react_agent = MagicMock( + side_effect=AssertionError("deprecated create_react_agent was called") ) - lc_msgs.AIMessage = MagicMock() - lc_msgs.SystemMessage = MagicMock() + mocks["langgraph.prebuilt"] = prebuilt - def _import_side_effect(name: str) -> Any: - if name == "langgraph.prebuilt": - return mock_langgraph_prebuilt - if name == "langchain": - # Return a mock that does NOT have createAgent - # so the handler would fail if it tries to call langchain.createAgent - m = MagicMock(spec=[]) # empty spec means no attributes allowed - return m - if name == "langchain_core.messages": - return lc_msgs - if name == "langchain_core.tools": - tools_mod = MagicMock() - tools_mod.tool = MagicMock(side_effect=lambda name, fn=None, **kw: fn) - return tools_mod - if name == "langchain_openai": - return MagicMock() - return __import__(name) - - with patch("importlib.import_module", side_effect=_import_side_effect): - with patch.object(spans_mod, "_HAS_OTEL", False): - h = create_langchain_agents_handler(llm=MagicMock()) + with _patch_lc(mocks), patch.object(spans_mod, "_HAS_OTEL", False): + h = create_langchain_agents_handler(llm=MagicMock()) + if streaming: + assert h.stream is not None + async for _ in await h.stream( + _make_config(instructions="Be helpful."), "hi" + ): + pass + else: await h(_make_config(instructions="Be helpful."), "hi") - assert captured["called"], ( - "create_react_agent from langgraph.prebuilt was not called. " - "Handler must use create_react_agent, not langchain.createAgent." - ) + create_agent.assert_called_once() + assert create_agent.call_args.kwargs.get("system_prompt") == "Be helpful." + assert "prompt" not in create_agent.call_args.kwargs + prebuilt.create_react_agent.assert_not_called() # --------------------------------------------------------------------------- @@ -563,9 +542,7 @@ def _create(_model: Any, tools: list[Any], **_kwargs: Any) -> Any: built["tools"] = tools return mocks["_agent"] - mocks["langgraph.prebuilt"].create_react_agent = MagicMock( - side_effect=_create - ) + mocks["langchain.agents"].create_agent = MagicMock(side_effect=_create) async def _call_handoff() -> str: assert built.get("tools"), "route did not receive built tools" @@ -621,7 +598,7 @@ async def test_no_tools_in_config_handler_never_invoked(self) -> None: class _FakeToolModel(BaseChatModel): - """A real ``BaseChatModel`` (so ``create_react_agent`` accepts it) that returns canned replies + """A real ``BaseChatModel`` (so ``create_agent`` accepts it) that returns canned replies in sequence and never touches the network. ``bind_tools`` is required by the agent graph and the base class raises ``NotImplementedError`` for it. """ @@ -1728,9 +1705,7 @@ def _capture_agent(*args: Any, **kw: Any) -> Any: captured_calls.append((args, kw)) return mocks["_agent"] - mocks["langgraph.prebuilt"].create_react_agent = MagicMock( - side_effect=_capture_agent - ) + mocks["langchain.agents"].create_agent = MagicMock(side_effect=_capture_agent) with _patch_lc(mocks): with patch.object(spans_mod, "_HAS_OTEL", False): @@ -1745,13 +1720,10 @@ def _capture_agent(*args: Any, **kw: Any) -> Any: ) await h(config, "hi") - if captured_calls: - # The system prompt is passed as the "prompt" keyword argument - kw = captured_calls[0][1] - system = kw.get("prompt", "") - assert ( - "json" in (system or "").lower() or "schema" in (system or "").lower() - ) + assert captured_calls, "create_agent was not called" + # The system prompt is passed as create_agent's "system_prompt" keyword argument + system = captured_calls[0][1].get("system_prompt", "") + assert "json" in (system or "").lower() or "schema" in (system or "").lower() # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 8aebcdd1..16afcbe4 100644 --- a/uv.lock +++ b/uv.lock @@ -683,6 +683,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "langchain" +version = "1.3.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/68/a6dbad9c22df4087a0f9e79ddd46226c442b30128bfeee538d5889492a73/langchain-1.3.14.tar.gz", hash = "sha256:1b6696c72ba3bbbce54d745e0180742c9f6ece8bbc59ed5a46c3e20b9a435929", size = 645181, upload-time = "2026-07-16T13:28:18.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ec/0f942e78a621f8e3162ff1ed24284f469aaf51fb4607ee5831c626f2b2bc/langchain-1.3.14-py3-none-any.whl", hash = "sha256:4d10dbe91005952cddd56d0dc77aa108964da6bae90ab20063653957e901f782", size = 139560, upload-time = "2026-07-16T13:28:16.498Z" }, +] + [[package]] name = "langchain-anthropic" version = "1.4.8" @@ -884,6 +898,7 @@ name = "launchdarkly-ai-langchain-agents" version = "0.2.5" source = { editable = "packages/langchain-agents" } dependencies = [ + { name = "langchain" }, { name = "langchain-core" }, { name = "langgraph" }, { name = "launchdarkly-ai-server" }, @@ -892,6 +907,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "langchain", specifier = ">=1.0" }, { name = "langchain-core", specifier = ">=0.3" }, { name = "langgraph", specifier = ">=0.2" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, From 1afec862b7333081b94b4e9aebadd4beb5111250 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Fri, 23 Oct 2026 12:13:14 -0700 Subject: [PATCH 2/2] fix(AIC-3590): require langchain-core and langgraph 1.0 create_agent comes from langchain 1.0, which needs the 1.0 lines of langchain-core and langgraph. The old floors let a resolver pick a combination the handler cannot run on. Co-Authored-By: Claude Opus 5.5 --- packages/langchain-agents/pyproject.toml | 4 ++-- uv.lock | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/langchain-agents/pyproject.toml b/packages/langchain-agents/pyproject.toml index 2c63289a..6eacb196 100644 --- a/packages/langchain-agents/pyproject.toml +++ b/packages/langchain-agents/pyproject.toml @@ -6,8 +6,8 @@ dependencies = [ "launchdarkly-ai-server>=0.4.1", "opentelemetry-api>=1.25", "langchain>=1.0", - "langchain-core>=0.3", - "langgraph>=0.2", + "langchain-core>=1.0", + "langgraph>=1.0", ] description = "LangChain/LangGraph agent handler for LaunchDarkly AI SDK" readme = "README.md" diff --git a/uv.lock b/uv.lock index bf0aad68..3e697a35 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", @@ -1381,8 +1381,8 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "langchain", specifier = ">=1.0" }, - { name = "langchain-core", specifier = ">=0.3" }, - { name = "langgraph", specifier = ">=0.2" }, + { name = "langchain-core", specifier = ">=1.0" }, + { name = "langgraph", specifier = ">=1.0" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ]