universal-mcp-agents 0.1.20rc1__py3-none-any.whl → 0.1.22__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.

@@ -56,40 +56,41 @@ class BaseAgent:
56
56
  "run_name": self.name,
57
57
  }
58
58
 
59
+ last_ai_chunk = None
59
60
  async for event, meta in self._graph.astream(
60
61
  {"messages": [{"role": "user", "content": user_input}]},
61
62
  config=run_config,
62
63
  context={"system_prompt": self.instructions, "model": self.model},
63
- stream_mode="messages",
64
+ stream_mode=["messages", "custom"],
64
65
  stream_usage=True,
65
66
  ):
66
- # Only forward assistant token chunks that are not tool-related.
67
- type_ = type(event)
68
- tags = meta.get("tags", []) if isinstance(meta, dict) else []
69
- is_quiet = isinstance(tags, list) and ("quiet" in tags)
70
- if is_quiet:
71
- continue
72
- # Handle different types of messages
73
- if type_ == AIMessageChunk:
74
- # Accumulate billing and aggregate message
75
- aggregate = event if aggregate is None else aggregate + event
76
- # Ignore intermeddite finish messages
77
- if "finish_reason" in event.response_metadata:
78
- # Got LLM finish reason ignore it
79
- logger.debug(
80
- f"Finish event: {event}, reason: {event.response_metadata['finish_reason']}, Metadata: {meta}"
81
- )
82
- pass
83
- else:
84
- logger.debug(f"Event: {event}, Metadata: {meta}")
85
- yield event
86
- # Send a final finished message
87
- # The last event would be finish
88
- event = cast(AIMessageChunk, event)
89
- event.usage_metadata = aggregate.usage_metadata
90
- logger.debug(f"Usage metadata: {event.usage_metadata}")
91
- event.content = "" # Clear the message since it would have already been streamed above
92
- yield event
67
+ if event == "messages" and isinstance(meta, (tuple, list)) and len(meta) == 2:
68
+ payload, meta_dict = meta
69
+ is_playbook = isinstance(meta_dict, dict) and meta_dict.get("langgraph_node") == "playbook"
70
+ additional_kwargs = getattr(payload, "additional_kwargs", {}) or {}
71
+ if is_playbook and not additional_kwargs.get("stream"):
72
+ continue
73
+ if isinstance(payload, AIMessageChunk):
74
+ last_ai_chunk = payload
75
+ aggregate = payload if aggregate is None else aggregate + payload
76
+ if "finish_reason" in payload.response_metadata:
77
+ logger.debug(
78
+ f"Finish event: {payload}, reason: {payload.response_metadata['finish_reason']}, Metadata: {meta_dict}"
79
+ )
80
+ pass
81
+ logger.debug(f"Event: {payload}, Metadata: {meta_dict}")
82
+ yield payload
83
+
84
+ if event == "custom":
85
+ yield meta
86
+
87
+ # Send a final finished message if we saw any AI chunks (to carry usage)
88
+ if last_ai_chunk is not None and aggregate is not None:
89
+ event = cast(AIMessageChunk, last_ai_chunk)
90
+ event.usage_metadata = aggregate.usage_metadata
91
+ logger.debug(f"Usage metadata: {event.usage_metadata}")
92
+ event.content = "" # Clear the message since it would have already been streamed above
93
+ yield event
93
94
 
94
95
  async def stream_interactive(self, thread_id: str, user_input: str):
95
96
  await self.ainit()
@@ -1,33 +1,35 @@
1
1
  import json
2
2
  import re
3
- from collections.abc import Callable
4
3
  from typing import Literal, cast
4
+ import uuid
5
5
 
6
6
  from langchain_core.messages import AIMessage, ToolMessage
7
7
  from langchain_core.tools import StructuredTool
8
8
  from langgraph.checkpoint.base import BaseCheckpointSaver
9
9
  from langgraph.graph import START, StateGraph
10
- from langgraph.types import Command, RetryPolicy
10
+ from langgraph.types import Command, RetryPolicy, StreamWriter
11
11
  from universal_mcp.tools.registry import ToolRegistry
12
12
  from universal_mcp.types import ToolConfig, ToolFormat
13
13
 
14
14
  from universal_mcp.agents.base import BaseAgent
15
- from universal_mcp.agents.codeact0.llm_tool import ai_classify, call_llm, data_extractor, smart_print
15
+ from universal_mcp.agents.codeact0.llm_tool import smart_print
16
16
  from universal_mcp.agents.codeact0.prompts import (
17
- PLAYBOOK_CONFIRMING_PROMPT,
18
17
  PLAYBOOK_GENERATING_PROMPT,
19
18
  PLAYBOOK_PLANNING_PROMPT,
19
+ PLAYBOOK_META_PROMPT,
20
20
  create_default_prompt,
21
21
  )
22
22
  from universal_mcp.agents.codeact0.sandbox import eval_unsafe, execute_ipython_cell, handle_execute_ipython_cell
23
- from universal_mcp.agents.codeact0.state import CodeActState
23
+ from universal_mcp.agents.codeact0.state import CodeActState, PlaybookCode, PlaybookPlan, PlaybookMeta
24
24
  from universal_mcp.agents.codeact0.tools import (
25
25
  create_meta_tools,
26
26
  enter_playbook_mode,
27
27
  get_valid_tools,
28
28
  )
29
+ from universal_mcp.agents.codeact0.utils import add_tools
29
30
  from universal_mcp.agents.llm import load_chat_model
30
31
  from universal_mcp.agents.utils import convert_tool_ids_to_dict, filter_retry_on, get_message_text
32
+ from universal_mcp.agents.codeact0.utils import get_connected_apps_string
31
33
 
32
34
 
33
35
  class CodeActPlaybookAgent(BaseAgent):
@@ -51,16 +53,22 @@ class CodeActPlaybookAgent(BaseAgent):
51
53
  **kwargs,
52
54
  )
53
55
  self.model_instance = load_chat_model(model)
54
- self.tools_config = tools or []
56
+ self.playbook_model_instance = load_chat_model("azure/gpt-4.1")
57
+ self.tools_config = tools or {}
55
58
  self.registry = registry
56
59
  self.playbook_registry = playbook_registry
60
+ self.playbook = playbook_registry.get_agent() if playbook_registry else None
57
61
  self.eval_fn = eval_unsafe
58
62
  self.sandbox_timeout = sandbox_timeout
59
- self.processed_tools: list[StructuredTool | Callable] = []
63
+ self.default_tools = {
64
+ "llm": ["generate_text", "classify_data", "extract_data", "call_llm"],
65
+ }
66
+ add_tools(self.tools_config, self.default_tools)
67
+
60
68
 
61
69
  async def _build_graph(self):
62
70
  meta_tools = create_meta_tools(self.registry)
63
- additional_tools = [smart_print, data_extractor, ai_classify, call_llm, meta_tools["web_search"]]
71
+ additional_tools = [smart_print, meta_tools["web_search"]]
64
72
  self.additional_tools = [
65
73
  t if isinstance(t, StructuredTool) else StructuredTool.from_function(t) for t in additional_tools
66
74
  ]
@@ -145,7 +153,7 @@ class CodeActPlaybookAgent(BaseAgent):
145
153
  else:
146
154
  raise Exception(
147
155
  f"Unexpected tool call: {tool_call['name']}. "
148
- "tool calls must be one of 'enter_playbook_mode', 'execute_ipython_cell', 'load_functions', or 'search_functions'"
156
+ "tool calls must be one of 'enter_playbook_mode', 'execute_ipython_cell', 'load_functions', or 'search_functions'. For using functions, call them in code using 'execute_ipython_cell'."
149
157
  )
150
158
  except Exception as e:
151
159
  tool_result = str(e)
@@ -161,7 +169,7 @@ class CodeActPlaybookAgent(BaseAgent):
161
169
  self.tools_config.extend(new_tool_ids)
162
170
  self.exported_tools = await self.registry.export_tools(new_tool_ids, ToolFormat.LANGCHAIN)
163
171
  self.final_instructions, self.tools_context = create_default_prompt(
164
- self.exported_tools, self.additional_tools, self.instructions
172
+ self.exported_tools, self.additional_tools, self.instructions, await get_connected_apps_string(self.registry)
165
173
  )
166
174
  if ask_user:
167
175
  tool_messages.append(AIMessage(content=ai_msg))
@@ -184,41 +192,80 @@ class CodeActPlaybookAgent(BaseAgent):
184
192
  },
185
193
  )
186
194
 
187
- def playbook(state: CodeActState) -> Command[Literal["call_model"]]:
195
+ def playbook(state: CodeActState, writer: StreamWriter) -> Command[Literal["call_model"]]:
188
196
  playbook_mode = state.get("playbook_mode")
189
197
  if playbook_mode == "planning":
198
+ plan_id = str(uuid.uuid4())
199
+ writer({
200
+ "type": "custom",
201
+ id: plan_id,
202
+ "name": "planning",
203
+ "data": {"update": bool(self.playbook)}
204
+ })
190
205
  planning_instructions = self.instructions + PLAYBOOK_PLANNING_PROMPT
191
206
  messages = [{"role": "system", "content": planning_instructions}] + state["messages"]
192
207
 
193
- response = self.model_instance.invoke(messages)
194
- response = cast(AIMessage, response)
195
- response_text = get_message_text(response)
196
- # Extract plan from response text between triple backticks
197
- plan_match = re.search(r"```(.*?)```", response_text, re.DOTALL)
198
- if plan_match:
199
- plan = plan_match.group(1).strip()
200
- else:
201
- plan = response_text.strip()
202
- return Command(update={"messages": [response], "playbook_mode": "confirming", "plan": plan})
208
+ model_with_structured_output = self.playbook_model_instance.with_structured_output(PlaybookPlan)
209
+ response = model_with_structured_output.invoke(messages)
210
+ plan = cast(PlaybookPlan, response)
211
+
212
+ writer({"type": "custom", id: plan_id, "name": "planning", "data": {"plan": plan.steps}})
213
+ return Command(update={"messages": [AIMessage(content=json.dumps(plan.dict()), additional_kwargs={"type": "planning", "plan": plan.steps, "update": bool(self.playbook)})], "playbook_mode": "confirming", "plan": plan.steps})
203
214
 
204
215
  elif playbook_mode == "confirming":
205
- confirmation_instructions = self.instructions + PLAYBOOK_CONFIRMING_PROMPT
206
- messages = [{"role": "system", "content": confirmation_instructions}] + state["messages"]
207
- response = self.model_instance.invoke(messages, stream=False)
208
- response = get_message_text(response)
209
- if "true" in response.lower():
210
- return Command(goto="playbook", update={"playbook_mode": "generating"})
211
- else:
212
- return Command(goto="playbook", update={"playbook_mode": "planning"})
216
+ # Deterministic routing based on three exact button inputs from UI
217
+ user_text = ""
218
+ for m in reversed(state["messages"]):
219
+ try:
220
+ if getattr(m, "type", "") in {"human", "user"}:
221
+ user_text = (get_message_text(m) or "").strip()
222
+ if user_text:
223
+ break
224
+ except Exception:
225
+ continue
226
+
227
+ t = user_text.lower()
228
+ if t == "yes, this is great":
229
+ # Generate playbook metadata (name and description) before moving to generation
230
+ meta_id = str(uuid.uuid4())
231
+ writer({
232
+ "type": "custom",
233
+ id: meta_id,
234
+ "name": "metadata",
235
+ "data": {"update": bool(self.playbook)}
236
+ })
237
+ meta_instructions = self.instructions + PLAYBOOK_META_PROMPT
238
+ messages = [{"role": "system", "content": meta_instructions}] + state["messages"]
239
+
240
+ model_with_structured_output = self.playbook_model_instance.with_structured_output(PlaybookMeta)
241
+ meta_response = model_with_structured_output.invoke(messages)
242
+ meta = cast(PlaybookMeta, meta_response)
243
+
244
+ writer({"type": "custom", id: meta_id, "name": "metadata", "data": {"name": meta.name, "description": meta.description}})
245
+ return Command(goto="playbook", update={"playbook_mode": "generating", "playbook_name": meta.name, "playbook_description": meta.description})
246
+ if t == "i would like to modify the plan":
247
+ prompt_ai = AIMessage(content="What would you like to change about the plan? Let me know and I'll update the plan accordingly.", additional_kwargs={"stream": "true"})
248
+ return Command(update={"playbook_mode": "planning", "messages": [prompt_ai]})
249
+ if t == "let's do something else":
250
+ return Command(goto="call_model", update={"playbook_mode": "inactive"})
251
+
252
+ # Fallback safe default
253
+ return Command(goto="call_model", update={"playbook_mode": "inactive"})
213
254
 
214
255
  elif playbook_mode == "generating":
256
+ generate_id = str(uuid.uuid4())
257
+ writer({
258
+ "type": "custom",
259
+ id: generate_id,
260
+ "name": "generating",
261
+ "data": {"update": bool(self.playbook)}
262
+ })
215
263
  generating_instructions = self.instructions + PLAYBOOK_GENERATING_PROMPT
216
264
  messages = [{"role": "system", "content": generating_instructions}] + state["messages"]
217
- response = cast(AIMessage, self.model_instance.invoke(messages))
218
- raw_content = get_message_text(response)
219
- func_code = raw_content.strip()
220
- func_code = func_code.replace("```python", "").replace("```", "")
221
- func_code = func_code.strip()
265
+
266
+ model_with_structured_output = self.playbook_model_instance.with_structured_output(PlaybookCode)
267
+ response = model_with_structured_output.invoke(messages)
268
+ func_code = cast(PlaybookCode, response).code
222
269
 
223
270
  # Extract function name (handle both regular and async functions)
224
271
  match = re.search(r"^\s*(?:async\s+)?def\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", func_code, re.MULTILINE)
@@ -227,48 +274,44 @@ class CodeActPlaybookAgent(BaseAgent):
227
274
  else:
228
275
  function_name = "generated_playbook"
229
276
 
277
+ # Use generated metadata if available
278
+ final_name = state.get("playbook_name") or function_name
279
+ final_description = state.get("playbook_description") or f"Generated playbook: {function_name}"
280
+
230
281
  # Save or update an Agent using the helper registry
231
- saved_note = ""
232
282
  try:
233
- if not self.playbook_registry:
283
+ if not self.playbook_registry:
234
284
  raise ValueError("Playbook registry is not configured")
235
285
 
236
286
  # Build instructions payload embedding the plan and function code
237
287
  instructions_payload = {
238
288
  "playbookPlan": state["plan"],
239
- "playbookScript": {
240
- "name": function_name,
241
- "code": func_code,
242
- },
289
+ "playbookScript": func_code,
243
290
  }
244
291
 
245
292
  # Convert tool ids list to dict
246
293
  tool_dict = convert_tool_ids_to_dict(state["selected_tool_ids"])
247
294
 
248
- res = self.playbook_registry.create_agent(
249
- name=function_name,
250
- description=f"Generated playbook: {function_name}",
295
+ res = self.playbook_registry.upsert_agent(
296
+ name=final_name,
297
+ description=final_description,
251
298
  instructions=instructions_payload,
252
299
  tools=tool_dict,
253
300
  visibility="private",
254
301
  )
255
- saved_note = f"Successfully created your playbook! Check it out here: [View Playbook](https://wingmen.info/agents/{res.id})"
256
302
  except Exception as e:
257
- saved_note = f"Failed to save generated playbook as Agent '{function_name}': {e}"
303
+ raise e
258
304
 
259
- # Mock tool call for exit_playbook_mode (for testing/demonstration)
260
- mock_exit_tool_call = {"name": "exit_playbook_mode", "args": {}, "id": "mock_exit_playbook_123"}
261
- mock_assistant_message = AIMessage(content=saved_note, tool_calls=[mock_exit_tool_call])
262
-
263
- # Mock tool response for exit_playbook_mode
264
- mock_exit_tool_response = ToolMessage(
265
- content=json.dumps(f"Exited Playbook Mode.{saved_note}"),
266
- name="exit_playbook_mode",
267
- tool_call_id="mock_exit_playbook_123",
268
- )
305
+ writer({
306
+ "type": "custom",
307
+ id: generate_id,
308
+ "name": "generating",
309
+ "data": {"id": str(res.id), "update": bool(self.playbook)}
310
+ })
311
+ mock_assistant_message = AIMessage(content=json.dumps(response.dict()), additional_kwargs={"type": "generating", "id": str(res.id), "update": bool(self.playbook)})
269
312
 
270
313
  return Command(
271
- update={"messages": [mock_assistant_message, mock_exit_tool_response], "playbook_mode": "normal"}
314
+ update={"messages": [mock_assistant_message], "playbook_mode": "normal"}
272
315
  )
273
316
 
274
317
  async def route_entry(state: CodeActState) -> Literal["call_model", "playbook"]:
@@ -277,7 +320,7 @@ class CodeActPlaybookAgent(BaseAgent):
277
320
  self.tools_config.extend(state.get("selected_tool_ids", []))
278
321
  self.exported_tools = await self.registry.export_tools(self.tools_config, ToolFormat.LANGCHAIN)
279
322
  self.final_instructions, self.tools_context = create_default_prompt(
280
- self.exported_tools, self.additional_tools, self.instructions
323
+ self.exported_tools, self.additional_tools, self.instructions, await get_connected_apps_string(self.registry)
281
324
  )
282
325
  if state.get("playbook_mode") in ["planning", "confirming", "generating"]:
283
326
  return "playbook"
@@ -1,11 +1,6 @@
1
- import json
2
- from dataclasses import dataclass
3
- from typing import Any, Literal, cast
1
+ from typing import Any
4
2
 
5
- from langchain.chat_models import init_chat_model
6
- from langchain_openai import AzureChatOpenAI
7
-
8
- from universal_mcp.agents.codeact0.utils import get_message_text, light_copy
3
+ from universal_mcp.agents.codeact0.utils import light_copy
9
4
 
10
5
  MAX_RETRIES = 3
11
6
 
@@ -28,250 +23,3 @@ def smart_print(data: Any) -> None:
28
23
  data: Either a dictionary with string keys, or a list of such dictionaries
29
24
  """
30
25
  print(light_copy(data)) # noqa: T201
31
-
32
-
33
- def creative_writer(
34
- task: str,
35
- context: Any | list[Any] | dict[str, Any],
36
- tone: str = "normal",
37
- format: Literal["markdown", "html", "plain"] = "markdown",
38
- length: Literal["very-short", "concise", "normal", "long"] = "concise",
39
- ) -> str:
40
- """
41
- Given a high-level writing task and context, returns a well-written text
42
- that achieves the task, given the context.
43
-
44
- Example Call:
45
- creative_writer("Summarize this website with the goal of making it easy to understand.", web_content)
46
- creative_writer("Make a markdown table summarizing the key differences between doc_1 and doc_2.", {"doc_1": str(doc_1), "doc_2": str(doc_2)})
47
- creative_writer("Summarize all the provided documents.", [doc_1, doc_2, doc_3])
48
-
49
- Important:
50
- - Include specifics of the goal in the context verbatim.
51
- - Be precise and direct in the task, and include as much context as possible.
52
- - Include relevant high-level goals or intent in the task.
53
- - You can provide multiple documents as input, and reference them in the task.
54
- - You MUST provide the contents of any source documents to `creative_writer`.
55
- - NEVER use `creative_writer` to produce JSON for a Pydantic model.
56
-
57
- Args:
58
- task: The main writing task or directive.
59
- context: A single string, list of strings, or dict mapping labels to content.
60
- tone: The desired tone of the output (e.g., "normal", "flirty", "formal", "casual", "crisp", "poetic", "technical", "internet-chat", "smartass", etc.).
61
- format: Output format ('markdown', 'html', 'plain-text').
62
- length: Desired length of the output ('very-short', 'concise', 'normal', 'long').
63
-
64
- Returns:
65
- str: The generated text output.
66
- """
67
-
68
- context = get_context_str(context)
69
-
70
- task = task.strip() + "\n\n"
71
- if format == "markdown":
72
- task += "Please write in Markdown format.\n\n"
73
- elif format == "html":
74
- task += "Please write in HTML format.\n\n"
75
- else:
76
- task += "Please write in plain text format. Don't use markdown or HTML.\n\n"
77
-
78
- if tone not in ["normal", "default", ""]:
79
- task = f"{task} (Tone instructions: {tone})"
80
-
81
- if length not in ["normal", "default", ""]:
82
- task = f"{task} (Length instructions: {length})"
83
-
84
- prompt = f"{task}\n\nContext:\n{context}\n\n"
85
-
86
- model = AzureChatOpenAI(model="gpt-4o", temperature=0.7)
87
-
88
- response = model.with_retry(stop_after_attempt=MAX_RETRIES).invoke(prompt)
89
- return get_message_text(response)
90
-
91
-
92
- def ai_classify(
93
- classification_task_and_requirements: str,
94
- context: Any | list[Any] | dict[str, Any],
95
- class_descriptions: dict[str, str],
96
- ) -> dict[str, Any]:
97
- """
98
- Classifies and compares data based on given requirements.
99
-
100
- Use `ai_classify` for tasks which need to classify data into one of many categories.
101
- If making multiple binary classifications, call `ai_classify` for each.
102
-
103
- Guidance:
104
- - Prefer to use ai_classify operations to compare strings, rather than string ops.
105
- - Prefer to include an "Unsure" category for classification tasks.
106
- - The `class_descriptions` dict argument MUST be a map from possible class names to a precise description.
107
- - Use precise and specific class names and concise descriptions.
108
- - Pass ALL relevant context, preferably as a dict mapping labels to content.
109
- - Returned dict maps each possible class name to a probability.
110
-
111
- Example Usage:
112
- classification_task_and_requirements = "Does the document contain an address?"
113
- class_descriptions = {
114
- "Is_Address": "Valid addresses usually have street names, city, and zip codes.",
115
- "Not_Address": "Not valid addresses."
116
- }
117
- classification = ai_classify(
118
- classification_task_and_requirements,
119
- {"address": extracted_address},
120
- class_descriptions
121
- )
122
- if classification["probabilities"]["Is_Address"] > 0.5:
123
- ...
124
-
125
- Args:
126
- classification_task_and_requirements: The classification question and rules.
127
- context: The data to classify (string, list, or dict).
128
- class_descriptions: Mapping from class names to descriptions.
129
-
130
- Returns:
131
- dict: {
132
- probabilities: dict[str, float],
133
- reason: str,
134
- top_class: str,
135
- }
136
- """
137
-
138
- context = get_context_str(context)
139
-
140
- prompt = (
141
- f"{classification_task_and_requirements}\n\n"
142
- f"\nThis is classification task\nPossible classes and descriptions:\n"
143
- f"{json.dumps(class_descriptions, indent=2)}\n"
144
- f"\nContext:\n{context}\n\n"
145
- "Return ONLY a valid JSON object, no extra text."
146
- )
147
-
148
- model = init_chat_model(model="claude-4-sonnet-20250514", temperature=0)
149
-
150
- @dataclass
151
- class ClassificationResult:
152
- probabilities: dict[str, float]
153
- reason: str
154
- top_class: str
155
-
156
- response = (
157
- model.with_structured_output(schema=ClassificationResult, method="json_mode")
158
- .with_retry(stop_after_attempt=MAX_RETRIES)
159
- .invoke(prompt)
160
- )
161
- return cast(dict[str, Any], response)
162
-
163
-
164
- def call_llm(
165
- task_instructions: str, context: Any | list[Any] | dict[str, Any], output_json_schema: dict[str, Any]
166
- ) -> dict[str, Any]:
167
- """
168
- Call a Large Language Model (LLM) with an instruction and contextual information,
169
- returning a dictionary matching the given output_json_schema.
170
- Can be used for tasks like creative writing, llm reasoning based content generation, etc.
171
-
172
- You MUST anticipate Exceptions in reasoning based tasks which will lead to some empty fields
173
- in the returned output; skip this item if applicable.
174
-
175
- General Guidelines:
176
- - Be comprehensive, specific, and precise on the task instructions.
177
- - Include as much context as possible.
178
- - You can provide multiple items in context, and reference them in the task.
179
- - Include relevant high-level goals or intent in the task.
180
- - In the output_json_schema, use required field wherever necessary.
181
- - The more specific your task instructions and output_json_schema are, the better the results.
182
-
183
- Guidelines for content generation tasks:
184
- - Feel free to add instructions for tone, length, and format (markdown, html, plain-text, xml)
185
- - Some examples of tone are: "normal", "flirty", "formal", "casual", "crisp", "poetic", "technical", "internet-chat", "smartass", etc.
186
- - Prefer length to be concise by default. Other examples are: "very-short", "concise", "normal", "long", "2-3 lines", etc.
187
- - In format prefer plain-text but you can also use markdown and html wherever useful.
188
-
189
- Args:
190
- instruction: The main directive for the LLM (e.g., "Summarize the article" or "Extract key entities").
191
- context:
192
- A dictionary containing named text elements that provide additional
193
- information for the LLM. Keys are labels (e.g., 'article', 'transcript'),
194
- values are strings of content.
195
- output_json_schema: must be a valid JSON schema with top-level 'title' and 'description' keys.
196
-
197
- Returns:
198
- dict: Parsed JSON object matching the desired output_json_schema.
199
-
200
- """
201
- context = get_context_str(context)
202
-
203
- prompt = f"{task_instructions}\n\nContext:\n{context}\n\nReturn ONLY a valid JSON object, no extra text."
204
-
205
- model = init_chat_model(model="claude-4-sonnet-20250514", temperature=0)
206
-
207
- response = (
208
- model.with_structured_output(schema=output_json_schema, method="json_mode")
209
- .with_retry(stop_after_attempt=MAX_RETRIES)
210
- .invoke(prompt)
211
- )
212
- return cast(dict[str, Any], response)
213
-
214
-
215
- def data_extractor(
216
- extraction_task: str, source: Any | list[Any] | dict[str, Any], output_json_schema: dict[str, Any]
217
- ) -> dict[str, Any]:
218
- """
219
- Extracts structured data from unstructured data (documents, webpages, images, large bodies of text),
220
- returning a dictionary matching the given output_json_schema.
221
-
222
- You MUST anticipate Exception raised for unextractable data; skip this item if applicable.
223
-
224
- Strongly prefer to:
225
- - Be comprehensive, specific, and precise on the data you want to extract.
226
- - Use optional fields everywhere.
227
- - Extract multiple items from each source unless otherwise specified.
228
- - The more specific your extraction task and output_json_schema are, the better the results.
229
-
230
- Args:
231
- extraction_task: The directive describing what to extract.
232
- source: The unstructured data to extract from.
233
- output_json_schema: must be a valid JSON schema with top-level 'title' and 'description' keys.
234
-
235
- Returns:
236
- dict: Parsed JSON object matching the desired output_json_schema.
237
-
238
- Example:
239
- news_articles_schema = {
240
- "title": "NewsArticleList",
241
- "description": "A list of news articles with headlines and URLs",
242
- "type": "object",
243
- "properties": {
244
- "articles": {
245
- "type": "array",
246
- "items": {
247
- "type": "object",
248
- "properties": {
249
- "headline": {
250
- "type": "string"
251
- },
252
- "url": {
253
- "type": "string"
254
- }
255
- },
256
- "required": ["headline", "url"]
257
- }
258
- }
259
- },
260
- "required": ["articles"]
261
- }
262
-
263
- news_articles = data_extractor("Extract headlines and their corresponding URLs.", content, news_articles_schema)
264
- """
265
-
266
- context = get_context_str(source)
267
-
268
- prompt = f"{extraction_task}\n\nContext:\n{context}\n\nReturn ONLY a valid JSON object, no extra text."
269
-
270
- model = init_chat_model(model="claude-4-sonnet-20250514", temperature=0)
271
-
272
- response = (
273
- model.with_structured_output(schema=output_json_schema, method="json_mode")
274
- .with_retry(stop_after_attempt=MAX_RETRIES)
275
- .invoke(prompt)
276
- )
277
- return cast(dict[str, Any], response)
@@ -3,11 +3,10 @@ import re
3
3
  from collections.abc import Sequence
4
4
 
5
5
  from langchain_core.tools import StructuredTool
6
-
7
6
  from universal_mcp.agents.codeact0.utils import schema_to_signature
8
7
 
9
8
  uneditable_prompt = """
10
- You are **Wingmen**, an AI Assistant created by AgentR — a creative, straight-forward, and direct principal software engineer with access to tools.
9
+ You are **Ruzo**, an AI Assistant created by AgentR — a creative, straight-forward, and direct principal software engineer with access to tools.
11
10
 
12
11
  Your job is to answer the user's question or perform the task they ask for.
13
12
  - Answer simple questions (which do not require you to write any code or access any external resources) directly. Note that any operation that involves using ONLY print functions should be answered directly in the chat. NEVER write a string yourself and print it.
@@ -21,7 +20,13 @@ Your job is to answer the user's question or perform the task they ask for.
21
20
  - Read and understand the output of the previous code snippet and use it to answer the user's request. Note that the code output is NOT visible to the user, so after the task is complete, you have to give the output to the user in a markdown format. Similarly, you should only use print/smart_print for your own analysis, the user does not get the output.
22
21
  - If needed, feel free to ask for more information from the user (without using the `execute_ipython_cell` tool) to clarify the task.
23
22
 
24
- GUIDELINES for writing code:
23
+ **Code Execution Guidelines:**
24
+ - The code you write will be executed in a sandbox environment, and you can use the output of previous executions in your code. Variables, functions, imports are retained.
25
+ - Read and understand the output of the previous code snippet and use it to answer the user's request. Note that the code output is NOT visible to the user, so after the task is complete, you have to give the output to the user in a markdown format. Similarly, you should only use print/smart_print for your own analysis, the user does not get the output.
26
+ - If needed, feel free to ask for more information from the user (without using the `execute_ipython_cell` tool) to clarify the task.
27
+ - Always describe in 2-3 lines about the current progress. In each step, mention what has been achieved and what you are planning to do next.
28
+
29
+ **Coding Best Practices:**
25
30
  - Variables defined at the top level of previous code snippets can be referenced in your code.
26
31
  - External functions which return a dict or list[dict] are ambiguous. Therefore, you MUST explore the structure of the returned data using `smart_print()` statements before using it, printing keys and values. `smart_print` truncates long strings from data, preventing huge output logs.
27
32
  - When an operation involves running a fixed set of steps on a list of items, run one run correctly and then use a for loop to run the steps on each item in the list.
@@ -29,25 +34,40 @@ GUIDELINES for writing code:
29
34
  - You can only import libraries that come pre-installed with Python. However, do consider searching for external functions first, using the search and load tools to access them in the code.
30
35
  - For displaying final results to the user, you must present your output in markdown format, including image links, so that they are rendered and displayed to the user. The code output is NOT visible to the user.
31
36
  - Call all functions using keyword arguments only, never positional arguments.
32
- - Async Functions (Critical): Use them only as follows-
33
- Case 1: Top-level await without asyncio.run()
37
+
38
+ **Async Functions (Critical Rules):**
39
+ Use async functions only as follows:
40
+ - Case 1: Top-level await without asyncio.run()
34
41
  Wrap in async function and call with asyncio.run():
42
+ ```python
35
43
  async def main():
36
44
  result = await some_async_function()
37
45
  return result
38
46
  asyncio.run(main())
39
- Case 2: Using asyncio.run() directly
40
- If code already contains asyncio.run(), use as-is — do not wrap again:
47
+ ```
48
+ - Case 2: Using asyncio.run() directly
49
+ If code already contains asyncio.run(), use as-is — do not wrap again:
50
+ ```python
41
51
  asyncio.run(some_async_function())
52
+ ```
42
53
  Rules:
43
54
  - Never use await outside an async function
44
55
  - Never use await asyncio.run()
45
56
  - Never nest asyncio.run() calls
57
+
58
+ **Final Output Requirements:**
59
+ - Once you have all the information about the task, return the text directly to user in markdown format. No need to call `execute_ipython_cell` again.
60
+ - Always respond in github flavoured markdown format.
61
+ - For charts and diagrams, use mermaid chart in markdown directly.
62
+ - Your final response should contain the complete answer to the user's request in a clear, well-formatted manner that directly addresses what they asked for.
46
63
  """
47
64
 
48
65
  PLAYBOOK_PLANNING_PROMPT = """Now, you are tasked with creating a reusable playbook from the user's previous workflow.
49
66
 
50
- TASK: Analyze the conversation history and code execution to create a step-by-step plan for a reusable function. Do not include the searching and loading of tools. Assume that the tools have already been loaded.
67
+ TASK: Analyze the conversation history and code execution to create a step-by-step plan for a reusable function.
68
+ Do not include the searching and loading of tools. Assume that the tools have already been loaded.
69
+ The plan is a sequence of steps.
70
+ You must output a JSON object with a single key "steps", which is a list of strings. Each string is a step in the playbook.
51
71
 
52
72
  Your plan should:
53
73
  1. Identify the key steps in the workflow
@@ -56,24 +76,45 @@ Your plan should:
56
76
  4. Be clear and concise
57
77
 
58
78
  Example:
59
- ```
60
- 1. Connect to database using `db_connection_string`
61
- 2. Query user data for `user_id`
62
- 3. Process results and calculate `metric_name`
63
- 4. Send notification to `email_address`
64
- ```
65
-
66
- Now create a plan based on the conversation history. Enclose it between ``` and ```. Ask the user if the plan is okay."""
67
-
68
-
69
- PLAYBOOK_CONFIRMING_PROMPT = """Now, you are tasked with confirming the playbook plan. Return True if the user is happy with the plan, False otherwise. Do not say anything else in your response. The user response will be the last message in the chain.
79
+ {
80
+ "steps": [
81
+ "Connect to database using `db_connection_string`",
82
+ "Query user data for `user_id`",
83
+ "Process results and calculate `metric_name`",
84
+ "Send notification to `email_address`"
85
+ ]
86
+ }
87
+
88
+ Now create a plan based on the conversation history. Do not include any other text or explanation in your response. Just the JSON object.
70
89
  """
71
90
 
72
- PLAYBOOK_GENERATING_PROMPT = """Now, you are tasked with generating the playbook function. Return the function in Python code.
73
- Do not include any other text in your response.
91
+
92
+ PLAYBOOK_GENERATING_PROMPT = """Now, you are tasked with generating the playbook function.
93
+ Your response must be ONLY the Python code for the function.
94
+ Do not include any other text, markdown, or explanations in your response.
95
+ Your response should start with `def` or `async def`.
74
96
  The function should be a single, complete piece of code that can be executed independently, based on previously executed code snippets that executed correctly.
75
97
  The parameters of the function should be the same as the final confirmed playbook plan.
76
- Do not include anything other than python code in your response
98
+ """
99
+
100
+
101
+ PLAYBOOK_META_PROMPT = """
102
+ You are preparing metadata for a reusable playbook based on the confirmed step-by-step plan.
103
+
104
+ TASK: Create a concise, human-friendly name and a short description for the playbook.
105
+
106
+ INPUTS:
107
+ - Conversation context and plan steps will be provided in prior messages
108
+
109
+ REQUIREMENTS:
110
+ 1. Name: 3-6 words, Title Case, no punctuation except hyphens if needed
111
+ 2. Description: Single sentence, <= 140 characters, clearly states what the playbook does
112
+
113
+ OUTPUT: Return ONLY a JSON object with exactly these keys:
114
+ {
115
+ "name": "...",
116
+ "description": "..."
117
+ }
77
118
  """
78
119
 
79
120
 
@@ -90,92 +131,20 @@ def make_safe_function_name(name: str) -> str:
90
131
  return safe_name
91
132
 
92
133
 
93
- def dedent(text):
94
- """Remove any common leading whitespace from every line in `text`.
95
-
96
- This can be used to make triple-quoted strings line up with the left
97
- edge of the display, while still presenting them in the source code
98
- in indented form.
99
-
100
- Note that tabs and spaces are both treated as whitespace, but they
101
- are not equal: the lines " hello" and "\\thello" are
102
- considered to have no common leading whitespace.
103
-
104
- Entirely blank lines are normalized to a newline character.
105
- """
106
- # Look for the longest leading string of spaces and tabs common to
107
- # all lines.
108
- margin = None
109
- _whitespace_only_re = re.compile("^[ \t]+$", re.MULTILINE)
110
- _leading_whitespace_re = re.compile("(^[ \t]*)(?:[^ \t\n])", re.MULTILINE)
111
- text = _whitespace_only_re.sub("", text)
112
- indents = _leading_whitespace_re.findall(text)
113
- for indent in indents:
114
- if margin is None:
115
- margin = indent
116
-
117
- # Current line more deeply indented than previous winner:
118
- # no change (previous winner is still on top).
119
- elif indent.startswith(margin):
120
- pass
121
-
122
- # Current line consistent with and no deeper than previous winner:
123
- # it's the new winner.
124
- elif margin.startswith(indent):
125
- margin = indent
126
-
127
- # Find the largest common whitespace between current line and previous
128
- # winner.
129
- else:
130
- for i, (x, y) in enumerate(zip(margin, indent)):
131
- if x != y:
132
- margin = margin[:i]
133
- break
134
-
135
- # sanity check (testing/debugging only)
136
- if 0 and margin:
137
- for line in text.split("\n"):
138
- assert not line or line.startswith(margin), f"line = {line!r}, margin = {margin!r}"
139
-
140
- if margin:
141
- text = re.sub(r"(?m)^" + margin, "", text)
142
- return text
143
-
144
-
145
- def indent(text, prefix, predicate=None):
146
- """Adds 'prefix' to the beginning of selected lines in 'text'.
147
-
148
- If 'predicate' is provided, 'prefix' will only be added to the lines
149
- where 'predicate(line)' is True. If 'predicate' is not provided,
150
- it will default to adding 'prefix' to all non-empty lines that do not
151
- consist solely of whitespace characters.
152
- """
153
- if predicate is None:
154
- # str.splitlines(True) doesn't produce empty string.
155
- # ''.splitlines(True) => []
156
- # 'foo\n'.splitlines(True) => ['foo\n']
157
- # So we can use just `not s.isspace()` here.
158
- def predicate(s):
159
- return not s.isspace()
160
-
161
- prefixed_lines = []
162
- for line in text.splitlines(True):
163
- if predicate(line):
164
- prefixed_lines.append(prefix)
165
- prefixed_lines.append(line)
166
-
167
- return "".join(prefixed_lines)
168
-
169
-
170
134
  def create_default_prompt(
171
135
  tools: Sequence[StructuredTool],
172
136
  additional_tools: Sequence[StructuredTool],
173
137
  base_prompt: str | None = None,
138
+ apps_string: str | None = None,
139
+ playbook: object | None = None,
174
140
  ):
175
- system_prompt = uneditable_prompt.strip() + (
176
- "\n\nIn addition to the Python Standard Library, you can use the following external functions:\n"
177
- )
141
+ system_prompt = uneditable_prompt.strip()
142
+ if apps_string:
143
+ system_prompt += f"\n\n**Connected external applications (These apps have been logged into by the user):**\n{apps_string}\n\n Use `search_functions` to search for functions you can perform using the above. You can also discover more applications using the `search_functions` tool to find additional tools and integrations, if required.\n"
144
+ system_prompt += "\n\nIn addition to the Python Standard Library, you can use the following external functions:\n"
145
+
178
146
  tools_context = {}
147
+
179
148
  for tool in tools:
180
149
  if hasattr(tool, "func") and tool.func is not None:
181
150
  tool_callable = tool.func
@@ -186,7 +155,7 @@ def create_default_prompt(
186
155
  system_prompt += f'''{"async " if is_async else ""}{schema_to_signature(tool.args, tool.name)}:
187
156
  """{tool.description}"""
188
157
  ...
189
- '''
158
+ '''
190
159
  safe_name = make_safe_function_name(tool.name)
191
160
  tools_context[safe_name] = tool_callable
192
161
 
@@ -200,11 +169,31 @@ def create_default_prompt(
200
169
  system_prompt += f'''{"async " if is_async else ""}def {tool.name} {str(inspect.signature(tool_callable))}:
201
170
  """{tool.description}"""
202
171
  ...
203
- '''
172
+ '''
204
173
  safe_name = make_safe_function_name(tool.name)
205
174
  tools_context[safe_name] = tool_callable
206
175
 
207
176
  if base_prompt and base_prompt.strip():
208
177
  system_prompt += f"Your goal is to perform the following task:\n\n{base_prompt}"
209
178
 
179
+ # Append existing playbook (plan + code) if provided
180
+ try:
181
+ if playbook and hasattr(playbook, "instructions"):
182
+ pb = playbook.instructions or {}
183
+ plan = pb.get("playbookPlan")
184
+ code = pb.get("playbookScript")
185
+ if plan or code:
186
+ system_prompt += "\n\nExisting Playbook Provided:\n"
187
+ if plan:
188
+ if isinstance(plan, list):
189
+ plan_block = "\n".join(f"- {str(s)}" for s in plan)
190
+ else:
191
+ plan_block = str(plan)
192
+ system_prompt += f"Plan Steps:\n{plan_block}\n"
193
+ if code:
194
+ system_prompt += f"\nScript:\n```python\n{str(code)}\n```\n"
195
+ except Exception:
196
+ # Silently ignore formatting issues
197
+ pass
198
+
210
199
  return system_prompt, tools_context
@@ -1,6 +1,20 @@
1
- from typing import Annotated, Any
1
+ from typing import Annotated, Any, List
2
2
 
3
3
  from langgraph.prebuilt.chat_agent_executor import AgentState
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class PlaybookPlan(BaseModel):
8
+ steps: List[str] = Field(description="The steps of the playbook.")
9
+
10
+
11
+ class PlaybookCode(BaseModel):
12
+ code: str = Field(description="The Python code for the playbook.")
13
+
14
+
15
+ class PlaybookMeta(BaseModel):
16
+ name: str = Field(description="Concise, title-cased playbook name (3-6 words).")
17
+ description: str = Field(description="Short, one-sentence description (<= 140 chars).")
4
18
 
5
19
 
6
20
  def _enqueue(left: list, right: list) -> list:
@@ -36,5 +50,9 @@ class CodeActState(AgentState):
36
50
  """State for the playbook agent."""
37
51
  selected_tool_ids: Annotated[list[str], _enqueue]
38
52
  """Queue for tools exported from registry"""
39
- plan: str | None
53
+ plan: list[str] | None
40
54
  """Plan for the playbook agent."""
55
+ playbook_name: str | None
56
+ """Generated playbook name after confirmation."""
57
+ playbook_description: str | None
58
+ """Generated short description after confirmation."""
@@ -4,6 +4,7 @@ from collections import defaultdict
4
4
  from typing import Annotated, Any
5
5
 
6
6
  from langchain_core.tools import tool
7
+ from loguru import logger
7
8
  from pydantic import Field
8
9
  from universal_mcp.agentr.registry import AgentrRegistry
9
10
  from universal_mcp.types import ToolFormat
@@ -69,9 +70,10 @@ def create_meta_tools(tool_registry: AgentrRegistry) -> dict[str, Any]:
69
70
 
70
71
  canonical_app_id = None
71
72
  found_tools_result = []
73
+ THRESHOLD = 0.8
72
74
 
73
75
  if app_id:
74
- relevant_apps = await registry.search_apps(query=app_id, distance_threshold=0.7)
76
+ relevant_apps = await registry.search_apps(query=app_id, distance_threshold=THRESHOLD)
75
77
  if not relevant_apps:
76
78
  return {
77
79
  "found_tools": [],
@@ -103,11 +105,11 @@ def create_meta_tools(tool_registry: AgentrRegistry) -> dict[str, Any]:
103
105
  prioritized_app_id_list = [canonical_app_id]
104
106
  else:
105
107
  # 1. Perform an initial broad search for tools.
106
- initial_tool_search_tasks = [registry.search_tools(query=q, distance_threshold=0.7) for q in queries]
108
+ initial_tool_search_tasks = [registry.search_tools(query=q, distance_threshold=THRESHOLD) for q in queries]
107
109
  initial_tool_results = await asyncio.gather(*initial_tool_search_tasks)
108
110
 
109
111
  # 2. Search for relevant apps.
110
- app_search_tasks = [registry.search_apps(query=q, distance_threshold=0.7) for q in queries]
112
+ app_search_tasks = [registry.search_apps(query=q, distance_threshold=THRESHOLD) for q in queries]
111
113
  app_search_results = await asyncio.gather(*app_search_tasks)
112
114
 
113
115
  # 3. Create a prioritized list of app IDs for the final search.
@@ -130,7 +132,7 @@ def create_meta_tools(tool_registry: AgentrRegistry) -> dict[str, Any]:
130
132
  for app_id_to_search in prioritized_app_id_list:
131
133
  for query in queries:
132
134
  final_tool_search_tasks.append(
133
- registry.search_tools(query=query, app_id=app_id_to_search, distance_threshold=0.7)
135
+ registry.search_tools(query=query, app_id=app_id_to_search, distance_threshold=THRESHOLD)
134
136
  )
135
137
  query_results = await asyncio.gather(*final_tool_search_tasks)
136
138
 
@@ -208,39 +210,27 @@ def create_meta_tools(tool_registry: AgentrRegistry) -> dict[str, Any]:
208
210
 
209
211
  @tool
210
212
  async def web_search(query: str) -> dict:
211
- """Get an LLM answer to a question informed by Perplexity web search results.
212
- Useful when you need information from a wide range of real-time sources on the web.
213
- Do not use this when you need to access contents of a specific webpage.
213
+ """
214
+ Get an LLM answer to a question informed by Exa search results. Useful when you need information from a wide range of real-time sources on the web. Do not use this when you need to access contents of a specific webpage.
214
215
 
215
- This tool performs a Perplexity request via `perplexity__answer_with_search`, which:
216
- 1. Provides a **direct answer** for factual queries with citation(s)
216
+ This tool performs an Exa `/answer` request, which:
217
+ 1. Provides a **direct answer** for factual queries (e.g., "What is the capital of France?" → "Paris")
217
218
  2. Generates a **summary with citations** for open-ended questions
219
+ (e.g., "What is the state of AI in healthcare?" → A detailed summary with source links)
218
220
 
219
221
  Args:
220
222
  query (str): The question or topic to answer.
221
-
222
223
  Returns:
223
- dict: A structured response containing:
224
- - answer (str): Generated answer with markdown formatting and citation numbers [1][2]
225
- - citations (list[str]): List of source URLs corresponding to citation numbers
224
+ dict: A structured response containing only:
225
+ - answer (str): Generated answer
226
+ - citations (list[dict]): List of cited sources
226
227
  """
227
- await tool_registry.export_tools(["perplexity__answer_with_search"], ToolFormat.LANGCHAIN)
228
-
229
- response = await tool_registry.call_tool(
230
- "perplexity__answer_with_search",
231
- {
232
- "query": query,
233
- "model": "sonar",
234
- "temperature": 1.0,
235
- "system_prompt": (
236
- "You are a helpful AI assistant that answers questions using real-time information from the web."
237
- ),
238
- },
239
- )
228
+ await tool_registry.export_tools(["exa__answer"], ToolFormat.LANGCHAIN)
229
+ response = await tool_registry.call_tool("exa__answer", {"query": query, "text": True})
240
230
 
241
231
  # Extract only desired fields
242
232
  return {
243
- "answer": response.get("content"),
233
+ "answer": response.get("answer"),
244
234
  "citations": response.get("citations", []),
245
235
  }
246
236
 
@@ -288,7 +278,7 @@ async def get_valid_tools(tool_ids: list[str], registry: AgentrRegistry) -> tupl
288
278
  continue
289
279
  if app not in connected_apps and app not in unconnected:
290
280
  unconnected.add(app)
291
- text = registry.authorise_app(app_id=app)
281
+ text = await registry.authorise_app(app_id=app)
292
282
  start = text.find(":") + 1
293
283
  end = text.find(". R", start)
294
284
  url = text[start:end].strip()
@@ -5,10 +5,18 @@ from collections.abc import Sequence
5
5
  from typing import Any
6
6
 
7
7
  from langchain_core.messages import BaseMessage
8
+ from universal_mcp.types import ToolConfig
8
9
 
9
10
  MAX_CHARS = 5000
10
11
 
11
12
 
13
+ def add_tools(tool_config: ToolConfig, tools_to_add: ToolConfig):
14
+ for app_id, new_tools in tools_to_add.items():
15
+ all_tools = tool_config.get(app_id, []) + new_tools
16
+ tool_config[app_id] = list(set(all_tools))
17
+ return tool_config
18
+
19
+
12
20
  def light_copy(data):
13
21
  """
14
22
  Deep copy a dict[str, any] or Sequence[any] with string truncation.
@@ -325,35 +333,48 @@ def inject_context(
325
333
  return namespace
326
334
 
327
335
 
328
- def schema_to_signature(schema: dict, func_name="my_function") -> str:
336
+ def schema_to_signature(schema: dict, func_name: str = "my_function") -> str:
337
+ """
338
+ Convert a JSON schema into a Python-style function signature string.
339
+ Handles fields with `type`, `anyOf`, defaults, and missing metadata safely.
340
+ """
329
341
  type_map = {
330
342
  "integer": "int",
331
343
  "string": "str",
332
344
  "boolean": "bool",
333
345
  "null": "None",
346
+ "number": "float",
347
+ "array": "list",
348
+ "object": "dict",
334
349
  }
335
350
 
336
351
  params = []
337
352
  for name, meta in schema.items():
338
- # figure out type
339
- if "type" in meta:
353
+ if not isinstance(meta, dict):
354
+ typ = "Any"
355
+ elif "type" in meta:
340
356
  typ = type_map.get(meta["type"], "Any")
341
357
  elif "anyOf" in meta:
342
- types = [type_map.get(t["type"], "Any") for t in meta["anyOf"]]
343
- typ = " | ".join(set(types))
358
+ types = []
359
+ for t in meta["anyOf"]:
360
+ if not isinstance(t, dict):
361
+ continue
362
+ t_type = t.get("type")
363
+ types.append(type_map.get(t_type, "Any") if t_type else "Any")
364
+ typ = " | ".join(sorted(set(types))) if types else "Any"
344
365
  else:
345
366
  typ = "Any"
346
367
 
347
- default = meta.get("default", None)
348
- default_repr = repr(default)
349
-
350
- params.append(f"{name}: {typ} = {default_repr}")
368
+ # Handle defaults gracefully
369
+ default = meta.get("default")
370
+ if default is None:
371
+ params.append(f"{name}: {typ}")
372
+ else:
373
+ params.append(f"{name}: {typ} = {repr(default)}")
351
374
 
352
- # join into signature
353
375
  param_str = ",\n ".join(params)
354
376
  return f"def {func_name}(\n {param_str},\n):"
355
377
 
356
-
357
378
  def smart_truncate(
358
379
  output: str, max_chars_full: int = 2000, max_lines_headtail: int = 20, summary_threshold: int = 10000
359
380
  ) -> str:
@@ -386,3 +407,27 @@ def smart_truncate(
386
407
  truncated = truncated[:summary_threshold] + "\n... [output truncated to fit context] ..."
387
408
 
388
409
  return truncated
410
+
411
+
412
+ async def get_connected_apps_string(registry) -> str:
413
+ """Get a formatted string of connected applications from the registry."""
414
+ if not registry:
415
+ return ""
416
+
417
+ try:
418
+ # Get connected apps from registry
419
+ connections = await registry.list_connected_apps()
420
+ if not connections:
421
+ return "No applications are currently connected."
422
+
423
+ # Extract app names from connections
424
+ connected_app_ids = {connection["app_id"] for connection in connections}
425
+
426
+ # Format the apps list
427
+ apps_list = []
428
+ for app_id in connected_app_ids:
429
+ apps_list.append(f"- {app_id}")
430
+
431
+ return "\n".join(apps_list)
432
+ except Exception:
433
+ return "Unable to retrieve connected applications."
@@ -162,11 +162,11 @@ class LlmApp(BaseApplication):
162
162
  top_class: str = Field(..., description="The class with the highest probability.")
163
163
 
164
164
  response = (
165
- model.with_structured_output(schema=ClassificationResult, method="json_mode")
165
+ model.with_structured_output(schema=ClassificationResult)
166
166
  .with_retry(stop_after_attempt=MAX_RETRIES)
167
167
  .invoke(prompt)
168
168
  )
169
- return cast(dict[str, Any], response)
169
+ return response.model_dump_json(indent=2)
170
170
 
171
171
  def extract_data(
172
172
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: universal-mcp-agents
3
- Version: 0.1.20rc1
3
+ Version: 0.1.22
4
4
  Summary: Add your description here
5
5
  Project-URL: Homepage, https://github.com/universal-mcp/applications
6
6
  Project-URL: Repository, https://github.com/universal-mcp/applications
@@ -1,5 +1,5 @@
1
1
  universal_mcp/agents/__init__.py,sha256=Rh6vKqpwuZ2joC9nzLFHUI1G7jzbwwC3p0mgpH5qRgo,1112
2
- universal_mcp/agents/base.py,sha256=wkqa_W2R6sFgjTMXn2bWVfjarHYDsLvGbG8nN-jRp6E,7175
2
+ universal_mcp/agents/base.py,sha256=CEnY8y2as_XR311t9v2iqd4DOCSyhpOPOBDcZKNJMpc,7378
3
3
  universal_mcp/agents/cli.py,sha256=bXdpgxsOMjclm1STHJgx10ocX9EebQ11DrxH0p6KMZk,943
4
4
  universal_mcp/agents/hil.py,sha256=_5PCK6q0goGm8qylJq44aSp2MadP-yCPvhOJYKqWLMo,3808
5
5
  universal_mcp/agents/llm.py,sha256=hVRwjZs3MHl5_3BWedmurs2Jt1oZDfFX0Zj9F8KH7fk,1787
@@ -22,23 +22,23 @@ universal_mcp/agents/builder/prompts.py,sha256=8Xs6uzTUHguDRngVMLak3lkXFkk2VV_uQ
22
22
  universal_mcp/agents/builder/state.py,sha256=7DeWllxfN-yD6cd9wJ3KIgjO8TctkJvVjAbZT8W_zqk,922
23
23
  universal_mcp/agents/codeact0/__init__.py,sha256=8-fvUo1Sm6dURGI-lW-X3Kd78LqySYbb5NMkNJ4NDwg,76
24
24
  universal_mcp/agents/codeact0/__main__.py,sha256=_7qSz97YnRgYJTESkALS5_eBIGHiMjA5rhr3IAeBvVo,896
25
- universal_mcp/agents/codeact0/agent.py,sha256=qUcNniw8DyvEfpEuaWA9LDqDe0eiRPEHrZtysfdKY8k,14238
25
+ universal_mcp/agents/codeact0/agent.py,sha256=Cxdh9eJvrIfUISdFQKXnYrGc6PePrIGusSvWiALmAJo,16634
26
26
  universal_mcp/agents/codeact0/config.py,sha256=H-1woj_nhSDwf15F63WYn723y4qlRefXzGxuH81uYF0,2215
27
27
  universal_mcp/agents/codeact0/langgraph_agent.py,sha256=8nz2wq-LexImx-l1y9_f81fK72IQetnCeljwgnduNGY,420
28
- universal_mcp/agents/codeact0/llm_tool.py,sha256=q-hiqkKtjVmpyNceFoRgo7hvKh4HtQf_I1VudRUEPR0,11075
29
- universal_mcp/agents/codeact0/prompts.py,sha256=IZwiGLYISr_oXeNwhPJLNpE5wYd5zyntEdEUGH21OD8,10720
28
+ universal_mcp/agents/codeact0/llm_tool.py,sha256=-pAz04OrbZ_dJ2ueysT1qZd02DrbLY4EbU0tiuF_UNU,798
29
+ universal_mcp/agents/codeact0/prompts.py,sha256=Gk6WTx2X7IOlbC3wYtGwKWDoeUtgpkMeiyfhj6LEq2I,11221
30
30
  universal_mcp/agents/codeact0/sandbox.py,sha256=Xw4tbUV_6haYIZZvteJi6lIYsW6ni_3DCRCOkslTKgM,4459
31
- universal_mcp/agents/codeact0/state.py,sha256=B1XF5ni9RvN6p5POHkPTm-KjEx5c8wRB7O1EJM8pbSQ,1297
32
- universal_mcp/agents/codeact0/tools.py,sha256=G580vy-m_BqlHc_j0yvLV5T_2d2G0JrD_eDhvDdJ9JA,13614
33
- universal_mcp/agents/codeact0/utils.py,sha256=jAZItSd3KGDkY9PquSWRIFCj9N26K9Kt0HKQ_jwvvSQ,15944
31
+ universal_mcp/agents/codeact0/state.py,sha256=co3BZBuMIt1FP2qzgsbsLkyKbddCG1ieKyAw9TAskSU,1944
32
+ universal_mcp/agents/codeact0/tools.py,sha256=WRJKNIf6_cP_enElboEyVd-aHF-kk6aq8Bg0biZijR4,13363
33
+ universal_mcp/agents/codeact0/utils.py,sha256=PgisAxmqYIzimc4lFA1nV-R7gxkICIrtO1q0FQZ-UoY,17580
34
34
  universal_mcp/agents/shared/__main__.py,sha256=XxH5qGDpgFWfq7fwQfgKULXGiUgeTp_YKfcxftuVZq8,1452
35
35
  universal_mcp/agents/shared/prompts.py,sha256=yjP3zbbuKi87qCj21qwTTicz8TqtkKgnyGSeEjMu3ho,3761
36
36
  universal_mcp/agents/shared/tool_node.py,sha256=DC9F-Ri28Pam0u3sXWNODVgmj9PtAEUb5qP1qOoGgfs,9169
37
37
  universal_mcp/applications/filesystem/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
38
  universal_mcp/applications/filesystem/app.py,sha256=0TRjjm8YnslVRSmfkXI7qQOAlqWlD1eEn8Jm0xBeigs,5561
39
39
  universal_mcp/applications/llm/__init__.py,sha256=_XGRxN3O1--ZS5joAsPf8IlI9Qa6negsJrwJ5VJXno0,46
40
- universal_mcp/applications/llm/app.py,sha256=oqX3byvlFRmeRo4jJJxUBGy-iTDGm2fplMEKA2pcMtw,12743
40
+ universal_mcp/applications/llm/app.py,sha256=g9mK-luOLUshZzBGyQZMOHBeCSXmh2kCKir40YnsGUo,12727
41
41
  universal_mcp/applications/ui/app.py,sha256=c7OkZsO2fRtndgAzAQbKu-1xXRuRp9Kjgml57YD2NR4,9459
42
- universal_mcp_agents-0.1.20rc1.dist-info/METADATA,sha256=49lAde7iqPxxgwPAb7BZCERlIkajP0EoiiuvtA7hy24,881
43
- universal_mcp_agents-0.1.20rc1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
44
- universal_mcp_agents-0.1.20rc1.dist-info/RECORD,,
42
+ universal_mcp_agents-0.1.22.dist-info/METADATA,sha256=6f34bvvybyI6et8rvKIu-4BtFJHGmt0YZmaZutJBXuw,878
43
+ universal_mcp_agents-0.1.22.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
44
+ universal_mcp_agents-0.1.22.dist-info/RECORD,,