universal-mcp-agents 0.1.7__py3-none-any.whl → 0.1.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of universal-mcp-agents might be problematic. Click here for more details.
- universal_mcp/agents/__init__.py +4 -1
- universal_mcp/agents/bigtool2/graph.py +49 -6
- universal_mcp/agents/builder.py +29 -8
- universal_mcp/agents/codeact/__init__.py +2 -254
- universal_mcp/agents/codeact/__main__.py +25 -0
- universal_mcp/agents/codeact/agent.py +171 -0
- universal_mcp/agents/codeact/prompts.py +92 -0
- universal_mcp/agents/codeact/sandbox.py +40 -19
- universal_mcp/agents/codeact/state.py +12 -0
- universal_mcp/agents/llm.py +1 -1
- universal_mcp/agents/planner/graph.py +1 -1
- universal_mcp/agents/shared/prompts.py +132 -0
- universal_mcp/agents/shared/tool_node.py +214 -205
- universal_mcp/applications/ui/app.py +1 -1
- {universal_mcp_agents-0.1.7.dist-info → universal_mcp_agents-0.1.9.dist-info}/METADATA +3 -2
- {universal_mcp_agents-0.1.7.dist-info → universal_mcp_agents-0.1.9.dist-info}/RECORD +17 -13
- universal_mcp/agents/codeact/test.py +0 -16
- {universal_mcp_agents-0.1.7.dist-info → universal_mcp_agents-0.1.9.dist-info}/WHEEL +0 -0
universal_mcp/agents/__init__.py
CHANGED
|
@@ -6,6 +6,7 @@ from universal_mcp.agents.builder import BuilderAgent
|
|
|
6
6
|
from universal_mcp.agents.planner import PlannerAgent
|
|
7
7
|
from universal_mcp.agents.react import ReactAgent
|
|
8
8
|
from universal_mcp.agents.simple import SimpleAgent
|
|
9
|
+
from universal_mcp.agents.codeact import CodeActAgent
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def get_agent(agent_name: str):
|
|
@@ -23,8 +24,10 @@ def get_agent(agent_name: str):
|
|
|
23
24
|
return BigToolAgent
|
|
24
25
|
elif agent_name == "bigtool2":
|
|
25
26
|
return BigToolAgent2
|
|
27
|
+
elif agent_name == "codeact":
|
|
28
|
+
return CodeActAgent
|
|
26
29
|
else:
|
|
27
|
-
raise ValueError(f"Unknown agent: {agent_name}. Possible values: auto, react, simple, builder, planner, bigtool, bigtool2")
|
|
30
|
+
raise ValueError(f"Unknown agent: {agent_name}. Possible values: auto, react, simple, builder, planner, bigtool, bigtool2, codeact")
|
|
28
31
|
|
|
29
32
|
__all__ = [
|
|
30
33
|
"BaseAgent",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from datetime import UTC, datetime
|
|
3
3
|
from typing import Literal, cast
|
|
4
|
+
import asyncio
|
|
4
5
|
|
|
5
6
|
from langchain_core.language_models import BaseChatModel
|
|
6
7
|
from langchain_core.messages import AIMessage, ToolMessage
|
|
@@ -65,12 +66,54 @@ def build_graph(
|
|
|
65
66
|
|
|
66
67
|
@tool
|
|
67
68
|
async def load_tools(tool_ids: list[str]) -> list[str]:
|
|
68
|
-
"""
|
|
69
|
-
|
|
69
|
+
"""
|
|
70
|
+
Load the tools for the given tool ids. Returns the valid tool ids after loading.
|
|
71
|
+
Tool ids are of form 'appid__toolid'. Example: 'google_mail__send_email'
|
|
72
|
+
"""
|
|
73
|
+
correct, incorrect = [], []
|
|
74
|
+
app_tool_list: dict[str, list[str]] = {}
|
|
75
|
+
|
|
76
|
+
# Group tool_ids by app for fewer registry calls
|
|
77
|
+
app_to_tools: dict[str, list[str]] = {}
|
|
78
|
+
for tool_id in tool_ids:
|
|
79
|
+
if "__" not in tool_id:
|
|
80
|
+
incorrect.append(tool_id)
|
|
81
|
+
continue
|
|
82
|
+
app, tool = tool_id.split("__", 1)
|
|
83
|
+
app_to_tools.setdefault(app, []).append((tool_id, tool))
|
|
84
|
+
|
|
85
|
+
# Fetch all apps concurrently
|
|
86
|
+
async def fetch_tools(app: str):
|
|
87
|
+
try:
|
|
88
|
+
tools_dict = await tool_registry.list_tools(app)
|
|
89
|
+
return app, {tool_unit["name"] for tool_unit in tools_dict}
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return app, None
|
|
92
|
+
|
|
93
|
+
results = await asyncio.gather(*(fetch_tools(app) for app in app_to_tools))
|
|
94
|
+
|
|
95
|
+
# Build map of available tools per app
|
|
96
|
+
for app, tools in results:
|
|
97
|
+
if tools is not None:
|
|
98
|
+
app_tool_list[app] = tools
|
|
99
|
+
|
|
100
|
+
# Validate tool_ids
|
|
101
|
+
for app, tool_entries in app_to_tools.items():
|
|
102
|
+
available = app_tool_list.get(app)
|
|
103
|
+
if available is None:
|
|
104
|
+
incorrect.extend(tool_id for tool_id, _ in tool_entries)
|
|
105
|
+
continue
|
|
106
|
+
for tool_id, tool in tool_entries:
|
|
107
|
+
if tool in available:
|
|
108
|
+
correct.append(tool_id)
|
|
109
|
+
else:
|
|
110
|
+
incorrect.append(tool_id)
|
|
111
|
+
|
|
112
|
+
return correct
|
|
70
113
|
|
|
71
114
|
@tool
|
|
72
115
|
async def web_search(query: str) -> str:
|
|
73
|
-
"""Search the web for the given query. Returns the search results."""
|
|
116
|
+
"""Search the web for the given query. Returns the search results. Do not use for app-specific searches (for example, reddit or linkedin searches should be done using the app's tools)"""
|
|
74
117
|
tool = await tool_registry.export_tools(
|
|
75
118
|
["exa__search_with_filters"], ToolFormat.LANGCHAIN
|
|
76
119
|
)
|
|
@@ -131,10 +174,10 @@ def build_graph(
|
|
|
131
174
|
return Command(goto="select_tools", update={"messages": [response]})
|
|
132
175
|
elif tool_call["name"] == load_tools.name:
|
|
133
176
|
logger.info("Model requested to load tools.")
|
|
177
|
+
selected_tool_ids = await load_tools.ainvoke(tool_call["args"])
|
|
134
178
|
tool_msg = ToolMessage(
|
|
135
|
-
"Loaded tools
|
|
179
|
+
f"Loaded tools- {selected_tool_ids}", tool_call_id=tool_call["id"]
|
|
136
180
|
)
|
|
137
|
-
selected_tool_ids = tool_call["args"]["tool_ids"]
|
|
138
181
|
logger.info(f"Loaded tools: {selected_tool_ids}")
|
|
139
182
|
return Command(
|
|
140
183
|
goto="call_model",
|
|
@@ -194,7 +237,7 @@ def build_graph(
|
|
|
194
237
|
tool_call = state["messages"][-1].tool_calls[0]
|
|
195
238
|
searched_tools = await search_tools.ainvoke(input=tool_call["args"])
|
|
196
239
|
tool_msg = ToolMessage(
|
|
197
|
-
f"Available
|
|
240
|
+
f"Available tool_ids: {searched_tools}. Call load_tools to select the required tools only.", tool_call_id=tool_call["id"]
|
|
198
241
|
)
|
|
199
242
|
return Command(goto="call_model", update={"messages": [tool_msg]})
|
|
200
243
|
except Exception as e:
|
universal_mcp/agents/builder.py
CHANGED
|
@@ -15,7 +15,7 @@ from universal_mcp.agents.base import BaseAgent
|
|
|
15
15
|
from universal_mcp.agents.llm import load_chat_model
|
|
16
16
|
from universal_mcp.agents.shared.tool_node import build_tool_node_graph
|
|
17
17
|
from universal_mcp.agents.utils import messages_to_list
|
|
18
|
-
|
|
18
|
+
from collections import defaultdict
|
|
19
19
|
|
|
20
20
|
class Agent(BaseModel):
|
|
21
21
|
"""Agent that can be created by the builder."""
|
|
@@ -146,16 +146,37 @@ class BuilderAgent(BaseAgent):
|
|
|
146
146
|
]
|
|
147
147
|
}
|
|
148
148
|
tool_finder_graph = build_tool_node_graph(self.llm, self.registry)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
149
|
+
|
|
150
|
+
initial_state = {
|
|
151
|
+
"original_task": task,
|
|
152
|
+
"messages": [HumanMessage(content=task)],
|
|
153
|
+
"decomposition_attempts": 0,
|
|
154
|
+
}
|
|
155
|
+
final_state = await tool_finder_graph.ainvoke(initial_state)
|
|
156
|
+
execution_plan = final_state.get("execution_plan")
|
|
157
|
+
tool_config = {}
|
|
158
|
+
if execution_plan:
|
|
159
|
+
# Use defaultdict to easily group tools by app_id
|
|
160
|
+
apps_with_tools = defaultdict(list)
|
|
161
|
+
for step in execution_plan:
|
|
162
|
+
app_id = step.get("app_id")
|
|
163
|
+
tool_ids = step.get("tool_ids")
|
|
164
|
+
if app_id and tool_ids:
|
|
165
|
+
apps_with_tools[app_id].extend(tool_ids)
|
|
166
|
+
|
|
167
|
+
# Convert to a regular dict and remove any duplicate tool_ids for the same app
|
|
168
|
+
tool_config = {
|
|
169
|
+
app_id: list(set(tools)) for app_id, tools in apps_with_tools.items()
|
|
170
|
+
}
|
|
171
|
+
final_message = "I have selected the necessary tools for the agent. The agent is ready!"
|
|
172
|
+
else:
|
|
173
|
+
# Handle the case where the graph failed to create a plan
|
|
174
|
+
final_message = "I was unable to find the right tools for this task. Please try rephrasing your request."
|
|
175
|
+
|
|
153
176
|
yield {
|
|
154
177
|
"tool_config": tool_config,
|
|
155
178
|
"messages": [
|
|
156
|
-
AIMessage(
|
|
157
|
-
content="I have selected the necessary tools for the agent. The agent is ready!"
|
|
158
|
-
)
|
|
179
|
+
AIMessage(content=final_message)
|
|
159
180
|
],
|
|
160
181
|
}
|
|
161
182
|
|
|
@@ -1,255 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
import re
|
|
3
|
-
from collections.abc import Awaitable, Callable, Sequence
|
|
4
|
-
from typing import Any, TypeVar
|
|
1
|
+
from .agent import CodeActAgent
|
|
5
2
|
|
|
6
|
-
|
|
7
|
-
from langchain_core.tools import StructuredTool
|
|
8
|
-
from langchain_core.tools import tool as create_tool
|
|
9
|
-
from langgraph.graph import END, START, MessagesState, StateGraph
|
|
10
|
-
from langgraph.types import Command
|
|
11
|
-
|
|
12
|
-
from .utils import extract_and_combine_codeblocks
|
|
13
|
-
|
|
14
|
-
EvalFunction = Callable[[str, dict[str, Any]], tuple[str, dict[str, Any]]]
|
|
15
|
-
EvalCoroutine = Callable[[str, dict[str, Any]], Awaitable[tuple[str, dict[str, Any]]]]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class CodeActState(MessagesState):
|
|
19
|
-
"""State for CodeAct agent."""
|
|
20
|
-
|
|
21
|
-
script: str | None
|
|
22
|
-
"""The Python code script to be executed."""
|
|
23
|
-
context: dict[str, Any]
|
|
24
|
-
"""Dictionary containing the execution context with available tools and variables."""
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
StateSchema = TypeVar("StateSchema", bound=CodeActState)
|
|
28
|
-
StateSchemaType = type[StateSchema]
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def make_safe_function_name(name: str) -> str:
|
|
32
|
-
"""Convert a tool name to a valid Python function name."""
|
|
33
|
-
# Replace non-alphanumeric characters with underscores
|
|
34
|
-
safe_name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
|
|
35
|
-
# Ensure the name doesn't start with a digit
|
|
36
|
-
if safe_name and safe_name[0].isdigit():
|
|
37
|
-
safe_name = f"tool_{safe_name}"
|
|
38
|
-
# Handle empty name edge case
|
|
39
|
-
if not safe_name:
|
|
40
|
-
safe_name = "unnamed_tool"
|
|
41
|
-
return safe_name
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def create_default_prompt(tools: list[StructuredTool], base_prompt: str | None = None):
|
|
45
|
-
"""Create default prompt for the CodeAct agent."""
|
|
46
|
-
tools = [t if isinstance(t, StructuredTool) else create_tool(t) for t in tools]
|
|
47
|
-
prompt = f"{base_prompt}\n\n" if base_prompt else ""
|
|
48
|
-
prompt += """You will be given a task to perform. You should output either
|
|
49
|
-
- a Python code snippet that provides the solution to the task, or a step towards the solution. Any output you want to extract from the code should be printed to the console. Code should be output in a fenced code block.
|
|
50
|
-
- text to be shown directly to the user, if you want to ask for more information or provide the final answer.
|
|
51
|
-
|
|
52
|
-
In addition to the Python Standard Library, you can use the following functions:
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
for tool in tools:
|
|
56
|
-
# Use coroutine if it exists, otherwise use func
|
|
57
|
-
tool_callable = (
|
|
58
|
-
tool.coroutine
|
|
59
|
-
if hasattr(tool, "coroutine") and tool.coroutine is not None
|
|
60
|
-
else tool.func
|
|
61
|
-
)
|
|
62
|
-
# Create a safe function name
|
|
63
|
-
safe_name = make_safe_function_name(tool.name)
|
|
64
|
-
# Determine if it's an async function
|
|
65
|
-
is_async = inspect.iscoroutinefunction(tool_callable)
|
|
66
|
-
# Add appropriate function definition
|
|
67
|
-
prompt += f'''
|
|
68
|
-
{"async " if is_async else ""}def {safe_name}{str(inspect.signature(tool_callable))}:
|
|
69
|
-
"""{tool.description}"""
|
|
70
|
-
...
|
|
71
|
-
'''
|
|
72
|
-
|
|
73
|
-
prompt += """
|
|
74
|
-
|
|
75
|
-
Variables defined at the top level of previous code snippets can be referenced in your code.
|
|
76
|
-
|
|
77
|
-
Reminder: use Python code snippets to call tools"""
|
|
78
|
-
return prompt
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def create_codeact(
|
|
82
|
-
model: BaseChatModel,
|
|
83
|
-
tools: Sequence[StructuredTool | Callable],
|
|
84
|
-
eval_fn: EvalFunction | EvalCoroutine,
|
|
85
|
-
*,
|
|
86
|
-
prompt: str | None = None,
|
|
87
|
-
reflection_prompt: str | None = None,
|
|
88
|
-
reflection_model: BaseChatModel | None = None,
|
|
89
|
-
max_reflections: int = 3,
|
|
90
|
-
state_schema: StateSchemaType = CodeActState,
|
|
91
|
-
) -> StateGraph:
|
|
92
|
-
"""Create a CodeAct agent.
|
|
93
|
-
|
|
94
|
-
Args:
|
|
95
|
-
model: The language model to use for generating code
|
|
96
|
-
tools: List of tools available to the agent. Can be passed as python functions or StructuredTool instances.
|
|
97
|
-
eval_fn: Function or coroutine that executes code in a sandbox. Takes code string and locals dict,
|
|
98
|
-
returns a tuple of (stdout output, new variables dict)
|
|
99
|
-
prompt: Optional custom system prompt. If None, uses default prompt.
|
|
100
|
-
To customize default prompt you can use `create_default_prompt` helper:
|
|
101
|
-
`create_default_prompt(tools, "You are a helpful assistant.")`
|
|
102
|
-
reflection_prompt: Optional prompt for reflection. If provided, will be used to evaluate responses.
|
|
103
|
-
If the reflection output contains "NONE", the response is considered valid, otherwise the
|
|
104
|
-
reflection output is passed back to the model for regeneration.
|
|
105
|
-
reflection_model: Optional model to use for reflection. If None, uses the same model as for generation.
|
|
106
|
-
max_reflections: Maximum number of reflection iterations (default: 3).
|
|
107
|
-
state_schema: The state schema to use for the agent.
|
|
108
|
-
|
|
109
|
-
Returns:
|
|
110
|
-
A StateGraph implementing the CodeAct architecture
|
|
111
|
-
"""
|
|
112
|
-
tools = [t if isinstance(t, StructuredTool) else create_tool(t) for t in tools]
|
|
113
|
-
|
|
114
|
-
if prompt is None:
|
|
115
|
-
prompt = create_default_prompt(tools)
|
|
116
|
-
|
|
117
|
-
# If no reflection model is provided, use the main model
|
|
118
|
-
if reflection_model is None:
|
|
119
|
-
reflection_model = model
|
|
120
|
-
|
|
121
|
-
# Make tools available to the code sandbox - use safe names for keys
|
|
122
|
-
tools_context = {}
|
|
123
|
-
for tool in tools:
|
|
124
|
-
safe_name = make_safe_function_name(tool.name)
|
|
125
|
-
# Use coroutine if it exists, otherwise use func (same as in create_default_prompt)
|
|
126
|
-
tool_callable = (
|
|
127
|
-
tool.coroutine
|
|
128
|
-
if hasattr(tool, "coroutine") and tool.coroutine is not None
|
|
129
|
-
else tool.func
|
|
130
|
-
)
|
|
131
|
-
# Only use the safe name for consistency with the prompt
|
|
132
|
-
tools_context[safe_name] = tool_callable
|
|
133
|
-
|
|
134
|
-
def call_model(state: StateSchema) -> Command:
|
|
135
|
-
messages = [{"role": "system", "content": prompt}] + state["messages"]
|
|
136
|
-
|
|
137
|
-
# Run the model and potentially loop for reflection
|
|
138
|
-
response = model.invoke(messages)
|
|
139
|
-
|
|
140
|
-
# Extract and combine all code blocks
|
|
141
|
-
code = extract_and_combine_codeblocks(response.content)
|
|
142
|
-
|
|
143
|
-
# Loop for reflection if needed and if code is present
|
|
144
|
-
if reflection_prompt and code:
|
|
145
|
-
reflection_count = 0
|
|
146
|
-
while reflection_count < max_reflections:
|
|
147
|
-
# Format conversation history with XML-style tags
|
|
148
|
-
conversation_history = "\n".join(
|
|
149
|
-
[
|
|
150
|
-
f'<message role="{("user" if m.type == "human" else "assistant")}">\n{m.content}\n</message>'
|
|
151
|
-
for m in state["messages"]
|
|
152
|
-
]
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
# Add the current response
|
|
156
|
-
conversation_history += (
|
|
157
|
-
f'\n<message role="assistant">\n{response.content}\n</message>'
|
|
158
|
-
)
|
|
159
|
-
|
|
160
|
-
# Create the reflection prompt with the tagged conversation history
|
|
161
|
-
formatted_prompt = f"""
|
|
162
|
-
Review the assistant's latest code for as per the quality rules:
|
|
163
|
-
|
|
164
|
-
<conversation_history>
|
|
165
|
-
{conversation_history}
|
|
166
|
-
</conversation_history>
|
|
167
|
-
|
|
168
|
-
If you find ANY of these issues, describe the problem briefly and clearly.
|
|
169
|
-
If NO issues are found, respond with EXACTLY: "NONE"
|
|
170
|
-
"""
|
|
171
|
-
|
|
172
|
-
# Create messages for reflection with correct ordering
|
|
173
|
-
reflection_messages = [
|
|
174
|
-
{"role": "system", "content": reflection_prompt},
|
|
175
|
-
# Include the formatted reflection prompt as the final user message
|
|
176
|
-
{"role": "user", "content": formatted_prompt},
|
|
177
|
-
]
|
|
178
|
-
reflection_result = reflection_model.invoke(reflection_messages)
|
|
179
|
-
|
|
180
|
-
# Check if reflection passed
|
|
181
|
-
if "NONE" in reflection_result.content:
|
|
182
|
-
# Reflection passed, exit loop
|
|
183
|
-
break
|
|
184
|
-
|
|
185
|
-
# Reflection didn't pass, regenerate response
|
|
186
|
-
reflection_messages = [
|
|
187
|
-
{"role": "system", "content": prompt},
|
|
188
|
-
*state["messages"],
|
|
189
|
-
{"role": "assistant", "content": response.content},
|
|
190
|
-
{
|
|
191
|
-
"role": "user",
|
|
192
|
-
"content": f"""
|
|
193
|
-
I need you to completely regenerate your previous response based on this feedback:
|
|
194
|
-
|
|
195
|
-
'''
|
|
196
|
-
{reflection_result.content}
|
|
197
|
-
'''
|
|
198
|
-
|
|
199
|
-
DO NOT reference the feedback directly. Instead, provide a completely new response that addresses the issues.
|
|
200
|
-
""",
|
|
201
|
-
},
|
|
202
|
-
]
|
|
203
|
-
response = model.invoke(reflection_messages)
|
|
204
|
-
|
|
205
|
-
# Extract code from the new response
|
|
206
|
-
code = extract_and_combine_codeblocks(response.content)
|
|
207
|
-
|
|
208
|
-
# If no code in the new response, exit the reflection loop
|
|
209
|
-
if not code:
|
|
210
|
-
break
|
|
211
|
-
|
|
212
|
-
# Increment reflection count
|
|
213
|
-
reflection_count += 1
|
|
214
|
-
|
|
215
|
-
# Return appropriate command with only the latest response
|
|
216
|
-
if code:
|
|
217
|
-
return Command(
|
|
218
|
-
goto="sandbox", update={"messages": [response], "script": code}
|
|
219
|
-
)
|
|
220
|
-
else:
|
|
221
|
-
# no code block, end the loop and respond to the user
|
|
222
|
-
return Command(update={"messages": [response], "script": None})
|
|
223
|
-
|
|
224
|
-
# If eval_fn is a async, we define async node function.
|
|
225
|
-
if inspect.iscoroutinefunction(eval_fn):
|
|
226
|
-
|
|
227
|
-
async def sandbox(state: StateSchema):
|
|
228
|
-
existing_context = state.get("context", {})
|
|
229
|
-
context = {**existing_context, **tools_context}
|
|
230
|
-
# Execute the script in the sandbox
|
|
231
|
-
output, new_vars = await eval_fn(state["script"], context)
|
|
232
|
-
new_context = {**existing_context, **new_vars}
|
|
233
|
-
return {
|
|
234
|
-
"messages": [{"role": "user", "content": output}],
|
|
235
|
-
"context": new_context,
|
|
236
|
-
}
|
|
237
|
-
else:
|
|
238
|
-
|
|
239
|
-
def sandbox(state: StateSchema):
|
|
240
|
-
existing_context = state.get("context", {})
|
|
241
|
-
context = {**existing_context, **tools_context}
|
|
242
|
-
# Execute the script in the sandbox
|
|
243
|
-
output, new_vars = eval_fn(state["script"], context)
|
|
244
|
-
new_context = {**existing_context, **new_vars}
|
|
245
|
-
return {
|
|
246
|
-
"messages": [{"role": "user", "content": output}],
|
|
247
|
-
"context": new_context,
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
agent = StateGraph(state_schema)
|
|
251
|
-
agent.add_node(call_model, destinations=(END, "sandbox"))
|
|
252
|
-
agent.add_node(sandbox)
|
|
253
|
-
agent.add_edge(START, "call_model")
|
|
254
|
-
agent.add_edge("sandbox", "call_model")
|
|
255
|
-
return agent
|
|
3
|
+
__all__ = ["CodeActAgent"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from universal_mcp.agentr.registry import AgentrRegistry
|
|
4
|
+
from universal_mcp.agents.codeact.agent import CodeActAgent
|
|
5
|
+
from universal_mcp.agents.utils import messages_to_list
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def main():
|
|
9
|
+
agent = CodeActAgent(
|
|
10
|
+
"CodeAct Agent",
|
|
11
|
+
instructions="Be very concise in your answers.",
|
|
12
|
+
model="azure/gpt-4o",
|
|
13
|
+
tools={"google_mail": ["send_email"]},
|
|
14
|
+
registry=AgentrRegistry(),
|
|
15
|
+
)
|
|
16
|
+
result = await agent.invoke(
|
|
17
|
+
"Send an email to manoj@agentr.dev from my Gmail account with a subject 'testing codeact agent' and body 'This is a test of the codeact agent.'"
|
|
18
|
+
)
|
|
19
|
+
from rich import print
|
|
20
|
+
|
|
21
|
+
print(messages_to_list(result["messages"]))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Callable, Union
|
|
3
|
+
|
|
4
|
+
from langchain_core.language_models import BaseChatModel
|
|
5
|
+
from langchain_core.tools import StructuredTool, tool as create_tool
|
|
6
|
+
from langgraph.checkpoint.base import BaseCheckpointSaver
|
|
7
|
+
from langgraph.graph import END, StateGraph
|
|
8
|
+
from loguru import logger
|
|
9
|
+
from universal_mcp.tools.registry import ToolRegistry
|
|
10
|
+
from universal_mcp.types import ToolConfig, ToolFormat
|
|
11
|
+
|
|
12
|
+
from universal_mcp.agents.base import BaseAgent
|
|
13
|
+
from universal_mcp.agents.llm import load_chat_model
|
|
14
|
+
from universal_mcp.agents.codeact.prompts import (
|
|
15
|
+
create_default_prompt,
|
|
16
|
+
make_safe_function_name,
|
|
17
|
+
REFLECTION_PROMPT,
|
|
18
|
+
RETRY_PROMPT,
|
|
19
|
+
)
|
|
20
|
+
from universal_mcp.agents.codeact.sandbox import eval_unsafe
|
|
21
|
+
from universal_mcp.agents.codeact.state import CodeActState
|
|
22
|
+
from universal_mcp.agents.codeact.utils import extract_and_combine_codeblocks
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CodeActAgent(BaseAgent):
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
name: str,
|
|
29
|
+
instructions: str,
|
|
30
|
+
model: str,
|
|
31
|
+
memory: BaseCheckpointSaver | None = None,
|
|
32
|
+
tools: ToolConfig | None = None,
|
|
33
|
+
registry: ToolRegistry | None = None,
|
|
34
|
+
*,
|
|
35
|
+
reflection_prompt: str = None,
|
|
36
|
+
reflection_model: BaseChatModel = None,
|
|
37
|
+
max_reflections: int = 3,
|
|
38
|
+
**kwargs,
|
|
39
|
+
):
|
|
40
|
+
super().__init__(name, instructions, model, memory, **kwargs)
|
|
41
|
+
self.model_instance = load_chat_model(model)
|
|
42
|
+
self.tools_config = tools or {}
|
|
43
|
+
self.registry = registry
|
|
44
|
+
self.eval_fn = eval_unsafe
|
|
45
|
+
self.reflection_prompt = reflection_prompt
|
|
46
|
+
self.reflection_model = reflection_model or self.model_instance
|
|
47
|
+
self.max_reflections = max_reflections if reflection_prompt else 0
|
|
48
|
+
self.tools_context = {}
|
|
49
|
+
self.processed_tools: list[Union[StructuredTool, Callable]] = []
|
|
50
|
+
|
|
51
|
+
async def _build_graph(self):
|
|
52
|
+
if self.tools_config:
|
|
53
|
+
if not self.registry:
|
|
54
|
+
raise ValueError("Tools are configured but no registry is provided")
|
|
55
|
+
# Langchain tools are fine
|
|
56
|
+
exported_tools = await self.registry.export_tools(
|
|
57
|
+
self.tools_config, ToolFormat.LANGCHAIN
|
|
58
|
+
)
|
|
59
|
+
self.processed_tools = [
|
|
60
|
+
t if isinstance(t, StructuredTool) else create_tool(t)
|
|
61
|
+
for t in exported_tools
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
self.instructions = create_default_prompt(
|
|
65
|
+
self.processed_tools, self.instructions
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
for tool in self.processed_tools:
|
|
69
|
+
safe_name = make_safe_function_name(tool.name)
|
|
70
|
+
tool_callable = (
|
|
71
|
+
tool.coroutine
|
|
72
|
+
if hasattr(tool, "coroutine") and tool.coroutine is not None
|
|
73
|
+
else tool.func
|
|
74
|
+
)
|
|
75
|
+
self.tools_context[safe_name] = tool_callable
|
|
76
|
+
|
|
77
|
+
agent = StateGraph(CodeActState)
|
|
78
|
+
agent.add_node("call_model", lambda state, config: self.call_model(state, config))
|
|
79
|
+
agent.add_node("sandbox", self.sandbox)
|
|
80
|
+
|
|
81
|
+
agent.set_entry_point("call_model")
|
|
82
|
+
agent.add_conditional_edges(
|
|
83
|
+
"call_model",
|
|
84
|
+
self.should_run_sandbox,
|
|
85
|
+
{
|
|
86
|
+
"sandbox": "sandbox",
|
|
87
|
+
END: END,
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
agent.add_edge("sandbox", "call_model")
|
|
91
|
+
return agent.compile(checkpointer=self.memory)
|
|
92
|
+
|
|
93
|
+
def should_run_sandbox(self, state: CodeActState) -> str:
|
|
94
|
+
if state.get("script"):
|
|
95
|
+
return "sandbox"
|
|
96
|
+
return END
|
|
97
|
+
|
|
98
|
+
def call_model(self, state: CodeActState, config: dict) -> dict:
|
|
99
|
+
context = config.get("context", {})
|
|
100
|
+
instructions = context.get("system_prompt", self.instructions)
|
|
101
|
+
model = self.model_instance
|
|
102
|
+
reflection_model = self.reflection_model
|
|
103
|
+
|
|
104
|
+
messages = [{"role": "system", "content": instructions}] + state["messages"]
|
|
105
|
+
|
|
106
|
+
response = model.invoke(messages)
|
|
107
|
+
|
|
108
|
+
code = extract_and_combine_codeblocks(response.content)
|
|
109
|
+
|
|
110
|
+
if self.max_reflections > 0 and code:
|
|
111
|
+
reflection_count = 0
|
|
112
|
+
while reflection_count < self.max_reflections:
|
|
113
|
+
conversation_history = "\n".join(
|
|
114
|
+
[
|
|
115
|
+
f'<message role="{("user" if m.type == "human" else "assistant")}">\n{m.content}\n</message>'
|
|
116
|
+
for m in state["messages"]
|
|
117
|
+
]
|
|
118
|
+
)
|
|
119
|
+
conversation_history += (
|
|
120
|
+
f'\n<message role="assistant">\n{response.content}\n</message>'
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
formatted_prompt = REFLECTION_PROMPT.format(
|
|
124
|
+
conversation_history=conversation_history
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
reflection_messages = [
|
|
128
|
+
{"role": "system", "content": self.reflection_prompt},
|
|
129
|
+
{"role": "user", "content": formatted_prompt},
|
|
130
|
+
]
|
|
131
|
+
reflection_result = reflection_model.invoke(reflection_messages)
|
|
132
|
+
|
|
133
|
+
if "NONE" in reflection_result.content:
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
retry_prompt = RETRY_PROMPT.format(
|
|
137
|
+
reflection_result=reflection_result.content
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
regeneration_messages = [
|
|
141
|
+
{"role": "system", "content": instructions},
|
|
142
|
+
*state["messages"],
|
|
143
|
+
{"role": "assistant", "content": response.content},
|
|
144
|
+
{"role": "user", "content": retry_prompt},
|
|
145
|
+
]
|
|
146
|
+
response = model.invoke(regeneration_messages)
|
|
147
|
+
|
|
148
|
+
code = extract_and_combine_codeblocks(response.content)
|
|
149
|
+
|
|
150
|
+
if not code:
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
reflection_count += 1
|
|
154
|
+
|
|
155
|
+
if code:
|
|
156
|
+
return {"messages": [response], "script": code}
|
|
157
|
+
else:
|
|
158
|
+
return {"messages": [response], "script": None}
|
|
159
|
+
|
|
160
|
+
async def sandbox(self, state: CodeActState) -> dict:
|
|
161
|
+
existing_context = state.get("context", {})
|
|
162
|
+
context = {**existing_context, **self.tools_context}
|
|
163
|
+
if inspect.iscoroutinefunction(self.eval_fn):
|
|
164
|
+
output, new_vars = await self.eval_fn(state["script"], context)
|
|
165
|
+
else:
|
|
166
|
+
output, new_vars = self.eval_fn(state["script"], context)
|
|
167
|
+
new_context = {**existing_context, **new_vars}
|
|
168
|
+
return {
|
|
169
|
+
"messages": [{"role": "user", "content": output}],
|
|
170
|
+
"context": new_context,
|
|
171
|
+
}
|