Skip to content

Commit 0e0c30f

Browse files
committed
feat: Introduce ManagedAgent and AgentRunner implementations
feat: Add OpenAIAgentRunner with agentic tool-calling loop feat: Add LangChainAgentRunner with agentic tool-calling loop feat: Add OpenAIRunnerFactory.create_agent(config, tools) -> OpenAIAgentRunner feat: Add LangChainRunnerFactory.create_agent(config, tools) -> LangChainAgentRunner feat: Add ManagedAgent wrapper holding AgentRunner and LDAIConfigTracker feat: Add LDAIClient.create_agent() returning ManagedAgent
1 parent 4fab18f commit 0e0c30f

12 files changed

Lines changed: 871 additions & 1 deletion

File tree

packages/ai-providers/server-ai-langchain/src/ldai_langchain/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from ldai_langchain.langchain_agent_runner import LangChainAgentRunner
12
from ldai_langchain.langchain_helper import (
23
convert_messages_to_langchain,
34
create_langchain_model,
@@ -16,6 +17,7 @@
1617
'__version__',
1718
'LangChainRunnerFactory',
1819
'LangChainModelRunner',
20+
'LangChainAgentRunner',
1921
'convert_messages_to_langchain',
2022
'create_langchain_model',
2123
'get_ai_metrics_from_response',
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""LangChain agent runner for LaunchDarkly AI SDK."""
2+
3+
from typing import Any, Dict, List
4+
5+
from ldai import log
6+
from ldai.providers import AgentResult, AgentRunner, ToolRegistry
7+
from ldai.providers.types import LDAIMetrics
8+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
9+
10+
from ldai_langchain.langchain_helper import get_ai_metrics_from_response
11+
12+
13+
class LangChainAgentRunner(AgentRunner):
14+
"""
15+
AgentRunner implementation for LangChain.
16+
17+
Executes a single-agent loop using a LangChain BaseChatModel with tool calling.
18+
Returned by LangChainRunnerFactory.create_agent(config, tools).
19+
"""
20+
21+
def __init__(
22+
self,
23+
llm: Any,
24+
instructions: str,
25+
tool_definitions: List[Dict[str, Any]],
26+
tools: ToolRegistry,
27+
):
28+
self._llm = llm
29+
self._instructions = instructions
30+
self._tool_definitions = tool_definitions
31+
self._tools = tools
32+
33+
async def run(self, input: Any) -> AgentResult:
34+
"""
35+
Run the agent with the given input string.
36+
37+
Executes an agentic loop: calls the model, handles tool calls,
38+
and continues until the model produces a final response.
39+
40+
:param input: The user prompt or input to the agent
41+
:return: AgentResult with output, raw response, and aggregated metrics
42+
"""
43+
messages: List[BaseMessage] = []
44+
if self._instructions:
45+
messages.append(SystemMessage(content=self._instructions))
46+
messages.append(HumanMessage(content=str(input)))
47+
48+
openai_tools = self._build_openai_tools()
49+
model = self._llm.bind_tools(openai_tools) if openai_tools else self._llm
50+
51+
raw_response = None
52+
53+
try:
54+
while True:
55+
response: AIMessage = await model.ainvoke(messages)
56+
raw_response = response
57+
messages.append(response)
58+
59+
tool_calls = getattr(response, 'tool_calls', None)
60+
61+
if not tool_calls:
62+
metrics = get_ai_metrics_from_response(response)
63+
content = response.content if isinstance(response.content, str) else ""
64+
return AgentResult(
65+
output=content,
66+
raw=raw_response,
67+
metrics=metrics,
68+
)
69+
70+
# Execute tool calls and append results
71+
for tool_call in tool_calls:
72+
tool_name = tool_call["name"]
73+
tool_args = tool_call.get("args", {})
74+
tool_id = tool_call.get("id", "")
75+
76+
tool_fn = self._tools.get(tool_name)
77+
if tool_fn:
78+
try:
79+
result = tool_fn(**tool_args)
80+
if hasattr(result, "__await__"):
81+
result = await result
82+
result_str = str(result)
83+
except Exception as error:
84+
log.warning(f"Tool '{tool_name}' execution failed: {error}")
85+
result_str = f"Tool execution failed: {error}"
86+
else:
87+
log.warning(f"Tool '{tool_name}' not found in registry")
88+
result_str = f"Tool '{tool_name}' not found"
89+
90+
messages.append(ToolMessage(content=result_str, tool_call_id=tool_id))
91+
92+
except Exception as error:
93+
log.warning(f"LangChain agent run failed: {error}")
94+
return AgentResult(
95+
output="",
96+
raw=raw_response,
97+
metrics=LDAIMetrics(success=False, usage=None),
98+
)
99+
100+
def _build_openai_tools(self) -> List[Dict[str, Any]]:
101+
"""Convert LD tool definitions to OpenAI function-calling format for bind_tools."""
102+
tools = []
103+
for td in self._tool_definitions:
104+
if not isinstance(td, dict):
105+
continue
106+
if "type" in td:
107+
tools.append(td)
108+
elif "name" in td:
109+
tools.append({
110+
"type": "function",
111+
"function": {
112+
"name": td["name"],
113+
"description": td.get("description", ""),
114+
"parameters": td.get("parameters", {"type": "object", "properties": {}}),
115+
},
116+
})
117+
return tools
118+
119+
def get_llm(self) -> Any:
120+
"""Return the underlying LangChain LLM."""
121+
return self._llm

packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_runner_factory.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any
2+
13
from ldai.models import AIConfigKind
24
from ldai.providers import AIProvider
35

@@ -17,3 +19,22 @@ def create_model(self, config: AIConfigKind) -> LangChainModelRunner:
1719
"""
1820
llm = create_langchain_model(config)
1921
return LangChainModelRunner(llm)
22+
23+
def create_agent(self, config: Any, tools: Any) -> 'LangChainAgentRunner':
24+
"""
25+
Create a configured LangChainAgentRunner for the given AI agent config.
26+
27+
:param config: The LaunchDarkly AI agent configuration
28+
:param tools: ToolRegistry mapping tool names to callables
29+
:return: LangChainAgentRunner ready to run the agent
30+
"""
31+
from ldai_langchain.langchain_agent_runner import LangChainAgentRunner
32+
33+
config_dict = config.to_dict()
34+
model_dict = config_dict.get('model') or {}
35+
parameters = dict(model_dict.get('parameters') or {})
36+
tool_definitions = parameters.pop('tools', []) or []
37+
instructions = config.instructions or '' if hasattr(config, 'instructions') else ''
38+
39+
llm = LangChainHelper.create_langchain_model(config)
40+
return LangChainAgentRunner(llm, instructions, tool_definitions, tools or {})

packages/ai-providers/server-ai-langchain/tests/test_langchain_provider.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,125 @@ def test_returns_underlying_llm(self):
330330
runner = LangChainModelRunner(mock_llm)
331331

332332
assert runner.get_llm() is mock_llm
333+
334+
335+
class TestCreateAgent:
336+
"""Tests for LangChainRunnerFactory.create_agent."""
337+
338+
def test_creates_agent_runner_with_instructions_and_tool_definitions(self):
339+
"""Should create LangChainAgentRunner with instructions and tool definitions."""
340+
from unittest.mock import patch
341+
from ldai_langchain import LangChainAgentRunner
342+
343+
mock_ai_config = MagicMock()
344+
mock_ai_config.instructions = "You are a helpful assistant."
345+
mock_ai_config.to_dict.return_value = {
346+
'model': {
347+
'name': 'gpt-4',
348+
'parameters': {
349+
'tools': [
350+
{'name': 'get-weather', 'description': 'Get weather', 'parameters': {}},
351+
],
352+
},
353+
},
354+
'provider': {'name': 'openai'},
355+
}
356+
357+
with patch.object(LangChainHelper, 'create_langchain_model') as mock_create:
358+
mock_llm = MagicMock()
359+
mock_create.return_value = mock_llm
360+
361+
factory = LangChainRunnerFactory()
362+
result = factory.create_agent(mock_ai_config, {'get-weather': lambda loc: 'sunny'})
363+
364+
assert isinstance(result, LangChainAgentRunner)
365+
assert result._instructions == "You are a helpful assistant."
366+
assert len(result._tool_definitions) == 1
367+
368+
def test_creates_agent_runner_with_no_tools(self):
369+
"""Should create LangChainAgentRunner with no tool definitions."""
370+
from unittest.mock import patch
371+
from ldai_langchain import LangChainAgentRunner
372+
373+
mock_ai_config = MagicMock()
374+
mock_ai_config.instructions = "You are a helpful assistant."
375+
mock_ai_config.to_dict.return_value = {
376+
'model': {'name': 'gpt-4', 'parameters': {}},
377+
'provider': {'name': 'openai'},
378+
}
379+
380+
with patch.object(LangChainHelper, 'create_langchain_model') as mock_create:
381+
mock_create.return_value = MagicMock()
382+
383+
factory = LangChainRunnerFactory()
384+
result = factory.create_agent(mock_ai_config, {})
385+
386+
assert isinstance(result, LangChainAgentRunner)
387+
assert result._tool_definitions == []
388+
389+
390+
class TestLangChainAgentRunner:
391+
"""Tests for LangChainAgentRunner.run."""
392+
393+
@pytest.mark.asyncio
394+
async def test_runs_agent_and_returns_result_with_no_tool_calls(self):
395+
"""Should return AgentResult when model responds with no tool calls."""
396+
from ldai_langchain import LangChainAgentRunner
397+
from langchain_core.messages import AIMessage
398+
399+
mock_llm = MagicMock()
400+
mock_response = AIMessage(content="The answer is 42.")
401+
mock_llm.bind_tools = MagicMock(return_value=mock_llm)
402+
mock_llm.ainvoke = AsyncMock(return_value=mock_response)
403+
404+
runner = LangChainAgentRunner(mock_llm, "You are helpful.", [], {})
405+
result = await runner.run("What is the answer?")
406+
407+
assert result.output == "The answer is 42."
408+
assert result.metrics.success is True
409+
410+
@pytest.mark.asyncio
411+
async def test_executes_tool_calls_and_returns_final_response(self):
412+
"""Should execute tool calls and continue loop until final response."""
413+
from ldai_langchain import LangChainAgentRunner
414+
from langchain_core.messages import AIMessage
415+
416+
# First response: has a tool call
417+
first_response = AIMessage(content="")
418+
first_response.tool_calls = [
419+
{"name": "get-weather", "args": {"location": "Paris"}, "id": "call_123"}
420+
]
421+
422+
# Second response: final answer
423+
second_response = AIMessage(content="It is sunny in Paris.")
424+
425+
mock_llm = MagicMock()
426+
mock_llm.bind_tools = MagicMock(return_value=mock_llm)
427+
mock_llm.ainvoke = AsyncMock(side_effect=[first_response, second_response])
428+
429+
weather_fn = MagicMock(return_value="Sunny, 25°C")
430+
runner = LangChainAgentRunner(
431+
mock_llm, "You are helpful.",
432+
[{'name': 'get-weather', 'description': 'Get weather', 'parameters': {}}],
433+
{'get-weather': weather_fn},
434+
)
435+
result = await runner.run("What is the weather in Paris?")
436+
437+
assert result.output == "It is sunny in Paris."
438+
assert result.metrics.success is True
439+
weather_fn.assert_called_once_with(location="Paris")
440+
441+
@pytest.mark.asyncio
442+
async def test_returns_failure_when_exception_thrown(self):
443+
"""Should return unsuccessful AgentResult when exception is thrown."""
444+
from ldai_langchain import LangChainAgentRunner
445+
446+
mock_llm = MagicMock()
447+
mock_llm.bind_tools = MagicMock(return_value=mock_llm)
448+
mock_llm.ainvoke = AsyncMock(side_effect=Exception("LLM Error"))
449+
450+
runner = LangChainAgentRunner(mock_llm, "", [], {})
451+
result = await runner.run("Hello")
452+
453+
assert result.output == ""
454+
assert result.metrics.success is False

packages/ai-providers/server-ai-openai/src/ldai_openai/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from ldai_openai.openai_agent_runner import OpenAIAgentRunner
12
from ldai_openai.openai_helper import (
23
convert_messages_to_openai,
34
get_ai_metrics_from_response,
@@ -9,6 +10,7 @@
910
__all__ = [
1011
'OpenAIRunnerFactory',
1112
'OpenAIModelRunner',
13+
'OpenAIAgentRunner',
1214
'convert_messages_to_openai',
1315
'get_ai_metrics_from_response',
1416
'get_ai_usage_from_response',

0 commit comments

Comments
 (0)