Skip to content

Commit 3dcfcfd

Browse files
committed
feat: Refactor AgentService to integrate LangGraph and replace OpenAI SDK usage
Signed-off-by: Andre Bossard <anbossar@microsoft.com>
1 parent 576eec9 commit 3dcfcfd

2 files changed

Lines changed: 122 additions & 135 deletions

File tree

backend/agents.py

Lines changed: 56 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,17 @@
2929
LangGraph workflow with nodes, edges, and conditional routing.
3030
"""
3131

32-
import json
33-
3432
# Standard library
3533
import os
3634
from datetime import datetime
3735
from typing import Literal, Optional
3836

3937
# Local - Import operations registry for automatic tool discovery
40-
from api_decorators import get_operations
38+
from api_decorators import get_langchain_tools
4139

42-
# Third-party - OpenAI SDK
43-
from openai import OpenAI
40+
# Third-party - LangChain and LangGraph
41+
from langchain_openai import ChatOpenAI
42+
from langgraph.prebuilt import create_react_agent
4443

4544
# Third-party - Pydantic for validation
4645
from pydantic import BaseModel, Field, field_validator
@@ -173,43 +172,21 @@ def __init__(self):
173172
"environment variables. See .env.example for template."
174173
)
175174

176-
# Initialize OpenAI client with Azure endpoint
177-
self.client = OpenAI(
175+
# Initialize ChatOpenAI with Azure endpoint using base_url pattern
176+
self.llm = ChatOpenAI(
178177
base_url=AZURE_OPENAI_ENDPOINT,
179-
api_key=AZURE_OPENAI_API_KEY,
178+
api_key=AZURE_OPENAI_API_KEY, # type: ignore
179+
model=AZURE_OPENAI_DEPLOYMENT,
180+
temperature=0.7,
180181
)
181-
self.model = AZURE_OPENAI_DEPLOYMENT
182182

183-
# Load tools from operation registry
184-
# These are automatically generated from @operation decorated functions
185-
self.tools = self._get_tools_for_openai()
186-
187-
def _get_tools_for_openai(self) -> list[dict]:
188-
"""Convert operations to OpenAI function calling format."""
189-
operations = get_operations()
190-
tools = []
191-
192-
for op_name, op in operations.items():
193-
# Get the input schema from the operation
194-
input_schema = op.get_mcp_input_schema()
195-
196-
# Convert operation to OpenAI tool schema
197-
tool_def = {
198-
"type": "function",
199-
"function": {
200-
"name": op.name,
201-
"description": op.description or "",
202-
"parameters": input_schema
203-
}
204-
}
205-
206-
tools.append(tool_def)
207-
208-
return tools
183+
# Load LangGraph tools from operation registry
184+
# These are the actual Python functions decorated with @tool
185+
self.tools = get_langchain_tools()
209186

210187
async def run_agent(self, request: AgentRequest) -> AgentResponse:
211188
"""
212-
Run a ReAct agent with the given request using native OpenAI SDK.
189+
Run a ReAct agent with the given request using LangGraph.
213190
214191
The agent uses a ReAct (Reasoning + Acting) loop:
215192
1. Receive user prompt
@@ -218,7 +195,8 @@ async def run_agent(self, request: AgentRequest) -> AgentResponse:
218195
4. Observe results
219196
5. Repeat until task is complete
220197
221-
All @operation decorated functions are automatically available as tools.
198+
All @operation decorated functions are automatically available as LangGraph tools.
199+
LangGraph calls the Python functions directly (not via MCP).
222200
223201
Args:
224202
request: AgentRequest with prompt and agent type
@@ -230,84 +208,57 @@ async def run_agent(self, request: AgentRequest) -> AgentResponse:
230208
ValueError: If agent execution fails
231209
"""
232210
try:
211+
# Create ReAct agent with LangGraph tools
212+
# The tools are the actual Python functions with @tool decorator
213+
agent = create_react_agent(self.llm, self.tools)
214+
233215
# System message to guide the agent's behavior
234216
system_msg = (
235217
"You are a helpful task management assistant. "
236-
"You can create, update, delete, and list tasks. "
237-
"When asked to create tasks, use descriptive titles. "
238-
"Always confirm what you've done and show the results."
218+
"You MUST use the available tools to perform all actions. "
219+
"NEVER pretend or simulate creating, updating, or listing tasks - you MUST call the actual tools. "
220+
"Available tools: create_task, update_task, delete_task, list_tasks, get_task, get_task_stats. "
221+
"When asked to create tasks, call create_task for each one. "
222+
"When asked to list tasks, call list_tasks. "
223+
"Always use tools and confirm what you've done based on the tool results."
239224
)
240225

241-
messages: list = [
242-
{"role": "system", "content": system_msg},
243-
{"role": "user", "content": request.prompt}
244-
]
226+
# Execute agent with user prompt
227+
print(f"DEBUG: Running agent with {len(self.tools)} tools")
228+
print(f"DEBUG: Tools: {[t.name if hasattr(t, 'name') else str(t) for t in self.tools]}")
245229

246-
tools_used = []
247-
max_iterations = 10
230+
result = await agent.ainvoke(
231+
{"messages": [("system", system_msg), ("user", request.prompt)]}
232+
)
248233

249-
# ReAct loop
250-
for _ in range(max_iterations):
251-
response = self.client.chat.completions.create(
252-
model=self.model,
253-
messages=messages, # type: ignore
254-
tools=self.tools, # type: ignore
255-
tool_choice="auto"
256-
)
257-
258-
message = response.choices[0].message
259-
260-
# Convert message to dict for appending
261-
message_dict: dict = {"role": "assistant", "content": message.content or ""}
262-
if message.tool_calls:
263-
message_dict["tool_calls"] = [
264-
{
265-
"id": tc.id,
266-
"type": tc.type,
267-
"function": {"name": tc.function.name, "arguments": tc.function.arguments} # type: ignore
268-
} for tc in message.tool_calls
269-
]
270-
messages.append(message_dict)
271-
272-
# If no tool calls, we're done
273-
if not message.tool_calls:
274-
break
275-
276-
# Execute tool calls
277-
for tool_call in message.tool_calls:
278-
function_name = tool_call.function.name # type: ignore
279-
tools_used.append(function_name)
280-
281-
try:
282-
# Parse arguments
283-
arguments = json.loads(tool_call.function.arguments) # type: ignore
284-
285-
# Find and execute the operation
286-
operations = get_operations()
287-
operation = operations.get(function_name)
288-
289-
if operation:
290-
result = await operation.handler(**arguments)
291-
result_str = json.dumps(result.model_dump() if hasattr(result, 'model_dump') else result)
292-
else:
293-
result_str = json.dumps({"error": f"Unknown tool: {function_name}"})
294-
295-
except Exception as e:
296-
result_str = json.dumps({"error": str(e)})
297-
298-
# Add tool result to messages
299-
messages.append({
300-
"role": "tool",
301-
"tool_call_id": tool_call.id,
302-
"content": result_str
303-
})
234+
print(f"DEBUG: Agent result messages: {len(result['messages'])}")
235+
for i, msg in enumerate(result["messages"]):
236+
print(f"DEBUG: Message {i}: type={type(msg).__name__}, has_tool_calls={hasattr(msg, 'tool_calls')}")
237+
if hasattr(msg, 'tool_calls') and msg.tool_calls:
238+
print(f"DEBUG: Tool calls: {msg.tool_calls}")
304239

305-
# Get final response
306-
last_msg = messages[-1]
307-
final_content = last_msg.get("content", "") if isinstance(last_msg, dict) else str(last_msg)
240+
# Extract the agent's final response
241+
final_message = result["messages"][-1]
242+
agent_output = final_message.content if hasattr(final_message, 'content') else str(final_message)
243+
244+
# Track which tools were used
245+
tools_used = []
246+
for msg in result["messages"]:
247+
# Check for tool_calls attribute
248+
if hasattr(msg, 'tool_calls') and msg.tool_calls:
249+
for tc in msg.tool_calls:
250+
# Extract tool name from tool call
251+
if isinstance(tc, dict):
252+
tools_used.append(tc.get('name', ''))
253+
else:
254+
tools_used.append(tc['name'] if 'name' in tc else str(tc))
255+
# Also check for ToolMessage type (tool results)
256+
elif hasattr(msg, 'type') and msg.type == 'tool':
257+
if hasattr(msg, 'name'):
258+
tools_used.append(msg.name)
308259

309260
return AgentResponse(
310-
result=final_content,
261+
result=agent_output,
311262
agent_type=request.agent_type,
312263
tools_used=list(set(tools_used)),
313264
created_at=datetime.now()

backend/api_decorators.py

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from pydantic import BaseModel
2626

27-
# Optional LangChain imports for agent tool support
27+
# Optional LangChain/LangGraph imports for agent tool support
2828
try:
2929
from langchain_core.tools import StructuredTool
3030
LANGCHAIN_AVAILABLE = True
@@ -207,48 +207,68 @@ def to_langchain_tool(self) -> Any:
207207
"""
208208
Convert operation to LangChain StructuredTool.
209209
210-
This enables LangGraph agents to automatically use operations as tools.
211-
Requires langchain-core to be installed.
210+
Creates a tool that wraps the handler and reconstructs Pydantic models
211+
from flattened parameters.
212212
213213
Returns:
214-
StructuredTool instance that wraps this operation
214+
StructuredTool instance
215215
216216
Raises:
217217
ImportError: If langchain-core is not available
218218
"""
219219
if not LANGCHAIN_AVAILABLE:
220220
raise ImportError(
221-
"langchain-core is required to convert operations to LangChain tools. "
221+
"langchain-core is required to use LangGraph tools. "
222222
"Install with: pip install langchain-core"
223223
)
224-
225-
# Create async wrapper that calls the operation handler
226-
async def tool_func(**kwargs):
227-
"""Execute the operation with provided arguments."""
228-
# Parse arguments using operation's type hints
229-
parsed_args = self.parse_arguments(kwargs)
230-
# Call the handler
231-
result = await self.handler(**parsed_args)
232-
# Return result (LangChain will handle serialization)
233-
return result
234-
235-
# Extract input schema from the operation
236-
input_schema = self.get_mcp_input_schema()
237-
238-
# Create StructuredTool
224+
225+
# Create a wrapper that reconstructs Pydantic models from flattened params
226+
async def tool_wrapper(**kwargs):
227+
\"\"\"Wrapper that converts flattened params back to Pydantic models.\"\"\"
228+
sig = inspect.signature(self.handler)
229+
hints = get_type_hints(self.handler)
230+
231+
# Check if handler expects a Pydantic model parameter
232+
handler_args = {}
233+
for param_name, param in sig.parameters.items():
234+
if param_name == 'self':
235+
continue
236+
237+
param_type = hints.get(param_name, Any)
238+
239+
# If parameter is a Pydantic model, reconstruct it from kwargs
240+
if inspect.isclass(param_type) and issubclass(param_type, BaseModel):
241+
# Collect all fields that belong to this Pydantic model
242+
model_data = {}
243+
for field_name in param_type.model_fields.keys():
244+
if field_name in kwargs:
245+
model_data[field_name] = kwargs[field_name]
246+
247+
# Instantiate the Pydantic model
248+
handler_args[param_name] = param_type(**model_data)
249+
else:
250+
# Simple parameter - pass through
251+
if param_name in kwargs:
252+
handler_args[param_name] = kwargs[param_name]
253+
254+
# Call the handler with reconstructed arguments
255+
return await self.handler(**handler_args)
256+
257+
# Create StructuredTool with the wrapper
239258
return StructuredTool(
240259
name=self.name,
241260
description=self.description,
242-
coroutine=tool_func,
261+
coroutine=tool_wrapper,
243262
args_schema=self._create_pydantic_model_for_langchain(),
244263
)
245264

246265
def _create_pydantic_model_for_langchain(self) -> type[BaseModel]:
247266
"""
248267
Create a Pydantic model from operation parameters for LangChain.
249268
250-
LangChain StructuredTool requires a Pydantic model for args_schema.
251-
This dynamically creates one from the function signature.
269+
If the operation takes a Pydantic model parameter, we flatten it
270+
so LangGraph tools get individual fields (title, description) instead
271+
of a nested object (data: {title, description}).
252272
253273
Returns:
254274
Pydantic model class representing the operation's parameters
@@ -259,20 +279,36 @@ def _create_pydantic_model_for_langchain(self) -> type[BaseModel]:
259279
hints = get_type_hints(self.handler)
260280

261281
fields = {}
282+
262283
for param_name, param in sig.parameters.items():
263284
if param_name == 'self':
264285
continue
265286

266287
param_type = hints.get(param_name, Any)
267-
default_value = ... if param.default == inspect.Parameter.empty else param.default
268-
269-
# Add field with proper type and default
270-
if default_value is ...:
271-
fields[param_name] = (param_type, Field(description=f"{param_name} parameter"))
288+
289+
# If parameter is a Pydantic model, flatten its fields
290+
if inspect.isclass(param_type) and issubclass(param_type, BaseModel):
291+
# Get the Pydantic model's fields and add them directly
292+
for field_name, field_info in param_type.model_fields.items():
293+
# Use the field's annotation and default from the Pydantic model
294+
field_type = field_info.annotation
295+
field_default = field_info.default if field_info.default is not None else ...
296+
field_desc = field_info.description or f"{field_name} parameter"
297+
298+
if field_default is ...:
299+
fields[field_name] = (field_type, Field(description=field_desc))
300+
else:
301+
fields[field_name] = (field_type, Field(default=field_default, description=field_desc))
272302
else:
273-
fields[param_name] = (param_type, Field(default=default_value, description=f"{param_name} parameter"))
303+
# Simple parameter - add directly
304+
default_value = ... if param.default == inspect.Parameter.empty else param.default
305+
306+
if default_value is ...:
307+
fields[param_name] = (param_type, Field(description=f"{param_name} parameter"))
308+
else:
309+
fields[param_name] = (param_type, Field(default=default_value, description=f"{param_name} parameter"))
274310

275-
# Create dynamic Pydantic model
311+
# Create dynamic Pydantic model with flattened fields
276312
model_name = f"{self.name.title().replace('_', '')}Input"
277313
return create_model(model_name, **fields)
278314

0 commit comments

Comments
 (0)