Skip to content

Commit 53fd95e

Browse files
authored
feat: Introduce ManagedAgent and AgentRunner implementations (#110)
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 LDAIClient.create_agent() returning ManagedAgent
1 parent 56ce0fd commit 53fd95e

16 files changed

Lines changed: 1128 additions & 94 deletions

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,
@@ -18,6 +19,7 @@
1819
'LangChainRunnerFactory',
1920
'LangGraphAgentGraphRunner',
2021
'LangChainModelRunner',
22+
'LangChainAgentRunner',
2123
'convert_messages_to_langchain',
2224
'create_langchain_model',
2325
'get_ai_metrics_from_response',
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""LangChain agent runner for LaunchDarkly AI SDK."""
2+
3+
from typing import Any
4+
5+
from ldai import log
6+
from ldai.providers import AgentResult, AgentRunner
7+
from ldai.providers.types import LDAIMetrics
8+
9+
from ldai_langchain.langchain_helper import sum_token_usage_from_messages
10+
11+
12+
class LangChainAgentRunner(AgentRunner):
13+
"""
14+
AgentRunner implementation for LangChain.
15+
16+
Wraps a compiled LangChain agent graph (from ``langchain.agents.create_agent``)
17+
and delegates execution to it. Tool calling and loop management are handled
18+
internally by the graph.
19+
Returned by LangChainRunnerFactory.create_agent(config, tools).
20+
"""
21+
22+
def __init__(self, agent: Any):
23+
self._agent = agent
24+
25+
async def run(self, input: Any) -> AgentResult:
26+
"""
27+
Run the agent with the given input string.
28+
29+
Delegates to the compiled LangChain agent, which handles
30+
the tool-calling loop internally.
31+
32+
:param input: The user prompt or input to the agent
33+
:return: AgentResult with output, raw response, and aggregated metrics
34+
"""
35+
try:
36+
result = await self._agent.ainvoke({
37+
"messages": [{"role": "user", "content": str(input)}]
38+
})
39+
messages = result.get("messages", [])
40+
output = ""
41+
if messages:
42+
last = messages[-1]
43+
if hasattr(last, 'content') and isinstance(last.content, str):
44+
output = last.content
45+
return AgentResult(
46+
output=output,
47+
raw=result,
48+
metrics=LDAIMetrics(
49+
success=True,
50+
usage=sum_token_usage_from_messages(messages),
51+
),
52+
)
53+
except Exception as error:
54+
log.warning(f"LangChain agent run failed: {error}")
55+
return AgentResult(
56+
output="",
57+
raw=None,
58+
metrics=LDAIMetrics(success=False, usage=None),
59+
)
60+
61+
def get_agent(self) -> Any:
62+
"""Return the underlying compiled LangChain agent."""
63+
return self._agent

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

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from langchain_core.language_models.chat_models import BaseChatModel
44
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
5-
from ldai import LDMessage
5+
from ldai import LDMessage, log
66
from ldai.models import AIConfigKind
7+
from ldai.providers import ToolRegistry
78
from ldai.providers.types import LDAIMetrics
89
from ldai.tracker import TokenUsage
910

@@ -50,12 +51,18 @@ def convert_messages_to_langchain(
5051
return result
5152

5253

53-
def create_langchain_model(ai_config: AIConfigKind) -> BaseChatModel:
54+
def create_langchain_model(ai_config: AIConfigKind, tool_registry: Optional[ToolRegistry] = None) -> BaseChatModel:
5455
"""
5556
Create a LangChain BaseChatModel from a LaunchDarkly AI configuration.
5657
58+
If the config includes tool definitions and a tool_registry is provided, tools found
59+
in the registry are bound to the model. Tools not found in the registry are skipped
60+
with a warning. Built-in provider tools (e.g. code_interpreter) are not supported
61+
via LangChain's bind_tools abstraction and are skipped with a warning.
62+
5763
:param ai_config: The LaunchDarkly AI configuration
58-
:return: A configured LangChain BaseChatModel
64+
:param tool_registry: Optional registry mapping tool names to callable implementations
65+
:return: A configured LangChain BaseChatModel, with tools bound if applicable
5966
"""
6067
from langchain.chat_models import init_chat_model
6168

@@ -66,19 +73,113 @@ def create_langchain_model(ai_config: AIConfigKind) -> BaseChatModel:
6673
model_name = model_dict.get('name', '')
6774
provider = provider_dict.get('name', '')
6875
parameters = dict(model_dict.get('parameters') or {})
76+
tool_definitions = parameters.pop('tools', []) or []
6977
mapped_provider = map_provider(provider)
7078

7179
# Bedrock requires the foundation provider (e.g. Bedrock:Anthropic) passed in
7280
# parameters separately from model_provider, which is used for LangChain routing.
7381
if mapped_provider == 'bedrock_converse' and 'provider' not in parameters:
7482
parameters['provider'] = provider.removeprefix('bedrock:')
7583

76-
return init_chat_model(
84+
model = init_chat_model(
7785
model_name,
7886
model_provider=mapped_provider,
7987
**parameters,
8088
)
8189

90+
if tool_definitions and tool_registry is not None:
91+
bindable = _resolve_tools_for_langchain(tool_definitions, tool_registry)
92+
if bindable:
93+
model = model.bind_tools(bindable)
94+
95+
return model
96+
97+
98+
def _iter_valid_tools(
99+
tool_definitions: List[Dict[str, Any]],
100+
tool_registry: ToolRegistry,
101+
) -> List[tuple]:
102+
"""
103+
Filter LD tool definitions against a registry, returning (name, td) pairs for each
104+
valid function tool that has a callable implementation. Built-in provider tools and
105+
tools missing from the registry are skipped with a warning.
106+
"""
107+
valid = []
108+
for td in tool_definitions:
109+
if not isinstance(td, dict):
110+
continue
111+
112+
tool_type = td.get('type')
113+
if tool_type and tool_type != 'function':
114+
log.warning(
115+
f"Built-in tool '{tool_type}' is not reliably supported via LangChain and will be skipped. "
116+
"Use a provider-specific runner to use built-in provider tools."
117+
)
118+
continue
119+
120+
name = td.get('name')
121+
if not name:
122+
continue
123+
124+
if name not in tool_registry:
125+
log.warning(f"Tool '{name}' is defined in the AI config but was not found in the tool registry; skipping.")
126+
continue
127+
128+
valid.append((name, td))
129+
130+
return valid
131+
132+
133+
def _resolve_tools_for_langchain(
134+
tool_definitions: List[Dict[str, Any]],
135+
tool_registry: ToolRegistry,
136+
) -> List[Dict[str, Any]]:
137+
"""
138+
Match LD tool definitions against a registry, returning function-calling tool dicts
139+
for tools that have a callable implementation. Built-in provider tools and tools
140+
missing from the registry are skipped with a warning.
141+
"""
142+
return [
143+
{
144+
'type': 'function',
145+
'function': {
146+
'name': name,
147+
'description': td.get('description', ''),
148+
'parameters': td.get('parameters', {'type': 'object', 'properties': {}}),
149+
},
150+
}
151+
for name, td in _iter_valid_tools(tool_definitions, tool_registry)
152+
]
153+
154+
155+
def build_structured_tools(ai_config: AIConfigKind, tool_registry: ToolRegistry) -> List[Any]:
156+
"""
157+
Build a list of LangChain StructuredTool instances from LD tool definitions and a registry.
158+
159+
Tools found in the registry are wrapped as StructuredTool with the name and description
160+
from the LD config. Built-in provider tools and tools missing from the registry are
161+
skipped with a warning.
162+
163+
:param ai_config: The LaunchDarkly AI configuration
164+
:param tool_registry: Registry mapping tool names to callable implementations
165+
:return: List of StructuredTool instances ready to pass to langchain.agents.create_agent
166+
"""
167+
from langchain_core.tools import StructuredTool
168+
169+
config_dict = ai_config.to_dict()
170+
model_dict = config_dict.get('model') or {}
171+
parameters = dict(model_dict.get('parameters') or {})
172+
tool_definitions = parameters.pop('tools', []) or []
173+
174+
return [
175+
StructuredTool.from_function(
176+
func=tool_registry[name],
177+
name=name,
178+
description=td.get('description', ''),
179+
)
180+
for name, td in _iter_valid_tools(tool_definitions, tool_registry)
181+
]
182+
82183

83184
def get_ai_usage_from_response(response: Any) -> Optional[TokenUsage]:
84185
"""
@@ -88,11 +189,11 @@ def get_ai_usage_from_response(response: Any) -> Optional[TokenUsage]:
88189
:return: TokenUsage or None if unavailable
89190
"""
90191
if hasattr(response, 'usage_metadata') and response.usage_metadata:
91-
return TokenUsage(
92-
total=response.usage_metadata.get('total_tokens', 0),
93-
input=response.usage_metadata.get('input_tokens', 0),
94-
output=response.usage_metadata.get('output_tokens', 0),
95-
)
192+
total = response.usage_metadata.get('total_tokens', 0)
193+
inp = response.usage_metadata.get('input_tokens', 0)
194+
out = response.usage_metadata.get('output_tokens', 0)
195+
if total or inp or out:
196+
return TokenUsage(total=total, input=inp, output=out)
96197
if hasattr(response, 'response_metadata') and response.response_metadata:
97198
token_usage = (
98199
response.response_metadata.get('tokenUsage')

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
from typing import Any
1+
from typing import Any, Optional
22

33
from ldai.models import AIConfigKind
44
from ldai.providers import AIProvider, ToolRegistry
55

6-
from ldai_langchain.langchain_helper import create_langchain_model
6+
from ldai_langchain.langchain_agent_runner import LangChainAgentRunner
7+
from ldai_langchain.langchain_helper import (
8+
build_structured_tools,
9+
create_langchain_model,
10+
)
711
from ldai_langchain.langchain_model_runner import LangChainModelRunner
812

913

@@ -32,3 +36,23 @@ def create_model(self, config: AIConfigKind) -> LangChainModelRunner:
3236
"""
3337
llm = create_langchain_model(config)
3438
return LangChainModelRunner(llm)
39+
40+
def create_agent(self, config: Any, tools: Optional[ToolRegistry] = None) -> LangChainAgentRunner:
41+
"""
42+
Create a configured LangChainAgentRunner for the given AI agent config.
43+
44+
:param config: The LaunchDarkly AI agent configuration
45+
:param tools: ToolRegistry mapping tool names to callables
46+
:return: LangChainAgentRunner ready to run the agent
47+
"""
48+
from langchain.agents import create_agent as lc_create_agent
49+
instructions = (config.instructions or '') if hasattr(config, 'instructions') else ''
50+
llm = create_langchain_model(config)
51+
lc_tools = build_structured_tools(config, tools or {})
52+
53+
agent = lc_create_agent(
54+
llm,
55+
tools=lc_tools or None,
56+
system_prompt=instructions or None,
57+
)
58+
return LangChainAgentRunner(agent)

0 commit comments

Comments
 (0)