universal-mcp-agents 0.1.14__py3-none-any.whl → 0.1.15__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.

Files changed (47) hide show
  1. universal_mcp/agents/__init__.py +1 -1
  2. universal_mcp/agents/base.py +2 -1
  3. universal_mcp/agents/bigtool/__main__.py +4 -3
  4. universal_mcp/agents/bigtool/agent.py +1 -0
  5. universal_mcp/agents/bigtool/graph.py +7 -4
  6. universal_mcp/agents/bigtool/tools.py +4 -5
  7. universal_mcp/agents/builder/__main__.py +49 -23
  8. universal_mcp/agents/builder/builder.py +101 -102
  9. universal_mcp/agents/builder/helper.py +4 -6
  10. universal_mcp/agents/builder/prompts.py +92 -39
  11. universal_mcp/agents/builder/state.py +1 -1
  12. universal_mcp/agents/codeact0/__init__.py +2 -1
  13. universal_mcp/agents/codeact0/agent.py +12 -5
  14. universal_mcp/agents/codeact0/langgraph_agent.py +11 -14
  15. universal_mcp/agents/codeact0/llm_tool.py +1 -2
  16. universal_mcp/agents/codeact0/playbook_agent.py +353 -0
  17. universal_mcp/agents/codeact0/prompts.py +113 -39
  18. universal_mcp/agents/codeact0/sandbox.py +43 -32
  19. universal_mcp/agents/codeact0/state.py +27 -3
  20. universal_mcp/agents/codeact0/tools.py +180 -0
  21. universal_mcp/agents/codeact0/utils.py +53 -18
  22. universal_mcp/agents/shared/__main__.py +3 -2
  23. universal_mcp/agents/shared/prompts.py +1 -1
  24. universal_mcp/agents/shared/tool_node.py +17 -12
  25. universal_mcp/agents/utils.py +18 -12
  26. {universal_mcp_agents-0.1.14.dist-info → universal_mcp_agents-0.1.15.dist-info}/METADATA +3 -3
  27. universal_mcp_agents-0.1.15.dist-info/RECORD +50 -0
  28. universal_mcp/agents/codeact0/usecases/1-unsubscribe.yaml +0 -4
  29. universal_mcp/agents/codeact0/usecases/10-reddit2.yaml +0 -10
  30. universal_mcp/agents/codeact0/usecases/11-github.yaml +0 -14
  31. universal_mcp/agents/codeact0/usecases/2-reddit.yaml +0 -27
  32. universal_mcp/agents/codeact0/usecases/2.1-instructions.md +0 -81
  33. universal_mcp/agents/codeact0/usecases/2.2-instructions.md +0 -71
  34. universal_mcp/agents/codeact0/usecases/3-earnings.yaml +0 -4
  35. universal_mcp/agents/codeact0/usecases/4-maps.yaml +0 -41
  36. universal_mcp/agents/codeact0/usecases/5-gmailreply.yaml +0 -8
  37. universal_mcp/agents/codeact0/usecases/6-contract.yaml +0 -6
  38. universal_mcp/agents/codeact0/usecases/7-overnight.yaml +0 -14
  39. universal_mcp/agents/codeact0/usecases/8-sheets_chart.yaml +0 -25
  40. universal_mcp/agents/codeact0/usecases/9-learning.yaml +0 -9
  41. universal_mcp/agents/planner/__init__.py +0 -51
  42. universal_mcp/agents/planner/__main__.py +0 -28
  43. universal_mcp/agents/planner/graph.py +0 -85
  44. universal_mcp/agents/planner/prompts.py +0 -14
  45. universal_mcp/agents/planner/state.py +0 -11
  46. universal_mcp_agents-0.1.14.dist-info/RECORD +0 -66
  47. {universal_mcp_agents-0.1.14.dist-info → universal_mcp_agents-0.1.15.dist-info}/WHEEL +0 -0
@@ -4,7 +4,7 @@ from universal_mcp.agents.base import BaseAgent
4
4
  from universal_mcp.agents.bigtool import BigToolAgent
5
5
  from universal_mcp.agents.builder.builder import BuilderAgent
6
6
  from universal_mcp.agents.codeact import CodeActAgent as CodeActScript
7
- from universal_mcp.agents.codeact0 import CodeActAgent as CodeActRepl
7
+ from universal_mcp.agents.codeact0 import CodeActPlaybookAgent as CodeActRepl
8
8
  from universal_mcp.agents.react import ReactAgent
9
9
  from universal_mcp.agents.simple import SimpleAgent
10
10
 
@@ -85,6 +85,7 @@ class BaseAgent:
85
85
  event = cast(AIMessageChunk, event)
86
86
  event.usage_metadata = aggregate.usage_metadata
87
87
  logger.debug(f"Usage metadata: {event.usage_metadata}")
88
+ event.content = "" # Clear the message since it would have already been streamed above
88
89
  yield event
89
90
 
90
91
  async def stream_interactive(self, thread_id: str, user_input: str):
@@ -116,7 +117,7 @@ class BaseAgent:
116
117
  "configurable": {"thread_id": thread_id},
117
118
  "metadata": run_metadata,
118
119
  "run_id": thread_id,
119
- "run_name" : self.name
120
+ "run_name": self.name,
120
121
  }
121
122
 
122
123
  result = await self._graph.ainvoke(
@@ -2,15 +2,16 @@ import asyncio
2
2
 
3
3
  from loguru import logger
4
4
  from universal_mcp.agentr.registry import AgentrRegistry
5
- from universal_mcp.agents.bigtoolcache import BigToolAgentCache
5
+
6
+ from universal_mcp.agents.bigtool import BigToolAgent
6
7
 
7
8
 
8
9
  async def main():
9
- agent = BigToolAgentCache(
10
+ agent = BigToolAgent(
10
11
  registry=AgentrRegistry(),
11
12
  )
12
13
  async for event in agent.stream(
13
- user_input="Send an email to manoj@agentr.dev",
14
+ user_input="Load a supabase tool",
14
15
  thread_id="test123",
15
16
  ):
16
17
  logger.info(event.content)
@@ -1,4 +1,5 @@
1
1
  from universal_mcp.agentr.registry import AgentrRegistry
2
+
2
3
  from universal_mcp.agents.bigtool import BigToolAgent
3
4
 
4
5
 
@@ -11,9 +11,10 @@ from langgraph.types import Command, RetryPolicy
11
11
  from universal_mcp.tools.registry import ToolRegistry
12
12
  from universal_mcp.types import ToolFormat
13
13
 
14
+ from universal_mcp.agents.utils import filter_retry_on
15
+
14
16
  from .state import State
15
17
  from .tools import get_valid_tools
16
- from universal_mcp.agents.utils import filter_retry_on
17
18
 
18
19
  load_dotenv()
19
20
 
@@ -96,13 +97,16 @@ def build_graph(
96
97
  for tool_call in tool_calls:
97
98
  try:
98
99
  if tool_call["name"] == "load_tools": # Handle load_tools separately
99
- valid_tools, unconnected_links = await get_valid_tools(tool_ids=tool_call["args"]["tool_ids"], registry=registry)
100
+ valid_tools, unconnected_links = await get_valid_tools(
101
+ tool_ids=tool_call["args"]["tool_ids"], registry=registry
102
+ )
100
103
  new_tool_ids.extend(valid_tools)
101
104
  # Create tool message response
102
105
  tool_result = f"Successfully loaded {len(valid_tools)} tools: {valid_tools}"
103
106
  if unconnected_links:
104
107
  ask_user = True
105
- ai_msg = f"Please login to the following app(s) using the following links and let me know in order to proceed:\n {'\n'.join(unconnected_links)} "
108
+ links = "\n".join(unconnected_links)
109
+ ai_msg = f"Please login to the following app(s) using the following links and let me know in order to proceed:\n {links} "
106
110
 
107
111
  elif tool_call["name"] == "search_tools":
108
112
  tool_result = await meta_tools["search_tools"].ainvoke(tool_call["args"])
@@ -115,7 +119,6 @@ def build_graph(
115
119
  except Exception as e:
116
120
  tool_result = f"Error during {tool_call}: {e}"
117
121
 
118
-
119
122
  tool_message = ToolMessage(
120
123
  content=json.dumps(tool_result),
121
124
  name=tool_call["name"],
@@ -34,9 +34,8 @@ def create_meta_tools(tool_registry: ToolRegistry) -> dict[str, Any]:
34
34
  for tools_list in query_results:
35
35
  for tool in tools_list:
36
36
  app = tool["id"].split("__")[0]
37
- if len(app_tools[app]) < 5:
38
- cleaned_desc = tool['description'].split("Context:")[0].strip()
39
- app_tools[app].append(f"{tool['id']}: {cleaned_desc}")
37
+ cleaned_desc = tool["description"].split("Context:")[0].strip()
38
+ app_tools[app].append(f"{tool['id']}: {cleaned_desc}")
40
39
 
41
40
  # Build result string efficiently
42
41
  result_parts = []
@@ -133,7 +132,7 @@ async def get_valid_tools(tool_ids: list[str], registry: ToolRegistry) -> tuple[
133
132
  app_tool_list[app] = tools
134
133
 
135
134
  # Validate tool_ids
136
- for app, tool_entries in app_to_tools.items():
135
+ for app, tool_entries in app_to_tools.items():
137
136
  available = app_tool_list.get(app)
138
137
  if available is None:
139
138
  incorrect.extend(tool_id for tool_id, _ in tool_entries)
@@ -142,7 +141,7 @@ async def get_valid_tools(tool_ids: list[str], registry: ToolRegistry) -> tuple[
142
141
  unconnected.add(app)
143
142
  text = registry.client.get_authorization_url(app)
144
143
  start = text.find(":") + 1
145
- end = text.find(".", start)
144
+ end = text.find(". R", start)
146
145
  url = text[start:end].strip()
147
146
  markdown_link = f"[{app}]({url})"
148
147
  unconnected_links.append(markdown_link)
@@ -19,7 +19,7 @@ async def run_interactive_build():
19
19
  agent = BuilderAgent(
20
20
  name="Builder Agent",
21
21
  instructions="You are a builder agent that creates other agents.",
22
- model="azure/gpt-4.1",
22
+ model="anthropic/claude-4-sonnet-20250514",
23
23
  registry=registry,
24
24
  memory=memory,
25
25
  )
@@ -40,9 +40,15 @@ async def run_interactive_build():
40
40
  logger.info(f"\n--- Conversation Turn {i + 1} ---")
41
41
  logger.info(f"User Request: '{user_input}'")
42
42
 
43
- # The first turn is a new build (agent=None).
44
- # Subsequent turns are modifications, passing the previously generated agent.
45
- result = await agent.invoke(user_input=user_input, thread_id=thread_id, agent=latest_agent, tools=latest_tools)
43
+ # Construct the payload based on the current state of the conversation
44
+ payload = {"userInput": user_input}
45
+ if latest_agent:
46
+ # On subsequent turns, pass the existing agent and tools for modification
47
+ payload["agent"] = latest_agent.model_dump() # Convert Pydantic model to dict
48
+ payload["tools"] = latest_tools
49
+
50
+ # The invoke method now takes a single payload dictionary
51
+ result = await agent.invoke(thread_id=thread_id, user_input=payload)
46
52
 
47
53
  # Update the latest state for the next turn
48
54
  latest_agent = result.get("generated_agent")
@@ -67,7 +73,7 @@ async def run_interactive_build():
67
73
 
68
74
  async def run_conversation_build():
69
75
  """Simulates a one-shot agent build from a conversation history payload."""
70
- logger.info("\n\n--- SCENARIO 3: AGENT BUILD FROM CONVERSATION HISTORY ---")
76
+ logger.info("\n\n--- SCENARIO 2: AGENT BUILD FROM CONVERSATION HISTORY ---")
71
77
 
72
78
  registry = AgentrRegistry()
73
79
  agent = BuilderAgent(
@@ -87,7 +93,11 @@ async def run_conversation_build():
87
93
  "content": "\"Tools from zenquotes (status: connected by user):\\n - zenquotes__get_random_quote: Fetches a random inspirational quote from the Zen Quotes API via an HTTP request. It parses the JSON response to extract the quote and author, returning them as a single formatted string ('quote - author'). This function is the primary tool provided by the ZenquotesApp.\\n - zenquotes__get_random_quote: Fetches a random inspirational quote from the Zen Quotes API via an HTTP request. It parses the JSON response to extract the quote and author, returning them as a single formatted string ('quote - author'). This function is the primary tool provided by the ZenquotesApp.\\n\\nTools from perplexity (status: NOT connected by user):\\n - perplexity__answer_with_search: Queries the Perplexity Chat Completions API for a web-search-grounded answer. It sends the user's prompt and model parameters to the `/chat/completions` endpoint, then parses the response to return the synthesized content and a list of supporting source citations, ideal for real-time information retrieval.\\n - perplexity__answer_with_search: Queries the Perplexity Chat Completions API for a web-search-grounded answer. It sends the user's prompt and model parameters to the `/chat/completions` endpoint, then parses the response to return the synthesized content and a list of supporting source citations, ideal for real-time information retrieval.\\n\\nCall load_tools to select the required tools only.\"",
88
94
  },
89
95
  {"type": "ai", "content": ""},
90
- {"type": "tool", "content": "\"Successfully loaded 1 tools: ['zenquotes__get_random_quote']\"", "name": "zenquotes__get_random_quote"},
96
+ {
97
+ "type": "tool",
98
+ "content": "\"Successfully loaded 1 tools: ['zenquotes__get_random_quote']\"",
99
+ "name": "zenquotes__get_random_quote",
100
+ },
91
101
  {"type": "ai", "content": ""},
92
102
  {
93
103
  "type": "tool",
@@ -104,7 +114,11 @@ async def run_conversation_build():
104
114
  "content": '"Tools from google_mail (status: connected by user):\\n - google_mail__send_email: Composes and immediately sends an email message via the Gmail API. It can function as a reply within an existing conversation if a `thread_id` is provided. This action is distinct from `send_draft`, which sends a previously saved draft message, or `create_draft`, which only saves an email.\\n - google_mail__send_draft: Sends a pre-existing Gmail draft identified by its unique ID. It posts to the `/drafts/send` endpoint, converting a saved draft into a sent message. This function acts on drafts from `create_draft` and differs from `send_email`, which composes and sends an email in one step.\\n - google_mail__create_draft: Saves a new email draft in Gmail with a specified recipient, subject, and body. An optional thread ID can create the draft as a reply within an existing conversation, distinguishing it from `send_email`, which sends immediately.\\n - google_mail__get_draft: Retrieves a specific Gmail draft by its unique ID. This function allows specifying the output format (e.g., full, raw) to control the response detail. Unlike `list_drafts`, it fetches a single, known draft rather than a collection of multiple drafts.\\n\\nCall load_tools to select the required tools only."',
105
115
  },
106
116
  {"type": "ai", "content": ""},
107
- {"type": "tool", "content": "\"Successfully loaded 1 tools: ['google_mail__send_email']\"", "name": "google_mail__send_email"},
117
+ {
118
+ "type": "tool",
119
+ "content": "\"Successfully loaded 1 tools: ['google_mail__send_email']\"",
120
+ "name": "google_mail__send_email",
121
+ },
108
122
  {"type": "ai", "content": ""},
109
123
  {"type": "tool", "content": '{"id": "199765690b278b56", "threadId": "199765690b278b56", "labelIds": ["SENT"]}'},
110
124
  {"type": "ai", "content": "The quote has been sent to **ankit@agentr.dev** successfully. ✅"},
@@ -113,11 +127,14 @@ async def run_conversation_build():
113
127
  logger.info(f"Payload Conversation History Length: {len(sample_conversation_history)} messages")
114
128
 
115
129
  thread_id = str(uuid4())
116
- result = await agent.invoke(
117
- thread_id=thread_id,
118
- user_input="Generate an agent from the provided conversation.", # This input is for logging/tracing
119
- messages=sample_conversation_history,
120
- )
130
+
131
+ # The payload contains the messages and a high-level instruction for the builder
132
+ payload = {
133
+ "userInput": "",
134
+ "messages": sample_conversation_history,
135
+ }
136
+
137
+ result = await agent.invoke(thread_id=thread_id, user_input=payload)
121
138
 
122
139
  generated_agent = result.get("generated_agent")
123
140
  tool_config = result.get("tool_config")
@@ -146,7 +163,7 @@ async def run_modification_with_manual_tool():
146
163
  configuration, and then uses the builder to modify the agent for a
147
164
  different reason, expecting the manually added tool to be preserved.
148
165
  """
149
- logger.info("\n\n--- SCENARIO 2: MODIFY AGENT WITH MANUAL TOOL ADDITION ---")
166
+ logger.info("\n\n--- SCENARIO 3: MODIFY AGENT WITH MANUAL TOOL ADDITION ---")
150
167
 
151
168
  registry = AgentrRegistry()
152
169
  memory = MemorySaver()
@@ -163,38 +180,47 @@ async def run_modification_with_manual_tool():
163
180
  initial_request = "Send an email to manoj@agentr.dev with the subject 'Hello' using my Gmail account."
164
181
  logger.info(f"User Request: '{initial_request}'")
165
182
 
166
- initial_result = await agent.invoke(user_input=initial_request, thread_id=thread_id)
183
+ # Initial agent creation
184
+ initial_payload = {"userInput": initial_request}
185
+ initial_result = await agent.invoke(thread_id=thread_id, user_input=initial_payload)
186
+
167
187
  initial_agent = initial_result.get("generated_agent")
168
188
  initial_tools = initial_result.get("tool_config")
169
189
 
190
+ logger.info("--- Initial Tools ---")
170
191
  tools_str = "\n".join(f"- {app}: {', '.join(tool_ids)}" for app, tool_ids in initial_tools.items())
171
192
  logger.info(tools_str)
172
193
 
194
+ # Manually add a new tool to the configuration
173
195
  manually_modified_tools = initial_tools.copy()
174
196
  manually_modified_tools["reddit"] = ["create_post"]
197
+ logger.info("--- Manually Modified Tools ---")
175
198
  tools_str = "\n".join(f"- {app}: {', '.join(tool_ids)}" for app, tool_ids in manually_modified_tools.items())
176
199
  logger.info(tools_str)
177
200
 
178
201
  modification_request = "Also add the above email to my draft"
179
202
  logger.info(f"User Request: '{modification_request}'")
180
203
 
181
- final_result = await agent.invoke(
182
- user_input=modification_request,
183
- thread_id=thread_id,
184
- agent=initial_agent,
185
- tools=manually_modified_tools,
186
- )
204
+ # Prepare payload for modification, passing the existing agent and the manually updated tools
205
+ modification_payload = {
206
+ "userInput": modification_request,
207
+ "agent": initial_agent.model_dump(), # Convert Pydantic model to dict
208
+ "tools": manually_modified_tools,
209
+ }
210
+
211
+ final_result = await agent.invoke(thread_id=thread_id, user_input=modification_payload)
187
212
 
188
213
  final_tools = final_result.get("tool_config")
214
+ logger.info("--- Final Tools After Modification (should include manual addition) ---")
189
215
  tools_str = "\n".join(f"- {app}: {', '.join(tool_ids)}" for app, tool_ids in final_tools.items())
190
216
  logger.info(tools_str)
191
217
 
192
218
 
193
219
  async def main():
194
- await run_interactive_build()
220
+ # await run_interactive_build()
195
221
  await run_conversation_build()
196
- await run_modification_with_manual_tool()
222
+ # await run_modification_with_manual_tool()
197
223
 
198
224
 
199
225
  if __name__ == "__main__":
200
- asyncio.run(main())
226
+ asyncio.run(main())
@@ -1,26 +1,24 @@
1
+ import asyncio
1
2
  import json
2
3
 
3
4
  from langchain_core.messages import HumanMessage
4
5
  from langgraph.checkpoint.base import BaseCheckpointSaver
5
6
  from langgraph.graph import END, START, StateGraph
7
+ from langgraph.types import Command
6
8
  from loguru import logger
7
9
  from universal_mcp.tools.registry import ToolRegistry
8
10
  from universal_mcp.types import ToolConfig
9
- from langgraph.types import Command
10
11
 
11
12
  from universal_mcp.agents.base import BaseAgent
12
- from universal_mcp.agents.builder.prompts import (
13
- NEW_AGENT_PROMPT,
14
- MODIFY_AGENT_PROMPT,
15
- )
16
- from universal_mcp.agents.builder.state import Agent, BuilderState
17
- from universal_mcp.agents.llm import load_chat_model
18
- from universal_mcp.agents.shared.tool_node import build_tool_node_graph
19
13
  from universal_mcp.agents.builder.helper import (
20
- _extract_tools_from_history,
21
14
  _clean_conversation_history,
15
+ _extract_tools_from_history,
22
16
  _merge_tool_configs,
23
17
  )
18
+ from universal_mcp.agents.builder.prompts import _build_prompt
19
+ from universal_mcp.agents.builder.state import Agent, BuilderState
20
+ from universal_mcp.agents.llm import load_chat_model
21
+ from universal_mcp.agents.shared.tool_node import build_tool_node_graph
24
22
 
25
23
 
26
24
  class BuilderAgent(BaseAgent):
@@ -69,16 +67,15 @@ class BuilderAgent(BaseAgent):
69
67
  if messages:
70
68
  initial_state["messages"] = [HumanMessage(content=json.dumps(messages))]
71
69
  elif not userInput and not agent:
72
- raise ValueError("Either 'user_input' or 'messages' must be provided for a new agent.")
73
-
70
+ raise ValueError("Either 'user_input' or 'messages' must be provided for a new agent.")
74
71
 
75
- run_metadata = { "agent_name": self.name, "is_background_run": False }
72
+ run_metadata = {"agent_name": self.name, "is_background_run": False}
76
73
 
77
74
  config = {
78
75
  "configurable": {"thread_id": thread_id},
79
76
  "metadata": run_metadata,
80
77
  "run_id": thread_id,
81
- "run_name" : self.name
78
+ "run_name": self.name,
82
79
  }
83
80
 
84
81
  final_state = await graph.ainvoke(initial_state, config=config)
@@ -88,70 +85,109 @@ class BuilderAgent(BaseAgent):
88
85
  """
89
86
  Determines the entry point of the graph based on the initial state.
90
87
  """
91
- if state.get("generated_agent"):
88
+ has_agent = state.get("generated_agent") is not None
89
+ has_messages = bool(state.get("messages"))
90
+ has_user_task = bool(state.get("user_task"))
91
+
92
+ if has_agent:
92
93
  logger.info("Routing to: modify_agent.")
93
94
  return "modify_agent"
95
+ elif has_messages:
96
+ logger.info("Routing to: create_agent_from_history.")
97
+ return "create_agent_from_history"
98
+ elif has_user_task:
99
+ logger.info("Routing to: create_agent_from_input.")
100
+ return "create_agent_from_input"
94
101
  else:
95
- logger.info("Routing to: create_agent.")
96
- return "create_agent"
97
-
98
- async def _create_agent(self, state: BuilderState):
99
- """Generates a new agent profile from scratch."""
100
- if not state.get("user_task") and not state["messages"]:
101
- raise ValueError("To create a new agent, provide either a 'user_task' or 'messages'.")
102
-
103
- user_task = state.get("user_task") or "Not provided"
104
- conversation_history = []
105
-
106
- if state["messages"]:
107
- content_str = state["messages"][-1].content
108
- raw_history = json.loads(content_str)
109
- conversation_history = _clean_conversation_history(raw_history)
110
-
111
- prompt = NEW_AGENT_PROMPT.format(
112
- user_task=user_task,
113
- conversation_history=json.dumps(conversation_history, indent=2),
102
+ raise ValueError("Invalid initial state. Cannot determine route.")
103
+
104
+ async def _create_agent_from_input(self, state: BuilderState) -> Command:
105
+ """SCENARIO 1: Generates a new agent from a single user_input, running agent and tool creation in parallel."""
106
+ user_task = state["user_task"]
107
+ logger.info(f"Creating new agent from input: '{user_task}'")
108
+
109
+ structured_llm = self.llm.with_structured_output(Agent)
110
+
111
+ async def _task_generate_agent():
112
+ prompt = _build_prompt(user_task=user_task)
113
+ return await structured_llm.ainvoke(prompt)
114
+
115
+ async def _task_find_tools():
116
+ return await self._get_tool_config_for_task(user_task)
117
+
118
+ # Run agent creation and tool finding concurrently for max efficiency
119
+ agent_profile, tool_config = await asyncio.gather(_task_generate_agent(), _task_find_tools())
120
+
121
+ logger.info(f"Successfully created agent '{agent_profile.name}' with tools: {tool_config}")
122
+
123
+ return Command(
124
+ update={"generated_agent": agent_profile, "tool_config": tool_config},
125
+ goto=END,
114
126
  )
115
127
 
128
+ async def _create_agent_from_history(self, state: BuilderState) -> Command:
129
+ """SCENARIO 2: Generates an agent by synthesizing a conversation history."""
130
+ user_task = state.get("user_task")
131
+
132
+ content_str = state["messages"][-1].content
133
+ raw_history = json.loads(content_str)
134
+ conversation_history = _clean_conversation_history(raw_history)
135
+
136
+ logger.info(f"Creating new agent from conversation history (length: {len(conversation_history)}).")
137
+
138
+ # 1. Generate the agent profile first to get the definitive instructions
139
+ tools_from_history = _extract_tools_from_history(raw_history)
140
+ prompt = _build_prompt(
141
+ user_task=user_task,
142
+ conversation_history=conversation_history,
143
+ tool_config=tools_from_history,
144
+ )
116
145
  structured_llm = self.llm.with_structured_output(Agent)
117
146
  generated_agent = await structured_llm.ainvoke(prompt)
118
-
119
- logger.info(f"Successfully created new agent '{generated_agent.name}'.")
147
+ logger.info(f"Successfully generated agent profile for '{generated_agent.name}'.")
148
+
149
+ # 2. Synthesize tool configuration based on the new instructions and history
150
+ tools_from_instructions = await self._get_tool_config_for_task(generated_agent.instructions)
151
+
152
+ final_tool_config = _merge_tool_configs(tools_from_history, tools_from_instructions)
153
+ logger.info(f"Final synthesized tool configuration: {final_tool_config}")
120
154
 
121
155
  return Command(
122
- update={"generated_agent": generated_agent},
123
- goto="create_or_update_tool_config",
156
+ update={
157
+ "generated_agent": generated_agent,
158
+ "tool_config": final_tool_config,
159
+ },
160
+ goto=END,
124
161
  )
125
162
 
126
- async def _modify_agent(self, state: BuilderState):
127
- """Modifies an existing agent based on new user feedback and/or conversation."""
163
+ async def _modify_agent(self, state: BuilderState) -> Command:
164
+ """SCENARIO 3: Modifies an existing agent and re-evaluates its tool configuration."""
128
165
  existing_agent = state["generated_agent"]
129
-
130
- if not state.get("user_task") and not state["messages"]:
131
- raise ValueError("To modify an agent, provide either a 'user_task' or 'messages'.")
132
-
133
- modification_request = state.get("user_task") or "No direct modification request provided."
134
-
135
- conversation_history = []
136
- if state["messages"]:
137
- content_str = state["messages"][-1].content
138
- raw_history = json.loads(content_str)
139
- conversation_history = _clean_conversation_history(raw_history)
140
-
141
- prompt = MODIFY_AGENT_PROMPT.format(
166
+ modification_request = state["user_task"]
167
+ existing_tools = state["tool_config"]
168
+
169
+ logger.info(f"Modifying existing agent '{existing_agent.name}' with request: '{modification_request}'")
170
+
171
+ # 1. Generate the modified agent profile to get the new definitive instructions
172
+ prompt = _build_prompt(
142
173
  existing_instructions=existing_agent.instructions,
143
174
  modification_request=modification_request,
144
- conversation_history=json.dumps(conversation_history, indent=2),
145
175
  )
146
-
147
176
  structured_llm = self.llm.with_structured_output(Agent)
148
177
  modified_agent = await structured_llm.ainvoke(prompt)
178
+ logger.info(f"Successfully generated modified agent profile for '{modified_agent.name}'.")
179
+
180
+ # 2. Update tool configuration based on the NEW instructions, preserving existing tools
181
+ tools_from_new_instructions = await self._get_tool_config_for_task(modified_agent.instructions)
182
+ final_tool_config = _merge_tool_configs(existing_tools, tools_from_new_instructions)
183
+ logger.info(f"Final updated tool configuration: {final_tool_config}")
149
184
 
150
- logger.info(f"Successfully modified agent '{modified_agent.name}'.")
151
-
152
185
  return Command(
153
- update={"generated_agent": modified_agent},
154
- goto="create_or_update_tool_config"
186
+ update={
187
+ "generated_agent": modified_agent,
188
+ "tool_config": final_tool_config,
189
+ },
190
+ goto=END,
155
191
  )
156
192
 
157
193
  async def _get_tool_config_for_task(self, task: str) -> ToolConfig:
@@ -162,53 +198,16 @@ class BuilderAgent(BaseAgent):
162
198
  final_state = await tool_finder_graph.ainvoke({"original_task": task})
163
199
  return final_state.get("execution_plan") or {}
164
200
 
165
- async def _create_or_update_tool_config(self, state: BuilderState):
166
- """
167
- Creates or updates the tool configuration by synthesizing tools from multiple sources:
168
- 1. Existing tool config (if any).
169
- 2. Tools extracted from conversation history.
170
- 3. Tools inferred from the agent's primary instructions.
171
- 4. Tools inferred from the user's direct input/task.
172
- """
173
- # 1. Get the existing configuration, if it exists
174
- final_tool_config = state.get("tool_config") or {}
175
-
176
- # 2. Extract tools directly from the conversation history
177
- if state["messages"]:
178
- content_str = state["messages"][-1].content
179
- raw_history = json.loads(content_str)
180
- history_tool_config = _extract_tools_from_history(raw_history)
181
- final_tool_config = _merge_tool_configs(final_tool_config, history_tool_config)
182
-
183
- # 3. Find tools based on the agent's synthesized instructions (even if modifying)
184
- instructions_task = state["generated_agent"].instructions
185
- instructions_tool_config = await self._get_tool_config_for_task(instructions_task)
186
- final_tool_config = _merge_tool_configs(final_tool_config, instructions_tool_config)
187
-
188
- # 4. Find tools based on the direct user input (when creating a new agent)
189
- user_task = state.get("user_task")
190
- if user_task:
191
- user_task_tool_config = await self._get_tool_config_for_task(user_task)
192
- final_tool_config = _merge_tool_configs(final_tool_config, user_task_tool_config)
193
-
194
- logger.info(f"Final synthesized tool configuration: {final_tool_config}")
195
-
196
- return Command(
197
- update={"tool_config": final_tool_config},
198
- goto=END,
199
- )
200
-
201
201
  async def _build_graph(self):
202
- """Builds the conversational agent graph."""
202
+ """Builds the conversational agent graph with the new, scenario-based structure."""
203
203
  builder = StateGraph(BuilderState)
204
204
 
205
- builder.add_node("create_agent", self._create_agent)
205
+ # Add the three self-contained nodes for each scenario
206
+ builder.add_node("create_agent_from_input", self._create_agent_from_input)
207
+ builder.add_node("create_agent_from_history", self._create_agent_from_history)
206
208
  builder.add_node("modify_agent", self._modify_agent)
207
- builder.add_node("create_or_update_tool_config", self._create_or_update_tool_config)
208
209
 
209
- builder.add_conditional_edges(
210
- START,
211
- self._entry_point_router,
212
- )
210
+ # The entry point router directs to one of the three nodes, and they all go to END
211
+ builder.add_conditional_edges(START, self._entry_point_router)
213
212
 
214
- return builder.compile(checkpointer=self.memory)
213
+ return builder.compile(checkpointer=self.memory)
@@ -1,9 +1,10 @@
1
- from collections import defaultdict
2
1
  import collections
2
+ from collections import defaultdict
3
3
 
4
4
  from loguru import logger
5
5
  from universal_mcp.types import ToolConfig
6
6
 
7
+
7
8
  def _extract_tools_from_history(history: list[dict]) -> ToolConfig:
8
9
  """
9
10
  Parses a conversation history to find and extract all tool names,
@@ -28,10 +29,7 @@ def _extract_tools_from_history(history: list[dict]) -> ToolConfig:
28
29
  app_id, tool_id = full_tool_name.split("__", 1)
29
30
  apps_with_tools[app_id].add(tool_id)
30
31
 
31
- return {
32
- app_id: sorted(list(tools))
33
- for app_id, tools in apps_with_tools.items()
34
- }
32
+ return {app_id: sorted(list(tools)) for app_id, tools in apps_with_tools.items()}
35
33
 
36
34
 
37
35
  def _clean_conversation_history(history: list[dict]) -> list[dict]:
@@ -70,4 +68,4 @@ def _merge_tool_configs(old_config: ToolConfig, new_config: ToolConfig) -> ToolC
70
68
  # Convert the sets back to sorted lists for consistent output
71
69
  final_config = {app: sorted(list(tool_set)) for app, tool_set in merged_config.items()}
72
70
  logger.info(f"Merged tool configuration: {final_config}")
73
- return final_config
71
+ return final_config