open-swarm 0.1.1748636259__py3-none-any.whl → 0.1.1748636295__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.
swarm/core.py ADDED
@@ -0,0 +1,411 @@
1
+ """
2
+ Swarm Core Module
3
+
4
+ This module defines the Swarm class, which orchestrates the Swarm framework by managing agents
5
+ and coordinating interactions with LLM endpoints and MCP servers. Modularized components live
6
+ in separate files for clarity.
7
+ """
8
+
9
+ import os
10
+ import copy
11
+ import json
12
+ import logging
13
+ import uuid
14
+ from typing import List, Optional, Dict, Any
15
+ from types import SimpleNamespace # Needed for stream processing
16
+
17
+ import asyncio
18
+ from openai import AsyncOpenAI
19
+
20
+ # Internal imports for modular components
21
+ from .util import merge_chunk
22
+ from .types import Agent, Response, ChatCompletionMessageToolCall # Ensure necessary types are imported
23
+ from .extensions.config.config_loader import load_llm_config
24
+ # Use mcp_utils from the extensions directory
25
+ from .extensions.mcp.mcp_utils import discover_and_merge_agent_tools, discover_and_merge_agent_resources
26
+ from .settings import DEBUG
27
+ from .utils.redact import redact_sensitive_data
28
+ # Import chat completion logic
29
+ from .llm.chat_completion import get_chat_completion, get_chat_completion_message
30
+ # Import message and tool execution logic
31
+ from .messages import ChatMessage
32
+ from .tool_executor import handle_tool_calls
33
+
34
+ # Configure module-level logging
35
+ logger = logging.getLogger(__name__)
36
+ # Set level based on DEBUG setting or default to INFO
37
+ logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
38
+ # Ensure handler is added only once
39
+ if not logger.handlers:
40
+ stream_handler = logging.StreamHandler()
41
+ formatter = logging.Formatter("[%(levelname)s] %(asctime)s - %(name)s:%(lineno)d - %(message)s")
42
+ stream_handler.setFormatter(formatter)
43
+ logger.addHandler(stream_handler)
44
+
45
+ # Constants
46
+ __CTX_VARS_NAME__ = "context_variables" # Standard name for context injection
47
+ GLOBAL_DEFAULT_MAX_CONTEXT_TOKENS = int(os.getenv("SWARM_MAX_CONTEXT_TOKENS", 8000)) # Default from env
48
+
49
+ _discovery_locks: Dict[str, asyncio.Lock] = {} # Lock for async discovery
50
+
51
+
52
+ class Swarm:
53
+ """
54
+ Core class managing agent interactions within the Swarm framework.
55
+
56
+ Attributes:
57
+ model (str): Default LLM model identifier.
58
+ temperature (float): Sampling temperature for LLM responses.
59
+ tool_choice (str): Strategy for selecting tools (e.g., "auto").
60
+ parallel_tool_calls (bool): Whether to execute tool calls in parallel.
61
+ agents (Dict[str, Agent]): Registered agents by name.
62
+ config (dict): Configuration for LLMs and MCP servers.
63
+ debug (bool): Enable detailed logging if True.
64
+ client (AsyncOpenAI): Client for OpenAI-compatible APIs.
65
+ current_llm_config (dict): Loaded config for the current default LLM.
66
+ max_context_messages (int): Max messages to keep in history.
67
+ max_context_tokens (int): Max tokens allowed in history.
68
+ """
69
+
70
+ def __init__(self, client: Optional[AsyncOpenAI] = None, config: Optional[Dict] = None, debug: bool = False):
71
+ """
72
+ Initialize the Swarm instance.
73
+
74
+ Args:
75
+ client: Optional pre-initialized AsyncOpenAI client.
76
+ config: Configuration dictionary for LLMs and MCP servers.
77
+ debug: Enable detailed logging if True.
78
+ """
79
+ self.model = os.getenv("DEFAULT_LLM", "default") # Default LLM profile name
80
+ self.temperature = 0.7 # Default temperature
81
+ self.tool_choice = "auto" # Default tool choice strategy
82
+ self.parallel_tool_calls = False # Default parallel tool call setting
83
+ self.agents: Dict[str, Agent] = {} # Dictionary to store registered agents
84
+ self.config = config or {} # Store provided or empty config
85
+ self.debug = debug or DEBUG # Use provided debug flag or global setting
86
+
87
+ # Context limits
88
+ self.max_context_messages = 50 # Default max messages
89
+ self.max_context_tokens = max(1, GLOBAL_DEFAULT_MAX_CONTEXT_TOKENS) # Ensure positive token limit
90
+ # Derived limits (optional, consider moving logic to truncation function)
91
+ # self.summarize_threshold_tokens = int(self.max_context_tokens * 0.75)
92
+ # self.keep_recent_tokens = int(self.max_context_tokens * 0.25)
93
+ logger.debug(f"Context limits set: max_messages={self.max_context_messages}, max_tokens={self.max_context_tokens}")
94
+
95
+ # Load LLM configuration for the default model
96
+ try:
97
+ self.current_llm_config = load_llm_config(self.config, self.model)
98
+ # Override API key from environment if using 'default' profile and key exists
99
+ if self.model == "default" and os.getenv("OPENAI_API_KEY"):
100
+ self.current_llm_config["api_key"] = os.getenv("OPENAI_API_KEY")
101
+ logger.debug(f"Overriding API key for model '{self.model}' from OPENAI_API_KEY env var.")
102
+ except ValueError as e:
103
+ logger.warning(f"LLM config for '{self.model}' not found: {e}. Falling back to loading 'default' profile.")
104
+ # Attempt to load the 'default' profile explicitly as fallback
105
+ try:
106
+ self.current_llm_config = load_llm_config(self.config, "default")
107
+ if os.getenv("OPENAI_API_KEY"): # Also check env var for fallback default
108
+ self.current_llm_config["api_key"] = os.getenv("OPENAI_API_KEY")
109
+ logger.debug("Overriding API key for fallback 'default' profile from OPENAI_API_KEY env var.")
110
+ except ValueError as e_default:
111
+ logger.error(f"Fallback 'default' LLM profile also not found: {e_default}. Swarm may not function correctly.")
112
+ self.current_llm_config = {} # Set empty config to avoid downstream errors
113
+
114
+ # Provide a dummy key if no real key is found and suppression is off
115
+ if not self.current_llm_config.get("api_key") and not os.getenv("SUPPRESS_DUMMY_KEY"):
116
+ self.current_llm_config["api_key"] = "sk-DUMMYKEY" # Use dummy key
117
+ logger.debug("No API key provided or found—using dummy key 'sk-DUMMYKEY'")
118
+
119
+ # Initialize AsyncOpenAI client using loaded config
120
+ # Filter out None values before passing to AsyncOpenAI constructor
121
+ client_kwargs = {
122
+ "api_key": self.current_llm_config.get("api_key"),
123
+ "base_url": self.current_llm_config.get("base_url")
124
+ }
125
+ client_kwargs = {k: v for k, v in client_kwargs.items() if v is not None}
126
+
127
+ # Log client initialization details (redacting API key)
128
+ redacted_kwargs_log = client_kwargs.copy()
129
+ if 'api_key' in redacted_kwargs_log:
130
+ redacted_kwargs_log['api_key'] = redact_sensitive_data(redacted_kwargs_log['api_key'])
131
+ logger.debug(f"Initializing AsyncOpenAI client with kwargs: {redacted_kwargs_log}")
132
+
133
+ # Use provided client or create a new one
134
+ self.client = client or AsyncOpenAI(**client_kwargs)
135
+
136
+ logger.info(f"Swarm initialized. Default LLM: '{self.model}', Max Tokens: {self.max_context_tokens}")
137
+
138
+ async def run_and_stream(
139
+ self,
140
+ agent: Agent,
141
+ messages: List[Dict[str, Any]],
142
+ context_variables: dict = {},
143
+ model_override: Optional[str] = None,
144
+ debug: bool = False,
145
+ max_turns: int = float("inf"), # Allow infinite turns by default
146
+ execute_tools: bool = True
147
+ ):
148
+ """
149
+ Run the swarm in streaming mode, yielding responses incrementally.
150
+
151
+ Args:
152
+ agent: Starting agent.
153
+ messages: Initial conversation history.
154
+ context_variables: Variables to include in the context.
155
+ model_override: Optional model to override default.
156
+ debug: If True, log detailed execution information.
157
+ max_turns: Maximum number of agent turns to process.
158
+ execute_tools: If True, execute tool calls requested by the agent.
159
+
160
+ Yields:
161
+ Dict: Streamed chunks (OpenAI format delta) or final response structure.
162
+ """
163
+ if not agent:
164
+ logger.error("Cannot run in streaming mode: Agent is None")
165
+ yield {"error": "Agent is None"} # Yield an error structure
166
+ return
167
+
168
+ effective_debug = debug or self.debug # Use local debug flag or instance default
169
+ logger.debug(f"Starting streaming run for agent '{agent.name}' with {len(messages)} messages")
170
+
171
+ active_agent = agent
172
+ # Deep copy context and history to avoid modifying originals
173
+ context_variables = copy.deepcopy(context_variables)
174
+ history = copy.deepcopy(messages)
175
+ init_len = len(messages) # Store initial length to return only new messages
176
+
177
+ # Ensure context_variables is a dict and set active agent name
178
+ if not isinstance(context_variables, dict):
179
+ logger.warning(f"Invalid context_variables type: {type(context_variables)}. Using empty dict.")
180
+ context_variables = {}
181
+ context_variables["active_agent_name"] = active_agent.name
182
+
183
+ # Discover tools and resources for the initial agent if not already done
184
+ # Use flags to avoid repeated discovery within the same run instance
185
+ if not hasattr(active_agent, '_tools_discovered'):
186
+ active_agent.functions = await discover_and_merge_agent_tools(active_agent, self.config, effective_debug)
187
+ active_agent._tools_discovered = True
188
+ if not hasattr(active_agent, '_resources_discovered'):
189
+ active_agent.resources = await discover_and_merge_agent_resources(active_agent, self.config, effective_debug)
190
+ active_agent._resources_discovered = True
191
+
192
+
193
+ turn = 0
194
+ while turn < max_turns:
195
+ turn += 1
196
+ logger.debug(f"Turn {turn} starting with agent '{active_agent.name}'.")
197
+ # Prepare message object for accumulating response
198
+ message = ChatMessage(sender=active_agent.name)
199
+
200
+ # Get chat completion stream
201
+ completion_stream = await get_chat_completion(
202
+ self.client, active_agent, history, context_variables, self.current_llm_config,
203
+ self.max_context_tokens, self.max_context_messages, model_override, stream=True, debug=effective_debug
204
+ )
205
+
206
+ yield {"delim": "start"} # Signal start of response stream
207
+ current_tool_calls_data = [] # Accumulate tool call data from chunks
208
+ async for chunk in completion_stream:
209
+ if not chunk.choices: continue # Skip empty chunks
210
+ delta = chunk.choices[0].delta
211
+ # Update message object with content from the chunk
212
+ merge_chunk(message, delta)
213
+ # Accumulate tool call chunks
214
+ if delta.tool_calls:
215
+ for tc_chunk in delta.tool_calls:
216
+ # Find or create tool call entry in accumulator
217
+ found = False
218
+ for existing_tc_data in current_tool_calls_data:
219
+ if existing_tc_data['index'] == tc_chunk.index:
220
+ # Merge chunk into existing tool call data
221
+ if tc_chunk.id: existing_tc_data['id'] = existing_tc_data.get('id', "") + tc_chunk.id
222
+ if tc_chunk.type: existing_tc_data['type'] = tc_chunk.type
223
+ if tc_chunk.function:
224
+ if 'function' not in existing_tc_data: existing_tc_data['function'] = {'name': "", 'arguments': ""}
225
+ func_data = existing_tc_data['function']
226
+ if tc_chunk.function.name: func_data['name'] = func_data.get('name', "") + tc_chunk.function.name
227
+ if tc_chunk.function.arguments: func_data['arguments'] = func_data.get('arguments', "") + tc_chunk.function.arguments
228
+ found = True
229
+ break
230
+ if not found:
231
+ # Add new tool call data initialized from chunk
232
+ new_tc_data = {'index': tc_chunk.index}
233
+ if tc_chunk.id: new_tc_data['id'] = tc_chunk.id
234
+ if tc_chunk.type: new_tc_data['type'] = tc_chunk.type
235
+ if tc_chunk.function:
236
+ new_tc_data['function'] = {}
237
+ if tc_chunk.function.name: new_tc_data['function']['name'] = tc_chunk.function.name
238
+ if tc_chunk.function.arguments: new_tc_data['function']['arguments'] = tc_chunk.function.arguments
239
+ current_tool_calls_data.append(new_tc_data)
240
+
241
+ yield delta # Yield the raw chunk
242
+ yield {"delim": "end"} # Signal end of response stream
243
+
244
+ # Finalize tool calls for the completed message from accumulated data
245
+ if current_tool_calls_data:
246
+ message.tool_calls = [
247
+ ChatCompletionMessageToolCall(**tc_data) # Instantiate objects from data
248
+ for tc_data in current_tool_calls_data
249
+ # Ensure essential keys are present before instantiation
250
+ if 'id' in tc_data and 'type' in tc_data and 'function' in tc_data
251
+ ]
252
+ else:
253
+ message.tool_calls = None
254
+
255
+ # Add the fully formed assistant message (potentially with tool calls) to history
256
+ history.append(json.loads(message.model_dump_json()))
257
+
258
+ # --- Tool Execution Phase ---
259
+ if message.tool_calls and execute_tools:
260
+ logger.debug(f"Turn {turn}: Agent '{active_agent.name}' requested {len(message.tool_calls)} tool calls.")
261
+ # Execute tools
262
+ partial_response = await handle_tool_calls(message.tool_calls, active_agent.functions, context_variables, effective_debug)
263
+ # Add tool results to history
264
+ history.extend(partial_response.messages)
265
+ # Update context variables from tool results
266
+ context_variables.update(partial_response.context_variables)
267
+
268
+ # Check for agent handoff
269
+ if partial_response.agent and partial_response.agent != active_agent:
270
+ active_agent = partial_response.agent
271
+ context_variables["active_agent_name"] = active_agent.name # Update context
272
+ logger.debug(f"Turn {turn}: Agent handoff to '{active_agent.name}' detected via tool call.")
273
+ # Discover tools/resources for the new agent if needed
274
+ if not hasattr(active_agent, '_tools_discovered'):
275
+ active_agent.functions = await discover_and_merge_agent_tools(active_agent, self.config, effective_debug)
276
+ active_agent._tools_discovered = True
277
+ if not hasattr(active_agent, '_resources_discovered'):
278
+ active_agent.resources = await discover_and_merge_agent_resources(active_agent, self.config, effective_debug)
279
+ active_agent._resources_discovered = True
280
+
281
+ # Continue the loop to get the next response from the (potentially new) agent
282
+ logger.debug(f"Turn {turn}: Continuing loop after tool execution.")
283
+ continue # Go to the start of the while loop for the next turn
284
+
285
+ else: # If no tool calls requested or execute_tools is False
286
+ logger.debug(f"Turn {turn}: No tool calls requested or execution disabled. Ending run.")
287
+ break # End the run
288
+
289
+ # After loop finishes (max_turns reached or break)
290
+ logger.debug(f"Streaming run completed after {turn} turns. Total history size: {len(history)} messages.")
291
+ # Yield the final aggregated response structure
292
+ yield {"response": Response(messages=history[init_len:], agent=active_agent, context_variables=context_variables)}
293
+
294
+ async def run(
295
+ self,
296
+ agent: Agent,
297
+ messages: List[Dict[str, Any]],
298
+ context_variables: dict = {},
299
+ model_override: Optional[str] = None,
300
+ stream: bool = False, # Default to non-streaming
301
+ debug: bool = False,
302
+ max_turns: int = float("inf"), # Allow infinite turns
303
+ execute_tools: bool = True
304
+ ) -> Response:
305
+ """
306
+ Execute the swarm run in streaming or non-streaming mode.
307
+
308
+ Args:
309
+ agent: Starting agent.
310
+ messages: Initial conversation history.
311
+ context_variables: Variables to include in the context.
312
+ model_override: Optional model to override default.
313
+ stream: If True, return an async generator; otherwise, return a single Response object.
314
+ debug: If True, log detailed execution information.
315
+ max_turns: Maximum number of agent turns to process.
316
+ execute_tools: If True, execute tool calls requested by the agent.
317
+
318
+ Returns:
319
+ Response or AsyncGenerator: Final response object, or an async generator if stream=True.
320
+ """
321
+ if not agent:
322
+ logger.error("Cannot run: Agent is None")
323
+ raise ValueError("Agent is required")
324
+
325
+ effective_debug = debug or self.debug
326
+ logger.debug(f"Starting run for agent '{agent.name}' with {len(messages)} messages, stream={stream}")
327
+
328
+ # Handle streaming case by returning the generator
329
+ if stream:
330
+ # We return the async generator directly when stream=True
331
+ return self.run_and_stream(
332
+ agent=agent, messages=messages, context_variables=context_variables,
333
+ model_override=model_override, debug=effective_debug, max_turns=max_turns, execute_tools=execute_tools
334
+ )
335
+
336
+ # --- Non-Streaming Execution ---
337
+ active_agent = agent
338
+ context_variables = copy.deepcopy(context_variables)
339
+ history = copy.deepcopy(messages)
340
+ init_len = len(messages)
341
+
342
+ if not isinstance(context_variables, dict):
343
+ logger.warning(f"Invalid context_variables type: {type(context_variables)}. Using empty dict.")
344
+ context_variables = {}
345
+ context_variables["active_agent_name"] = active_agent.name
346
+
347
+ # Discover tools and resources for initial agent if not done
348
+ if not hasattr(active_agent, '_tools_discovered'):
349
+ active_agent.functions = await discover_and_merge_agent_tools(active_agent, self.config, effective_debug)
350
+ active_agent._tools_discovered = True
351
+ if not hasattr(active_agent, '_resources_discovered'):
352
+ active_agent.resources = await discover_and_merge_agent_resources(active_agent, self.config, effective_debug)
353
+ active_agent._resources_discovered = True
354
+
355
+ turn = 0
356
+ while turn < max_turns:
357
+ turn += 1
358
+ logger.debug(f"Turn {turn} starting with agent '{active_agent.name}'.")
359
+ # Get a single, complete chat completion message
360
+ message = await get_chat_completion_message(
361
+ self.client, active_agent, history, context_variables, self.current_llm_config,
362
+ self.max_context_tokens, self.max_context_messages, model_override, stream=False, debug=effective_debug
363
+ )
364
+ # Ensure message has sender info (might be redundant if get_chat_completion_message does it)
365
+ message.sender = active_agent.name
366
+ # Add the assistant's response to history
367
+ history.append(json.loads(message.model_dump_json()))
368
+
369
+ # --- Tool Execution Phase ---
370
+ if message.tool_calls and execute_tools:
371
+ logger.debug(f"Turn {turn}: Agent '{active_agent.name}' requested {len(message.tool_calls)} tool calls.")
372
+ # Execute tools
373
+ partial_response = await handle_tool_calls(message.tool_calls, active_agent.functions, context_variables, effective_debug)
374
+ # Add tool results to history
375
+ history.extend(partial_response.messages)
376
+ # Update context variables
377
+ context_variables.update(partial_response.context_variables)
378
+
379
+ # Check for agent handoff
380
+ if partial_response.agent and partial_response.agent != active_agent:
381
+ active_agent = partial_response.agent
382
+ context_variables["active_agent_name"] = active_agent.name # Update context
383
+ logger.debug(f"Turn {turn}: Agent handoff to '{active_agent.name}' detected.")
384
+ # Discover tools/resources for the new agent if needed
385
+ if not hasattr(active_agent, '_tools_discovered'):
386
+ active_agent.functions = await discover_and_merge_agent_tools(active_agent, self.config, effective_debug)
387
+ active_agent._tools_discovered = True
388
+ if not hasattr(active_agent, '_resources_discovered'):
389
+ active_agent.resources = await discover_and_merge_agent_resources(active_agent, self.config, effective_debug)
390
+ active_agent._resources_discovered = True
391
+
392
+ # Continue loop for next turn if tools were called
393
+ logger.debug(f"Turn {turn}: Continuing loop after tool execution.")
394
+ continue
395
+
396
+ else: # No tool calls or execution disabled
397
+ logger.debug(f"Turn {turn}: No tool calls requested or execution disabled. Ending run.")
398
+ break # End the run
399
+
400
+ # After loop finishes
401
+ logger.debug(f"Non-streaming run completed after {turn} turns. Total history size: {len(history)} messages.")
402
+ # Create the final Response object containing only the new messages
403
+ final_response = Response(
404
+ id=f"response-{uuid.uuid4()}", # Generate unique ID
405
+ messages=history[init_len:], # Only messages added during this run
406
+ agent=active_agent, # The agent that produced the last message
407
+ context_variables=context_variables # Final context state
408
+ )
409
+ if effective_debug:
410
+ logger.debug(f"Final Response ID: {final_response.id}, Messages count: {len(final_response.messages)}")
411
+ return final_response
@@ -1,21 +1,45 @@
1
1
  """
2
- agent_utils.py
3
-
4
- Utility functions for agent operations used in blueprints.
5
- This module has been updated to remove dependency on swarm.types;
6
- instead, it now imports Agent from the openai-agents SDK.
2
+ Agent utility functions for Swarm blueprints
7
3
  """
8
4
 
9
- from agents.agent import Agent # Updated import
5
+ import logging
6
+ import os
7
+ from typing import Dict, List, Any, Callable, Optional
8
+ import asyncio
9
+ from swarm.types import Agent
10
+
11
+ logger = logging.getLogger(__name__)
10
12
 
11
13
  def get_agent_name(agent: Agent) -> str:
12
- """
13
- Returns the name of the agent.
14
- """
15
- return agent.name
16
-
17
- def initialize_agents(blueprint) -> dict:
18
- """
19
- Initializes agents by calling the blueprint's create_agents() method.
20
- """
21
- return blueprint.create_agents()
14
+ """Extract an agent's name, defaulting to its class name if not explicitly set."""
15
+ return getattr(agent, 'name', agent.__class__.__name__)
16
+
17
+ async def discover_tools_for_agent(agent: Agent, blueprint: Any) -> List[Any]:
18
+ """Asynchronously discover tools available for an agent within a blueprint."""
19
+ return getattr(blueprint, '_discovered_tools', {}).get(get_agent_name(agent), [])
20
+
21
+ async def discover_resources_for_agent(agent: Agent, blueprint: Any) -> List[Any]:
22
+ """Asynchronously discover resources available for an agent within a blueprint."""
23
+ return getattr(blueprint, '_discovered_resources', {}).get(get_agent_name(agent), [])
24
+
25
+ def initialize_agents(blueprint: Any) -> None:
26
+ """Initialize agents defined in the blueprint's create_agents method."""
27
+ if not callable(getattr(blueprint, 'create_agents', None)):
28
+ logger.error(f"Blueprint {blueprint.__class__.__name__} has no callable create_agents method.")
29
+ return
30
+
31
+ agents = blueprint.create_agents()
32
+ if not isinstance(agents, dict):
33
+ logger.error(f"Blueprint {blueprint.__class__.__name__}.create_agents must return a dict, got {type(agents)}")
34
+ return
35
+
36
+ if hasattr(blueprint, 'swarm') and hasattr(blueprint.swarm, 'agents'):
37
+ blueprint.swarm.agents.update(agents)
38
+ else:
39
+ logger.error("Blueprint or its swarm instance lacks an 'agents' attribute to update.")
40
+ return
41
+
42
+ if not blueprint.starting_agent and agents:
43
+ first_agent_name = next(iter(agents.keys()))
44
+ blueprint.starting_agent = agents[first_agent_name]
45
+ logger.debug(f"Set default starting agent: {first_agent_name}")