Spaces:
Configuration error
Configuration error
add multiple agents
Browse filesSigned-off-by: Jintao Zhang <zhangjintao9020@gmail.com>
- src/amcp/__init__.py +56 -2
- src/amcp/agent.py +251 -5
- src/amcp/agent_spec.py +17 -0
- src/amcp/cli.py +89 -15
- src/amcp/config.py +22 -0
- src/amcp/message_queue.py +531 -0
- src/amcp/multi_agent.py +375 -0
- tests/test_agent_spec.py +245 -5
- tests/test_message_queue.py +372 -0
- tests/test_multi_agent.py +288 -0
src/amcp/__init__.py
CHANGED
|
@@ -1,2 +1,56 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AMCP - Lego-style Coding Agent CLI with Multi-Agent Support."""
|
| 2 |
+
|
| 3 |
+
__all__ = [
|
| 4 |
+
"__version__",
|
| 5 |
+
# Agent classes and functions
|
| 6 |
+
"Agent",
|
| 7 |
+
"AgentExecutionError",
|
| 8 |
+
"MaxStepsReached",
|
| 9 |
+
"BusyError",
|
| 10 |
+
"create_agent_by_name",
|
| 11 |
+
"create_agent_from_config",
|
| 12 |
+
"create_subagent",
|
| 13 |
+
"list_available_agents",
|
| 14 |
+
"list_primary_agents",
|
| 15 |
+
"list_subagent_types",
|
| 16 |
+
# Multi-agent system
|
| 17 |
+
"AgentMode",
|
| 18 |
+
"AgentConfig",
|
| 19 |
+
"AgentRegistry",
|
| 20 |
+
"get_agent_registry",
|
| 21 |
+
"get_agent_config",
|
| 22 |
+
# Message queue
|
| 23 |
+
"MessagePriority",
|
| 24 |
+
"QueuedMessage",
|
| 25 |
+
"MessageQueueManager",
|
| 26 |
+
"get_message_queue_manager",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
__version__ = "0.2.0"
|
| 30 |
+
|
| 31 |
+
# Lazy imports for cleaner namespace
|
| 32 |
+
from .agent import (
|
| 33 |
+
Agent,
|
| 34 |
+
AgentExecutionError,
|
| 35 |
+
BusyError,
|
| 36 |
+
MaxStepsReached,
|
| 37 |
+
create_agent_by_name,
|
| 38 |
+
create_agent_from_config,
|
| 39 |
+
create_subagent,
|
| 40 |
+
list_available_agents,
|
| 41 |
+
list_primary_agents,
|
| 42 |
+
list_subagent_types,
|
| 43 |
+
)
|
| 44 |
+
from .message_queue import (
|
| 45 |
+
MessagePriority,
|
| 46 |
+
MessageQueueManager,
|
| 47 |
+
QueuedMessage,
|
| 48 |
+
get_message_queue_manager,
|
| 49 |
+
)
|
| 50 |
+
from .multi_agent import (
|
| 51 |
+
AgentConfig,
|
| 52 |
+
AgentMode,
|
| 53 |
+
AgentRegistry,
|
| 54 |
+
get_agent_config,
|
| 55 |
+
get_agent_registry,
|
| 56 |
+
)
|
src/amcp/agent.py
CHANGED
|
@@ -14,6 +14,8 @@ from .chat import _make_client, _resolve_api_key, _resolve_base_url
|
|
| 14 |
from .compaction import Compactor
|
| 15 |
from .config import load_config
|
| 16 |
from .mcp_client import call_mcp_tool, list_mcp_tools
|
|
|
|
|
|
|
| 17 |
from .tools import ToolRegistry
|
| 18 |
from .ui import LiveUI
|
| 19 |
|
|
@@ -30,6 +32,12 @@ class MaxStepsReached(Exception):
|
|
| 30 |
pass
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
class Agent:
|
| 34 |
"""
|
| 35 |
Enhanced agent execution engine with tool calling and conversation management.
|
|
@@ -222,7 +230,13 @@ class Agent:
|
|
| 222 |
}
|
| 223 |
|
| 224 |
async def run(
|
| 225 |
-
self,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
) -> str:
|
| 227 |
"""
|
| 228 |
Run the agent with the given user input.
|
|
@@ -232,6 +246,8 @@ class Agent:
|
|
| 232 |
work_dir: Working directory for context
|
| 233 |
stream: Whether to stream responses
|
| 234 |
show_progress: Whether to show progress indicators
|
|
|
|
|
|
|
| 235 |
|
| 236 |
Returns:
|
| 237 |
Agent's response
|
|
@@ -239,6 +255,77 @@ class Agent:
|
|
| 239 |
Raises:
|
| 240 |
AgentExecutionError: If execution fails
|
| 241 |
MaxStepsReached: If max steps exceeded
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
"""
|
| 243 |
# Save user input to conversation history immediately to preserve context
|
| 244 |
self.conversation_history.append({"role": "user", "content": user_input})
|
|
@@ -247,10 +334,6 @@ class Agent:
|
|
| 247 |
with self._create_progress_context(show_progress) as status:
|
| 248 |
status.update(f"[bold]Agent {self.name}[/bold] thinking...")
|
| 249 |
|
| 250 |
-
# DO NOT reset current conversation tool calls counter here
|
| 251 |
-
# It should persist across multiple calls within the same session
|
| 252 |
-
# Reset only when explicitly requested or when starting a completely new session
|
| 253 |
-
|
| 254 |
# Prepare messages with conversation history
|
| 255 |
system_prompt = self._get_system_prompt(work_dir)
|
| 256 |
messages = [{"role": "system", "content": system_prompt}]
|
|
@@ -301,6 +384,27 @@ class Agent:
|
|
| 301 |
self.console.print(f"[red]Agent execution failed:[/red] {e}")
|
| 302 |
raise AgentExecutionError(f"Agent execution failed: {e}") from e
|
| 303 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
async def _build_tools(self) -> list[dict[str, Any]]:
|
| 305 |
"""Build list of available tools."""
|
| 306 |
tools = []
|
|
@@ -626,9 +730,151 @@ class Agent:
|
|
| 626 |
"""Get summary of agent execution."""
|
| 627 |
return {
|
| 628 |
"agent_name": self.name,
|
|
|
|
| 629 |
"steps_taken": self.step_count,
|
| 630 |
"max_steps": self.max_steps,
|
| 631 |
"tools_called": len(self.tool_calls_history),
|
| 632 |
"tool_calls": self.tool_calls_history,
|
| 633 |
"context_vars": self._get_context_vars(),
|
|
|
|
|
|
|
|
|
|
| 634 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from .compaction import Compactor
|
| 15 |
from .config import load_config
|
| 16 |
from .mcp_client import call_mcp_tool, list_mcp_tools
|
| 17 |
+
from .message_queue import MessagePriority, get_message_queue_manager
|
| 18 |
+
from .multi_agent import AgentMode
|
| 19 |
from .tools import ToolRegistry
|
| 20 |
from .ui import LiveUI
|
| 21 |
|
|
|
|
| 32 |
pass
|
| 33 |
|
| 34 |
|
| 35 |
+
class BusyError(Exception):
|
| 36 |
+
"""Raised when agent session is busy processing another request."""
|
| 37 |
+
|
| 38 |
+
pass
|
| 39 |
+
|
| 40 |
+
|
| 41 |
class Agent:
|
| 42 |
"""
|
| 43 |
Enhanced agent execution engine with tool calling and conversation management.
|
|
|
|
| 230 |
}
|
| 231 |
|
| 232 |
async def run(
|
| 233 |
+
self,
|
| 234 |
+
user_input: str,
|
| 235 |
+
work_dir: Path | None = None,
|
| 236 |
+
stream: bool = True,
|
| 237 |
+
show_progress: bool = True,
|
| 238 |
+
priority: MessagePriority = MessagePriority.NORMAL,
|
| 239 |
+
queue_if_busy: bool = True,
|
| 240 |
) -> str:
|
| 241 |
"""
|
| 242 |
Run the agent with the given user input.
|
|
|
|
| 246 |
work_dir: Working directory for context
|
| 247 |
stream: Whether to stream responses
|
| 248 |
show_progress: Whether to show progress indicators
|
| 249 |
+
priority: Message priority (for queuing)
|
| 250 |
+
queue_if_busy: Whether to queue the message if session is busy
|
| 251 |
|
| 252 |
Returns:
|
| 253 |
Agent's response
|
|
|
|
| 255 |
Raises:
|
| 256 |
AgentExecutionError: If execution fails
|
| 257 |
MaxStepsReached: If max steps exceeded
|
| 258 |
+
BusyError: If session is busy and queue_if_busy is False
|
| 259 |
+
"""
|
| 260 |
+
queue_manager = get_message_queue_manager()
|
| 261 |
+
|
| 262 |
+
# Check if session is busy
|
| 263 |
+
if queue_manager.is_busy(self.session_id):
|
| 264 |
+
if queue_if_busy:
|
| 265 |
+
# Queue the message for later processing
|
| 266 |
+
await queue_manager.enqueue(
|
| 267 |
+
session_id=self.session_id,
|
| 268 |
+
prompt=user_input,
|
| 269 |
+
priority=priority,
|
| 270 |
+
work_dir=str(work_dir) if work_dir else None,
|
| 271 |
+
stream=stream,
|
| 272 |
+
show_progress=show_progress,
|
| 273 |
+
)
|
| 274 |
+
self.console.print(
|
| 275 |
+
f"[dim]Message queued ({queue_manager.queued_count(self.session_id)} in queue)[/dim]"
|
| 276 |
+
)
|
| 277 |
+
return "[Message queued for later processing]"
|
| 278 |
+
else:
|
| 279 |
+
raise BusyError(f"Session {self.session_id} is busy processing another request")
|
| 280 |
+
|
| 281 |
+
# Acquire session lock
|
| 282 |
+
acquired = await queue_manager.acquire(self.session_id)
|
| 283 |
+
if not acquired:
|
| 284 |
+
# Race condition - queue it
|
| 285 |
+
if queue_if_busy:
|
| 286 |
+
await queue_manager.enqueue(
|
| 287 |
+
session_id=self.session_id,
|
| 288 |
+
prompt=user_input,
|
| 289 |
+
priority=priority,
|
| 290 |
+
)
|
| 291 |
+
return "[Message queued for later processing]"
|
| 292 |
+
else:
|
| 293 |
+
raise BusyError(f"Session {self.session_id} is busy processing another request")
|
| 294 |
+
|
| 295 |
+
try:
|
| 296 |
+
# Process the current message
|
| 297 |
+
result = await self._process_message(user_input, work_dir, stream, show_progress)
|
| 298 |
+
|
| 299 |
+
# Process any queued messages
|
| 300 |
+
while True:
|
| 301 |
+
next_msg = await queue_manager.dequeue(self.session_id)
|
| 302 |
+
if not next_msg:
|
| 303 |
+
break
|
| 304 |
+
|
| 305 |
+
# Process queued message
|
| 306 |
+
self.console.print(f"[dim]Processing queued message...[/dim]")
|
| 307 |
+
queued_work_dir = Path(next_msg.metadata.get("work_dir")) if next_msg.metadata.get("work_dir") else work_dir
|
| 308 |
+
await self._process_message(
|
| 309 |
+
next_msg.prompt,
|
| 310 |
+
queued_work_dir,
|
| 311 |
+
next_msg.metadata.get("stream", stream),
|
| 312 |
+
next_msg.metadata.get("show_progress", show_progress),
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
return result
|
| 316 |
+
|
| 317 |
+
finally:
|
| 318 |
+
# Always release the session lock
|
| 319 |
+
queue_manager.release(self.session_id)
|
| 320 |
+
|
| 321 |
+
async def _process_message(
|
| 322 |
+
self, user_input: str, work_dir: Path | None, stream: bool, show_progress: bool
|
| 323 |
+
) -> str:
|
| 324 |
+
"""
|
| 325 |
+
Process a single message (internal implementation).
|
| 326 |
+
|
| 327 |
+
This is the core message processing logic, extracted from run()
|
| 328 |
+
to support queue-based processing.
|
| 329 |
"""
|
| 330 |
# Save user input to conversation history immediately to preserve context
|
| 331 |
self.conversation_history.append({"role": "user", "content": user_input})
|
|
|
|
| 334 |
with self._create_progress_context(show_progress) as status:
|
| 335 |
status.update(f"[bold]Agent {self.name}[/bold] thinking...")
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
# Prepare messages with conversation history
|
| 338 |
system_prompt = self._get_system_prompt(work_dir)
|
| 339 |
messages = [{"role": "system", "content": system_prompt}]
|
|
|
|
| 384 |
self.console.print(f"[red]Agent execution failed:[/red] {e}")
|
| 385 |
raise AgentExecutionError(f"Agent execution failed: {e}") from e
|
| 386 |
|
| 387 |
+
def is_busy(self) -> bool:
|
| 388 |
+
"""Check if this agent's session is currently busy."""
|
| 389 |
+
return get_message_queue_manager().is_busy(self.session_id)
|
| 390 |
+
|
| 391 |
+
def queued_count(self) -> int:
|
| 392 |
+
"""Get the number of queued messages for this session."""
|
| 393 |
+
return get_message_queue_manager().queued_count(self.session_id)
|
| 394 |
+
|
| 395 |
+
def queued_prompts(self) -> list[str]:
|
| 396 |
+
"""Get list of queued prompts for this session."""
|
| 397 |
+
return get_message_queue_manager().queued_prompts(self.session_id)
|
| 398 |
+
|
| 399 |
+
async def clear_queue(self) -> int:
|
| 400 |
+
"""Clear all queued messages for this session."""
|
| 401 |
+
return await get_message_queue_manager().clear_queue(self.session_id)
|
| 402 |
+
|
| 403 |
+
def get_queue_status(self) -> dict[str, Any]:
|
| 404 |
+
"""Get queue status for this session."""
|
| 405 |
+
return get_message_queue_manager().get_queue_status(self.session_id)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
async def _build_tools(self) -> list[dict[str, Any]]:
|
| 409 |
"""Build list of available tools."""
|
| 410 |
tools = []
|
|
|
|
| 730 |
"""Get summary of agent execution."""
|
| 731 |
return {
|
| 732 |
"agent_name": self.name,
|
| 733 |
+
"agent_mode": self.agent_spec.mode.value,
|
| 734 |
"steps_taken": self.step_count,
|
| 735 |
"max_steps": self.max_steps,
|
| 736 |
"tools_called": len(self.tool_calls_history),
|
| 737 |
"tool_calls": self.tool_calls_history,
|
| 738 |
"context_vars": self._get_context_vars(),
|
| 739 |
+
"can_delegate": self.agent_spec.can_delegate,
|
| 740 |
+
"is_busy": self.is_busy(),
|
| 741 |
+
"queued_count": self.queued_count(),
|
| 742 |
}
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
# Factory functions for creating agents from multi-agent configurations
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
def create_agent_from_config(
|
| 749 |
+
config: "AgentConfig",
|
| 750 |
+
session_id: str | None = None,
|
| 751 |
+
) -> Agent:
|
| 752 |
+
"""
|
| 753 |
+
Create an Agent from an AgentConfig.
|
| 754 |
+
|
| 755 |
+
This is the primary way to instantiate agents using the multi-agent system.
|
| 756 |
+
|
| 757 |
+
Args:
|
| 758 |
+
config: AgentConfig from the multi_agent module
|
| 759 |
+
session_id: Optional session ID for conversation persistence
|
| 760 |
+
|
| 761 |
+
Returns:
|
| 762 |
+
Configured Agent instance
|
| 763 |
+
"""
|
| 764 |
+
from .multi_agent import AgentConfig
|
| 765 |
+
|
| 766 |
+
# Convert AgentConfig to ResolvedAgentSpec
|
| 767 |
+
from .agent_spec import ResolvedAgentSpec
|
| 768 |
+
|
| 769 |
+
spec = ResolvedAgentSpec(
|
| 770 |
+
name=config.name,
|
| 771 |
+
description=config.description,
|
| 772 |
+
mode=config.mode,
|
| 773 |
+
system_prompt=config.system_prompt,
|
| 774 |
+
tools=config.tools,
|
| 775 |
+
exclude_tools=config.excluded_tools,
|
| 776 |
+
max_steps=config.max_steps,
|
| 777 |
+
model="", # Use default from config
|
| 778 |
+
base_url="", # Use default from config
|
| 779 |
+
can_delegate=config.can_delegate,
|
| 780 |
+
)
|
| 781 |
+
|
| 782 |
+
return Agent(agent_spec=spec, session_id=session_id)
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def create_agent_by_name(
|
| 786 |
+
name: str,
|
| 787 |
+
session_id: str | None = None,
|
| 788 |
+
) -> Agent:
|
| 789 |
+
"""
|
| 790 |
+
Create an Agent by looking up its name in the registry.
|
| 791 |
+
|
| 792 |
+
Args:
|
| 793 |
+
name: Name of the agent in the registry (e.g., "coder", "explorer", "planner")
|
| 794 |
+
session_id: Optional session ID for conversation persistence
|
| 795 |
+
|
| 796 |
+
Returns:
|
| 797 |
+
Configured Agent instance
|
| 798 |
+
|
| 799 |
+
Raises:
|
| 800 |
+
ValueError: If agent name is not found in registry
|
| 801 |
+
"""
|
| 802 |
+
from .multi_agent import get_agent_config
|
| 803 |
+
|
| 804 |
+
config = get_agent_config(name)
|
| 805 |
+
if config is None:
|
| 806 |
+
from .multi_agent import get_agent_registry
|
| 807 |
+
available = get_agent_registry().list_agents()
|
| 808 |
+
raise ValueError(f"Unknown agent: {name}. Available agents: {', '.join(available)}")
|
| 809 |
+
|
| 810 |
+
return create_agent_from_config(config, session_id)
|
| 811 |
+
|
| 812 |
+
|
| 813 |
+
def create_subagent(
|
| 814 |
+
parent_agent: Agent,
|
| 815 |
+
task_description: str,
|
| 816 |
+
tools: list[str] | None = None,
|
| 817 |
+
) -> Agent:
|
| 818 |
+
"""
|
| 819 |
+
Create a subagent for a specific task.
|
| 820 |
+
|
| 821 |
+
This creates a new agent that inherits the session from the parent
|
| 822 |
+
but has a focused task and possibly restricted tools.
|
| 823 |
+
|
| 824 |
+
Args:
|
| 825 |
+
parent_agent: The parent agent creating this subagent
|
| 826 |
+
task_description: Description of the task for the subagent
|
| 827 |
+
tools: Optional list of tools for the subagent
|
| 828 |
+
|
| 829 |
+
Returns:
|
| 830 |
+
New Agent configured as a subagent
|
| 831 |
+
|
| 832 |
+
Raises:
|
| 833 |
+
ValueError: If parent agent cannot delegate
|
| 834 |
+
"""
|
| 835 |
+
if not parent_agent.agent_spec.can_delegate:
|
| 836 |
+
raise ValueError(f"Agent '{parent_agent.name}' cannot delegate to subagents")
|
| 837 |
+
|
| 838 |
+
from .multi_agent import create_subagent_config
|
| 839 |
+
|
| 840 |
+
config = create_subagent_config(
|
| 841 |
+
parent_name=parent_agent.name,
|
| 842 |
+
task_description=task_description,
|
| 843 |
+
tools=tools,
|
| 844 |
+
)
|
| 845 |
+
|
| 846 |
+
# Create subagent with a new session (isolated from parent)
|
| 847 |
+
return create_agent_from_config(config)
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
def list_available_agents() -> list[str]:
|
| 851 |
+
"""
|
| 852 |
+
List all available agent names.
|
| 853 |
+
|
| 854 |
+
Returns:
|
| 855 |
+
List of agent names from the registry
|
| 856 |
+
"""
|
| 857 |
+
from .multi_agent import get_agent_registry
|
| 858 |
+
return get_agent_registry().list_agents()
|
| 859 |
+
|
| 860 |
+
|
| 861 |
+
def list_primary_agents() -> list[str]:
|
| 862 |
+
"""
|
| 863 |
+
List all primary (main) agent names.
|
| 864 |
+
|
| 865 |
+
Returns:
|
| 866 |
+
List of primary agent names
|
| 867 |
+
"""
|
| 868 |
+
from .multi_agent import get_agent_registry
|
| 869 |
+
return get_agent_registry().list_primary_agents()
|
| 870 |
+
|
| 871 |
+
|
| 872 |
+
def list_subagent_types() -> list[str]:
|
| 873 |
+
"""
|
| 874 |
+
List all available subagent types.
|
| 875 |
+
|
| 876 |
+
Returns:
|
| 877 |
+
List of subagent names
|
| 878 |
+
"""
|
| 879 |
+
from .multi_agent import get_agent_registry
|
| 880 |
+
return get_agent_registry().list_subagents()
|
src/amcp/agent_spec.py
CHANGED
|
@@ -7,6 +7,7 @@ import yaml
|
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
|
| 9 |
from .config import load_config as load_app_config
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
class AgentSpecError(Exception):
|
|
@@ -20,6 +21,7 @@ class AgentSpec(BaseModel):
|
|
| 20 |
|
| 21 |
name: str = Field(description="Agent name")
|
| 22 |
description: str = Field(default="", description="Agent description")
|
|
|
|
| 23 |
system_prompt: str = Field(description="System prompt for the agent")
|
| 24 |
system_prompt_template: str = Field(default="", description="System prompt template with variables")
|
| 25 |
system_prompt_vars: dict[str, str] = Field(default_factory=dict, description="Variables for system prompt template")
|
|
@@ -28,6 +30,7 @@ class AgentSpec(BaseModel):
|
|
| 28 |
max_steps: int = Field(default=5, description="Maximum tool execution steps")
|
| 29 |
model: str = Field(default="", description="Preferred model name")
|
| 30 |
base_url: str = Field(default="", description="Preferred base URL")
|
|
|
|
| 31 |
|
| 32 |
class Config:
|
| 33 |
extra = "allow"
|
|
@@ -39,12 +42,14 @@ class ResolvedAgentSpec:
|
|
| 39 |
|
| 40 |
name: str
|
| 41 |
description: str
|
|
|
|
| 42 |
system_prompt: str
|
| 43 |
tools: list[str]
|
| 44 |
exclude_tools: list[str]
|
| 45 |
max_steps: int
|
| 46 |
model: str
|
| 47 |
base_url: str
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:
|
|
@@ -89,15 +94,25 @@ def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:
|
|
| 89 |
elif spec.system_prompt_template:
|
| 90 |
system_prompt = spec.system_prompt_template
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
return ResolvedAgentSpec(
|
| 93 |
name=spec.name,
|
| 94 |
description=spec.description,
|
|
|
|
| 95 |
system_prompt=system_prompt,
|
| 96 |
tools=spec.tools or [],
|
| 97 |
exclude_tools=spec.exclude_tools or [],
|
| 98 |
max_steps=spec.max_steps,
|
| 99 |
model=spec.model or default_model,
|
| 100 |
base_url=spec.base_url or default_base_url,
|
|
|
|
| 101 |
)
|
| 102 |
|
| 103 |
|
|
@@ -106,6 +121,7 @@ def get_default_agent_spec() -> ResolvedAgentSpec:
|
|
| 106 |
return ResolvedAgentSpec(
|
| 107 |
name="default",
|
| 108 |
description="Default AMCP agent",
|
|
|
|
| 109 |
system_prompt="""You are AMCP, a Lego-style coding agent CLI. You help users with software engineering tasks using the available tools.
|
| 110 |
|
| 111 |
Available tools:
|
|
@@ -129,6 +145,7 @@ Current time: {current_time}""",
|
|
| 129 |
max_steps=300,
|
| 130 |
model="",
|
| 131 |
base_url="",
|
|
|
|
| 132 |
)
|
| 133 |
|
| 134 |
|
|
|
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
|
| 9 |
from .config import load_config as load_app_config
|
| 10 |
+
from .multi_agent import AgentMode
|
| 11 |
|
| 12 |
|
| 13 |
class AgentSpecError(Exception):
|
|
|
|
| 21 |
|
| 22 |
name: str = Field(description="Agent name")
|
| 23 |
description: str = Field(default="", description="Agent description")
|
| 24 |
+
mode: str = Field(default="primary", description="Agent mode: 'primary' or 'subagent'")
|
| 25 |
system_prompt: str = Field(description="System prompt for the agent")
|
| 26 |
system_prompt_template: str = Field(default="", description="System prompt template with variables")
|
| 27 |
system_prompt_vars: dict[str, str] = Field(default_factory=dict, description="Variables for system prompt template")
|
|
|
|
| 30 |
max_steps: int = Field(default=5, description="Maximum tool execution steps")
|
| 31 |
model: str = Field(default="", description="Preferred model name")
|
| 32 |
base_url: str = Field(default="", description="Preferred base URL")
|
| 33 |
+
can_delegate: bool = Field(default=True, description="Whether agent can spawn subagents")
|
| 34 |
|
| 35 |
class Config:
|
| 36 |
extra = "allow"
|
|
|
|
| 42 |
|
| 43 |
name: str
|
| 44 |
description: str
|
| 45 |
+
mode: AgentMode
|
| 46 |
system_prompt: str
|
| 47 |
tools: list[str]
|
| 48 |
exclude_tools: list[str]
|
| 49 |
max_steps: int
|
| 50 |
model: str
|
| 51 |
base_url: str
|
| 52 |
+
can_delegate: bool = True
|
| 53 |
|
| 54 |
|
| 55 |
def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:
|
|
|
|
| 94 |
elif spec.system_prompt_template:
|
| 95 |
system_prompt = spec.system_prompt_template
|
| 96 |
|
| 97 |
+
# Parse mode
|
| 98 |
+
mode = AgentMode.PRIMARY if spec.mode == "primary" else AgentMode.SUBAGENT
|
| 99 |
+
|
| 100 |
+
# Subagents cannot delegate by default
|
| 101 |
+
can_delegate = spec.can_delegate
|
| 102 |
+
if mode == AgentMode.SUBAGENT:
|
| 103 |
+
can_delegate = False
|
| 104 |
+
|
| 105 |
return ResolvedAgentSpec(
|
| 106 |
name=spec.name,
|
| 107 |
description=spec.description,
|
| 108 |
+
mode=mode,
|
| 109 |
system_prompt=system_prompt,
|
| 110 |
tools=spec.tools or [],
|
| 111 |
exclude_tools=spec.exclude_tools or [],
|
| 112 |
max_steps=spec.max_steps,
|
| 113 |
model=spec.model or default_model,
|
| 114 |
base_url=spec.base_url or default_base_url,
|
| 115 |
+
can_delegate=can_delegate,
|
| 116 |
)
|
| 117 |
|
| 118 |
|
|
|
|
| 121 |
return ResolvedAgentSpec(
|
| 122 |
name="default",
|
| 123 |
description="Default AMCP agent",
|
| 124 |
+
mode=AgentMode.PRIMARY,
|
| 125 |
system_prompt="""You are AMCP, a Lego-style coding agent CLI. You help users with software engineering tasks using the available tools.
|
| 126 |
|
| 127 |
Available tools:
|
|
|
|
| 145 |
max_steps=300,
|
| 146 |
model="",
|
| 147 |
base_url="",
|
| 148 |
+
can_delegate=True,
|
| 149 |
)
|
| 150 |
|
| 151 |
|
src/amcp/cli.py
CHANGED
|
@@ -13,11 +13,13 @@ from rich.console import Console
|
|
| 13 |
from rich.json import JSON
|
| 14 |
from rich.markdown import Markdown
|
| 15 |
from rich.panel import Panel
|
|
|
|
| 16 |
|
| 17 |
-
from .agent import Agent
|
| 18 |
from .agent_spec import get_default_agent_spec, list_available_agents, load_agent_spec
|
| 19 |
from .config import AMCPConfig, load_config, save_default_config
|
| 20 |
from .mcp_client import call_mcp_tool, list_mcp_tools
|
|
|
|
| 21 |
|
| 22 |
app = typer.Typer(add_completion=False, context_settings={"help_option_names": ["-h", "--help"]})
|
| 23 |
console = Console()
|
|
@@ -101,6 +103,10 @@ def main(
|
|
| 101 |
ctx: typer.Context,
|
| 102 |
message: Annotated[str | None, typer.Option("--once", help="Send one message and exit")] = None,
|
| 103 |
agent_file: Annotated[str | None, typer.Option("--agent", help="Path to agent specification file")] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
work_dir: Annotated[
|
| 105 |
Path | None,
|
| 106 |
typer.Option(
|
|
@@ -109,6 +115,9 @@ def main(
|
|
| 109 |
] = None,
|
| 110 |
no_progress: Annotated[bool, typer.Option("--no-progress", help="Disable progress indicators")] = False,
|
| 111 |
list_agents: Annotated[bool, typer.Option("--list", help="List available agent specifications")] = False,
|
|
|
|
|
|
|
|
|
|
| 112 |
session_id: Annotated[
|
| 113 |
str | None, typer.Option("--session", help="Use specific session ID for conversation continuity")
|
| 114 |
] = None,
|
|
@@ -123,6 +132,33 @@ def main(
|
|
| 123 |
if ctx.invoked_subcommand is not None:
|
| 124 |
return
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
# Handle session listing
|
| 127 |
if list_sessions:
|
| 128 |
sessions_dir = Path.home() / ".config" / "amcp" / "sessions"
|
|
@@ -155,11 +191,11 @@ def main(
|
|
| 155 |
agents_dir = Path(os.path.expanduser("~/.config/amcp/agents"))
|
| 156 |
local_agents_dir = Path("agents")
|
| 157 |
|
| 158 |
-
|
| 159 |
local_agent_files = list_available_agents(local_agents_dir)
|
| 160 |
|
| 161 |
# Combine both lists
|
| 162 |
-
all_agent_files =
|
| 163 |
|
| 164 |
if not all_agent_files:
|
| 165 |
console.print(
|
|
@@ -168,41 +204,71 @@ def main(
|
|
| 168 |
return
|
| 169 |
|
| 170 |
console.print("[bold]Available Agent Specifications:[/bold]")
|
| 171 |
-
for
|
| 172 |
try:
|
| 173 |
-
spec = load_agent_spec(
|
| 174 |
-
console.print(f"📄 {
|
| 175 |
console.print(f" Name: {spec.name}")
|
|
|
|
| 176 |
console.print(f" Description: {spec.description}")
|
| 177 |
console.print(f" Tools: {len(spec.tools)}")
|
| 178 |
console.print()
|
| 179 |
except Exception as e:
|
| 180 |
-
console.print(f"❌ {
|
| 181 |
|
| 182 |
# Also show default agent
|
| 183 |
default_spec = get_default_agent_spec()
|
| 184 |
console.print("[bold]Default Agent:[/bold]")
|
| 185 |
console.print(f" Name: {default_spec.name}")
|
|
|
|
| 186 |
console.print(f" Description: {default_spec.description}")
|
| 187 |
console.print(f" Tools: {len(default_spec.tools)}")
|
| 188 |
return
|
| 189 |
|
|
|
|
|
|
|
|
|
|
| 190 |
try:
|
| 191 |
-
#
|
|
|
|
|
|
|
| 192 |
if agent_file:
|
|
|
|
| 193 |
agent_path = Path(agent_file).expanduser()
|
| 194 |
if not agent_path.exists():
|
| 195 |
console.print(f"[red]Agent file not found: {agent_path}[/red]")
|
| 196 |
raise typer.Exit(1)
|
| 197 |
agent_spec = load_agent_spec(agent_path)
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
else:
|
|
|
|
| 200 |
agent_spec = get_default_agent_spec()
|
|
|
|
| 201 |
console.print(f"[green]Using default agent: {agent_spec.name}[/green]")
|
| 202 |
|
| 203 |
-
# Create agent with session management
|
| 204 |
-
agent = Agent(agent_spec, session_id=session_id)
|
| 205 |
-
|
| 206 |
# Handle session clearing
|
| 207 |
if clear_session:
|
| 208 |
agent.clear_conversation_history()
|
|
@@ -233,8 +299,8 @@ def main(
|
|
| 233 |
else:
|
| 234 |
# Interactive mode
|
| 235 |
console.print(f"[bold]🤖 Agent {agent.name} - Interactive Mode[/bold]")
|
| 236 |
-
console.print(f"[dim]Description: {agent_spec.description}[/dim]")
|
| 237 |
-
console.print(f"[dim]Max steps: {agent_spec.max_steps} | Session: {agent.session_id}[/dim]")
|
| 238 |
console.print("[dim]Commands: 'exit' to quit, 'clear' to clear history, 'info' for session info[/dim]")
|
| 239 |
console.print()
|
| 240 |
|
|
@@ -298,12 +364,15 @@ def main(
|
|
| 298 |
raise typer.Exit(code=1) from None
|
| 299 |
|
| 300 |
|
| 301 |
-
@app.command(help="Enhanced agent chat (alias for default command)")
|
| 302 |
@app.command(help="Enhanced agent chat (alias for default command)")
|
| 303 |
def agent(
|
| 304 |
ctx: typer.Context,
|
| 305 |
message: Annotated[str | None, typer.Option("--once", help="Send one message and exit")] = None,
|
| 306 |
agent_file: Annotated[str | None, typer.Option("--agent", help="Path to agent specification file")] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
work_dir: Annotated[
|
| 308 |
Path | None,
|
| 309 |
typer.Option(
|
|
@@ -312,6 +381,9 @@ def agent(
|
|
| 312 |
] = None,
|
| 313 |
no_progress: Annotated[bool, typer.Option("--no-progress", help="Disable progress indicators")] = False,
|
| 314 |
list_agents: Annotated[bool, typer.Option("--list", help="List available agent specifications")] = False,
|
|
|
|
|
|
|
|
|
|
| 315 |
session_id: Annotated[
|
| 316 |
str | None, typer.Option("--session", help="Use specific session ID for conversation continuity")
|
| 317 |
] = None,
|
|
@@ -325,9 +397,11 @@ def agent(
|
|
| 325 |
main,
|
| 326 |
message=message,
|
| 327 |
agent_file=agent_file,
|
|
|
|
| 328 |
work_dir=work_dir,
|
| 329 |
no_progress=no_progress,
|
| 330 |
list_agents=list_agents,
|
|
|
|
| 331 |
session_id=session_id,
|
| 332 |
clear_session=clear_session,
|
| 333 |
list_sessions=list_sessions,
|
|
|
|
| 13 |
from rich.json import JSON
|
| 14 |
from rich.markdown import Markdown
|
| 15 |
from rich.panel import Panel
|
| 16 |
+
from rich.table import Table
|
| 17 |
|
| 18 |
+
from .agent import Agent, create_agent_by_name
|
| 19 |
from .agent_spec import get_default_agent_spec, list_available_agents, load_agent_spec
|
| 20 |
from .config import AMCPConfig, load_config, save_default_config
|
| 21 |
from .mcp_client import call_mcp_tool, list_mcp_tools
|
| 22 |
+
from .multi_agent import get_agent_registry
|
| 23 |
|
| 24 |
app = typer.Typer(add_completion=False, context_settings={"help_option_names": ["-h", "--help"]})
|
| 25 |
console = Console()
|
|
|
|
| 103 |
ctx: typer.Context,
|
| 104 |
message: Annotated[str | None, typer.Option("--once", help="Send one message and exit")] = None,
|
| 105 |
agent_file: Annotated[str | None, typer.Option("--agent", help="Path to agent specification file")] = None,
|
| 106 |
+
agent_type: Annotated[
|
| 107 |
+
str | None,
|
| 108 |
+
typer.Option("--agent-type", "-t", help="Built-in agent type: coder, explorer, planner, focused_coder"),
|
| 109 |
+
] = None,
|
| 110 |
work_dir: Annotated[
|
| 111 |
Path | None,
|
| 112 |
typer.Option(
|
|
|
|
| 115 |
] = None,
|
| 116 |
no_progress: Annotated[bool, typer.Option("--no-progress", help="Disable progress indicators")] = False,
|
| 117 |
list_agents: Annotated[bool, typer.Option("--list", help="List available agent specifications")] = False,
|
| 118 |
+
list_agent_types: Annotated[
|
| 119 |
+
bool, typer.Option("--list-types", help="List available built-in agent types")
|
| 120 |
+
] = False,
|
| 121 |
session_id: Annotated[
|
| 122 |
str | None, typer.Option("--session", help="Use specific session ID for conversation continuity")
|
| 123 |
] = None,
|
|
|
|
| 132 |
if ctx.invoked_subcommand is not None:
|
| 133 |
return
|
| 134 |
|
| 135 |
+
# Handle list agent types
|
| 136 |
+
if list_agent_types:
|
| 137 |
+
registry = get_agent_registry()
|
| 138 |
+
table = Table(title="Available Built-in Agent Types")
|
| 139 |
+
table.add_column("Name", style="cyan")
|
| 140 |
+
table.add_column("Mode", style="magenta")
|
| 141 |
+
table.add_column("Description", style="green")
|
| 142 |
+
table.add_column("Can Delegate", style="yellow")
|
| 143 |
+
table.add_column("Max Steps", style="blue")
|
| 144 |
+
|
| 145 |
+
for name in registry.list_agents():
|
| 146 |
+
config = registry.get(name)
|
| 147 |
+
if config:
|
| 148 |
+
table.add_row(
|
| 149 |
+
config.name,
|
| 150 |
+
config.mode.value,
|
| 151 |
+
config.description[:50] + "..." if len(config.description) > 50 else config.description,
|
| 152 |
+
"✅" if config.can_delegate else "❌",
|
| 153 |
+
str(config.max_steps),
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
console.print(table)
|
| 157 |
+
console.print()
|
| 158 |
+
console.print("[dim]Use --agent-type <name> or -t <name> to select an agent type[/dim]")
|
| 159 |
+
console.print("[dim]Example: amcp -t explorer --once 'Find all TODO comments'[/dim]")
|
| 160 |
+
return
|
| 161 |
+
|
| 162 |
# Handle session listing
|
| 163 |
if list_sessions:
|
| 164 |
sessions_dir = Path.home() / ".config" / "amcp" / "sessions"
|
|
|
|
| 191 |
agents_dir = Path(os.path.expanduser("~/.config/amcp/agents"))
|
| 192 |
local_agents_dir = Path("agents")
|
| 193 |
|
| 194 |
+
agent_files_list = list_available_agents(agents_dir)
|
| 195 |
local_agent_files = list_available_agents(local_agents_dir)
|
| 196 |
|
| 197 |
# Combine both lists
|
| 198 |
+
all_agent_files = agent_files_list + local_agent_files
|
| 199 |
|
| 200 |
if not all_agent_files:
|
| 201 |
console.print(
|
|
|
|
| 204 |
return
|
| 205 |
|
| 206 |
console.print("[bold]Available Agent Specifications:[/bold]")
|
| 207 |
+
for agent_file_path in all_agent_files:
|
| 208 |
try:
|
| 209 |
+
spec = load_agent_spec(agent_file_path)
|
| 210 |
+
console.print(f"📄 {agent_file_path.name}")
|
| 211 |
console.print(f" Name: {spec.name}")
|
| 212 |
+
console.print(f" Mode: {spec.mode.value}")
|
| 213 |
console.print(f" Description: {spec.description}")
|
| 214 |
console.print(f" Tools: {len(spec.tools)}")
|
| 215 |
console.print()
|
| 216 |
except Exception as e:
|
| 217 |
+
console.print(f"❌ {agent_file_path.name}: {e}")
|
| 218 |
|
| 219 |
# Also show default agent
|
| 220 |
default_spec = get_default_agent_spec()
|
| 221 |
console.print("[bold]Default Agent:[/bold]")
|
| 222 |
console.print(f" Name: {default_spec.name}")
|
| 223 |
+
console.print(f" Mode: {default_spec.mode.value}")
|
| 224 |
console.print(f" Description: {default_spec.description}")
|
| 225 |
console.print(f" Tools: {len(default_spec.tools)}")
|
| 226 |
return
|
| 227 |
|
| 228 |
+
# Load configuration
|
| 229 |
+
cfg = load_config()
|
| 230 |
+
|
| 231 |
try:
|
| 232 |
+
# Determine agent to use (priority: --agent > --agent-type > config.default_agent > default)
|
| 233 |
+
agent = None
|
| 234 |
+
|
| 235 |
if agent_file:
|
| 236 |
+
# Load from YAML file
|
| 237 |
agent_path = Path(agent_file).expanduser()
|
| 238 |
if not agent_path.exists():
|
| 239 |
console.print(f"[red]Agent file not found: {agent_path}[/red]")
|
| 240 |
raise typer.Exit(1)
|
| 241 |
agent_spec = load_agent_spec(agent_path)
|
| 242 |
+
agent = Agent(agent_spec, session_id=session_id)
|
| 243 |
+
console.print(f"[green]Loaded agent from file: {agent_spec.name} ({agent_spec.mode.value})[/green]")
|
| 244 |
+
|
| 245 |
+
elif agent_type:
|
| 246 |
+
# Use built-in agent type
|
| 247 |
+
registry = get_agent_registry()
|
| 248 |
+
if agent_type not in registry.list_agents():
|
| 249 |
+
console.print(f"[red]Unknown agent type: {agent_type}[/red]")
|
| 250 |
+
console.print(f"[dim]Available types: {', '.join(registry.list_agents())}[/dim]")
|
| 251 |
+
raise typer.Exit(1)
|
| 252 |
+
agent = create_agent_by_name(agent_type, session_id=session_id)
|
| 253 |
+
console.print(f"[green]Using agent: {agent.name} ({agent.agent_spec.mode.value})[/green]")
|
| 254 |
+
|
| 255 |
+
elif cfg.chat and cfg.chat.default_agent:
|
| 256 |
+
# Use agent from config
|
| 257 |
+
registry = get_agent_registry()
|
| 258 |
+
if cfg.chat.default_agent in registry.list_agents():
|
| 259 |
+
agent = create_agent_by_name(cfg.chat.default_agent, session_id=session_id)
|
| 260 |
+
console.print(f"[green]Using configured agent: {agent.name} ({agent.agent_spec.mode.value})[/green]")
|
| 261 |
+
else:
|
| 262 |
+
console.print(f"[yellow]Warning: Configured agent '{cfg.chat.default_agent}' not found, using default[/yellow]")
|
| 263 |
+
agent_spec = get_default_agent_spec()
|
| 264 |
+
agent = Agent(agent_spec, session_id=session_id)
|
| 265 |
+
|
| 266 |
else:
|
| 267 |
+
# Use default agent
|
| 268 |
agent_spec = get_default_agent_spec()
|
| 269 |
+
agent = Agent(agent_spec, session_id=session_id)
|
| 270 |
console.print(f"[green]Using default agent: {agent_spec.name}[/green]")
|
| 271 |
|
|
|
|
|
|
|
|
|
|
| 272 |
# Handle session clearing
|
| 273 |
if clear_session:
|
| 274 |
agent.clear_conversation_history()
|
|
|
|
| 299 |
else:
|
| 300 |
# Interactive mode
|
| 301 |
console.print(f"[bold]🤖 Agent {agent.name} - Interactive Mode[/bold]")
|
| 302 |
+
console.print(f"[dim]Description: {agent.agent_spec.description}[/dim]")
|
| 303 |
+
console.print(f"[dim]Max steps: {agent.agent_spec.max_steps} | Mode: {agent.agent_spec.mode.value} | Session: {agent.session_id}[/dim]")
|
| 304 |
console.print("[dim]Commands: 'exit' to quit, 'clear' to clear history, 'info' for session info[/dim]")
|
| 305 |
console.print()
|
| 306 |
|
|
|
|
| 364 |
raise typer.Exit(code=1) from None
|
| 365 |
|
| 366 |
|
|
|
|
| 367 |
@app.command(help="Enhanced agent chat (alias for default command)")
|
| 368 |
def agent(
|
| 369 |
ctx: typer.Context,
|
| 370 |
message: Annotated[str | None, typer.Option("--once", help="Send one message and exit")] = None,
|
| 371 |
agent_file: Annotated[str | None, typer.Option("--agent", help="Path to agent specification file")] = None,
|
| 372 |
+
agent_type: Annotated[
|
| 373 |
+
str | None,
|
| 374 |
+
typer.Option("--agent-type", "-t", help="Built-in agent type: coder, explorer, planner, focused_coder"),
|
| 375 |
+
] = None,
|
| 376 |
work_dir: Annotated[
|
| 377 |
Path | None,
|
| 378 |
typer.Option(
|
|
|
|
| 381 |
] = None,
|
| 382 |
no_progress: Annotated[bool, typer.Option("--no-progress", help="Disable progress indicators")] = False,
|
| 383 |
list_agents: Annotated[bool, typer.Option("--list", help="List available agent specifications")] = False,
|
| 384 |
+
list_agent_types: Annotated[
|
| 385 |
+
bool, typer.Option("--list-types", help="List available built-in agent types")
|
| 386 |
+
] = False,
|
| 387 |
session_id: Annotated[
|
| 388 |
str | None, typer.Option("--session", help="Use specific session ID for conversation continuity")
|
| 389 |
] = None,
|
|
|
|
| 397 |
main,
|
| 398 |
message=message,
|
| 399 |
agent_file=agent_file,
|
| 400 |
+
agent_type=agent_type,
|
| 401 |
work_dir=work_dir,
|
| 402 |
no_progress=no_progress,
|
| 403 |
list_agents=list_agents,
|
| 404 |
+
list_agent_types=list_agent_types,
|
| 405 |
session_id=session_id,
|
| 406 |
clear_session=clear_session,
|
| 407 |
list_sessions=list_sessions,
|
src/amcp/config.py
CHANGED
|
@@ -43,6 +43,12 @@ class ChatConfig:
|
|
| 43 |
# Built-in file modification tools
|
| 44 |
write_tool_enabled: bool | None = None
|
| 45 |
edit_tool_enabled: bool | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
@dataclass
|
|
@@ -97,6 +103,11 @@ def _decode_chat(raw: Mapping[str, object] | None) -> ChatConfig | None:
|
|
| 97 |
mcp_servers = raw.get("mcp_servers")
|
| 98 |
write_tool_enabled = raw.get("write_tool_enabled")
|
| 99 |
edit_tool_enabled = raw.get("edit_tool_enabled")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
return ChatConfig(
|
| 101 |
base_url=str(base_url) if base_url is not None else None,
|
| 102 |
model=str(model) if model is not None else None,
|
|
@@ -109,6 +120,9 @@ def _decode_chat(raw: Mapping[str, object] | None) -> ChatConfig | None:
|
|
| 109 |
mcp_servers=[str(s) for s in (mcp_servers or [])] if mcp_servers is not None else None,
|
| 110 |
write_tool_enabled=bool(write_tool_enabled) if write_tool_enabled is not None else None,
|
| 111 |
edit_tool_enabled=bool(edit_tool_enabled) if edit_tool_enabled is not None else None,
|
|
|
|
|
|
|
|
|
|
| 112 |
)
|
| 113 |
|
| 114 |
|
|
@@ -160,6 +174,14 @@ def _encode_chat(c: ChatConfig | None) -> dict | None:
|
|
| 160 |
out["write_tool_enabled"] = bool(c.write_tool_enabled)
|
| 161 |
if c.edit_tool_enabled is not None:
|
| 162 |
out["edit_tool_enabled"] = bool(c.edit_tool_enabled)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
return out
|
| 164 |
|
| 165 |
|
|
|
|
| 43 |
# Built-in file modification tools
|
| 44 |
write_tool_enabled: bool | None = None
|
| 45 |
edit_tool_enabled: bool | None = None
|
| 46 |
+
# Agent settings
|
| 47 |
+
default_agent: str | None = None # default agent to use: "coder", "explorer", etc.
|
| 48 |
+
# Queue settings
|
| 49 |
+
enable_queue: bool | None = None # enable message queue (default: True)
|
| 50 |
+
max_queue_size: int | None = None # max queued messages per session (default: 100)
|
| 51 |
+
|
| 52 |
|
| 53 |
|
| 54 |
@dataclass
|
|
|
|
| 103 |
mcp_servers = raw.get("mcp_servers")
|
| 104 |
write_tool_enabled = raw.get("write_tool_enabled")
|
| 105 |
edit_tool_enabled = raw.get("edit_tool_enabled")
|
| 106 |
+
# Agent settings
|
| 107 |
+
default_agent = raw.get("default_agent")
|
| 108 |
+
# Queue settings
|
| 109 |
+
enable_queue = raw.get("enable_queue")
|
| 110 |
+
max_queue_size = raw.get("max_queue_size")
|
| 111 |
return ChatConfig(
|
| 112 |
base_url=str(base_url) if base_url is not None else None,
|
| 113 |
model=str(model) if model is not None else None,
|
|
|
|
| 120 |
mcp_servers=[str(s) for s in (mcp_servers or [])] if mcp_servers is not None else None,
|
| 121 |
write_tool_enabled=bool(write_tool_enabled) if write_tool_enabled is not None else None,
|
| 122 |
edit_tool_enabled=bool(edit_tool_enabled) if edit_tool_enabled is not None else None,
|
| 123 |
+
default_agent=str(default_agent) if default_agent is not None else None,
|
| 124 |
+
enable_queue=bool(enable_queue) if enable_queue is not None else None,
|
| 125 |
+
max_queue_size=int(max_queue_size) if max_queue_size is not None else None,
|
| 126 |
)
|
| 127 |
|
| 128 |
|
|
|
|
| 174 |
out["write_tool_enabled"] = bool(c.write_tool_enabled)
|
| 175 |
if c.edit_tool_enabled is not None:
|
| 176 |
out["edit_tool_enabled"] = bool(c.edit_tool_enabled)
|
| 177 |
+
# Agent settings
|
| 178 |
+
if c.default_agent:
|
| 179 |
+
out["default_agent"] = c.default_agent
|
| 180 |
+
# Queue settings
|
| 181 |
+
if c.enable_queue is not None:
|
| 182 |
+
out["enable_queue"] = bool(c.enable_queue)
|
| 183 |
+
if c.max_queue_size is not None:
|
| 184 |
+
out["max_queue_size"] = int(c.max_queue_size)
|
| 185 |
return out
|
| 186 |
|
| 187 |
|
src/amcp/message_queue.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Session-level Message Queue for AMCP.
|
| 3 |
+
|
| 4 |
+
This module provides message queuing capabilities for agent sessions,
|
| 5 |
+
inspired by Crush's sessionAgent message queue implementation.
|
| 6 |
+
|
| 7 |
+
Key Features:
|
| 8 |
+
- Session-specific message queues (messages are isolated per session)
|
| 9 |
+
- Priority support for urgent messages
|
| 10 |
+
- Automatic queuing when session is busy
|
| 11 |
+
- Queue management (clear, peek, list)
|
| 12 |
+
- Async/await compatible
|
| 13 |
+
|
| 14 |
+
The queue ensures that:
|
| 15 |
+
1. A session can only process one request at a time
|
| 16 |
+
2. Incoming messages are queued while the session is busy
|
| 17 |
+
3. Queued messages are processed in order when the session becomes free
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import asyncio
|
| 23 |
+
import uuid
|
| 24 |
+
from collections import deque
|
| 25 |
+
from dataclasses import dataclass, field
|
| 26 |
+
from datetime import datetime
|
| 27 |
+
from enum import Enum
|
| 28 |
+
from typing import Any
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class MessagePriority(Enum):
|
| 32 |
+
"""Priority levels for queued messages."""
|
| 33 |
+
|
| 34 |
+
LOW = 0
|
| 35 |
+
NORMAL = 1
|
| 36 |
+
HIGH = 2
|
| 37 |
+
URGENT = 3
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class QueuedMessage:
|
| 42 |
+
"""A message waiting in the queue.
|
| 43 |
+
|
| 44 |
+
Attributes:
|
| 45 |
+
id: Unique message identifier
|
| 46 |
+
session_id: Session this message belongs to
|
| 47 |
+
prompt: User's input prompt
|
| 48 |
+
attachments: Optional file attachments
|
| 49 |
+
priority: Message priority (default NORMAL)
|
| 50 |
+
created_at: When the message was queued
|
| 51 |
+
metadata: Additional message metadata
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
id: str
|
| 55 |
+
session_id: str
|
| 56 |
+
prompt: str
|
| 57 |
+
attachments: list[dict[str, Any]] = field(default_factory=list)
|
| 58 |
+
priority: MessagePriority = MessagePriority.NORMAL
|
| 59 |
+
created_at: datetime = field(default_factory=datetime.now)
|
| 60 |
+
metadata: dict[str, Any] = field(default_factory=dict)
|
| 61 |
+
|
| 62 |
+
@classmethod
|
| 63 |
+
def create(
|
| 64 |
+
cls,
|
| 65 |
+
session_id: str,
|
| 66 |
+
prompt: str,
|
| 67 |
+
attachments: list[dict[str, Any]] | None = None,
|
| 68 |
+
priority: MessagePriority = MessagePriority.NORMAL,
|
| 69 |
+
**metadata: Any,
|
| 70 |
+
) -> "QueuedMessage":
|
| 71 |
+
"""Create a new queued message.
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
session_id: Target session ID
|
| 75 |
+
prompt: User's prompt text
|
| 76 |
+
attachments: Optional attachments
|
| 77 |
+
priority: Message priority
|
| 78 |
+
**metadata: Additional metadata
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
New QueuedMessage instance
|
| 82 |
+
"""
|
| 83 |
+
return cls(
|
| 84 |
+
id=str(uuid.uuid4()),
|
| 85 |
+
session_id=session_id,
|
| 86 |
+
prompt=prompt,
|
| 87 |
+
attachments=attachments or [],
|
| 88 |
+
priority=priority,
|
| 89 |
+
metadata=metadata,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class SessionQueue:
|
| 94 |
+
"""Message queue for a single session.
|
| 95 |
+
|
| 96 |
+
Manages a FIFO queue of messages for one session,
|
| 97 |
+
with support for priorities.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
def __init__(self, session_id: str):
|
| 101 |
+
"""Initialize session queue.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
session_id: Session identifier
|
| 105 |
+
"""
|
| 106 |
+
self.session_id = session_id
|
| 107 |
+
self._queue: deque[QueuedMessage] = deque()
|
| 108 |
+
self._lock = asyncio.Lock()
|
| 109 |
+
|
| 110 |
+
def __len__(self) -> int:
|
| 111 |
+
"""Get the number of messages in the queue."""
|
| 112 |
+
return len(self._queue)
|
| 113 |
+
|
| 114 |
+
def is_empty(self) -> bool:
|
| 115 |
+
"""Check if the queue is empty."""
|
| 116 |
+
return len(self._queue) == 0
|
| 117 |
+
|
| 118 |
+
async def enqueue(self, message: QueuedMessage) -> None:
|
| 119 |
+
"""Add a message to the queue.
|
| 120 |
+
|
| 121 |
+
Messages are ordered by priority, then by creation time.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
message: Message to add
|
| 125 |
+
"""
|
| 126 |
+
async with self._lock:
|
| 127 |
+
# Find insertion point based on priority
|
| 128 |
+
insert_idx = len(self._queue)
|
| 129 |
+
for i, existing in enumerate(self._queue):
|
| 130 |
+
if message.priority.value > existing.priority.value:
|
| 131 |
+
insert_idx = i
|
| 132 |
+
break
|
| 133 |
+
|
| 134 |
+
self._queue.insert(insert_idx, message)
|
| 135 |
+
|
| 136 |
+
async def dequeue(self) -> QueuedMessage | None:
|
| 137 |
+
"""Remove and return the next message from the queue.
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
Next message or None if queue is empty
|
| 141 |
+
"""
|
| 142 |
+
async with self._lock:
|
| 143 |
+
if self._queue:
|
| 144 |
+
return self._queue.popleft()
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
async def peek(self) -> QueuedMessage | None:
|
| 148 |
+
"""Look at the next message without removing it.
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
Next message or None if queue is empty
|
| 152 |
+
"""
|
| 153 |
+
async with self._lock:
|
| 154 |
+
if self._queue:
|
| 155 |
+
return self._queue[0]
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
async def clear(self) -> int:
|
| 159 |
+
"""Clear all messages from the queue.
|
| 160 |
+
|
| 161 |
+
Returns:
|
| 162 |
+
Number of messages cleared
|
| 163 |
+
"""
|
| 164 |
+
async with self._lock:
|
| 165 |
+
count = len(self._queue)
|
| 166 |
+
self._queue.clear()
|
| 167 |
+
return count
|
| 168 |
+
|
| 169 |
+
def list_messages(self) -> list[QueuedMessage]:
|
| 170 |
+
"""Get a copy of all queued messages.
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
List of queued messages
|
| 174 |
+
"""
|
| 175 |
+
return list(self._queue)
|
| 176 |
+
|
| 177 |
+
def list_prompts(self) -> list[str]:
|
| 178 |
+
"""Get list of queued prompts for display.
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
List of prompt strings
|
| 182 |
+
"""
|
| 183 |
+
return [msg.prompt for msg in self._queue]
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class MessageQueueManager:
|
| 187 |
+
"""Manages message queues for multiple sessions.
|
| 188 |
+
|
| 189 |
+
Provides centralized management of session queues,
|
| 190 |
+
busy state tracking, and message routing.
|
| 191 |
+
|
| 192 |
+
Example usage:
|
| 193 |
+
manager = MessageQueueManager()
|
| 194 |
+
|
| 195 |
+
# Check if session is busy
|
| 196 |
+
if manager.is_busy("session-123"):
|
| 197 |
+
# Enqueue the message
|
| 198 |
+
await manager.enqueue("session-123", "Hello!")
|
| 199 |
+
else:
|
| 200 |
+
# Process immediately
|
| 201 |
+
await manager.acquire("session-123")
|
| 202 |
+
try:
|
| 203 |
+
result = await process_message(prompt)
|
| 204 |
+
finally:
|
| 205 |
+
manager.release("session-123")
|
| 206 |
+
|
| 207 |
+
# Process any queued messages
|
| 208 |
+
while True:
|
| 209 |
+
next_msg = await manager.dequeue("session-123")
|
| 210 |
+
if not next_msg:
|
| 211 |
+
break
|
| 212 |
+
# Process next_msg...
|
| 213 |
+
"""
|
| 214 |
+
|
| 215 |
+
def __init__(self):
|
| 216 |
+
"""Initialize the message queue manager."""
|
| 217 |
+
self._session_queues: dict[str, SessionQueue] = {}
|
| 218 |
+
self._busy_sessions: set[str] = set()
|
| 219 |
+
self._session_locks: dict[str, asyncio.Lock] = {}
|
| 220 |
+
self._global_lock = asyncio.Lock()
|
| 221 |
+
|
| 222 |
+
def _get_or_create_queue(self, session_id: str) -> SessionQueue:
|
| 223 |
+
"""Get or create a queue for a session.
|
| 224 |
+
|
| 225 |
+
Args:
|
| 226 |
+
session_id: Session identifier
|
| 227 |
+
|
| 228 |
+
Returns:
|
| 229 |
+
Session queue
|
| 230 |
+
"""
|
| 231 |
+
if session_id not in self._session_queues:
|
| 232 |
+
self._session_queues[session_id] = SessionQueue(session_id)
|
| 233 |
+
return self._session_queues[session_id]
|
| 234 |
+
|
| 235 |
+
def _get_or_create_lock(self, session_id: str) -> asyncio.Lock:
|
| 236 |
+
"""Get or create a lock for a session.
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
session_id: Session identifier
|
| 240 |
+
|
| 241 |
+
Returns:
|
| 242 |
+
Session lock
|
| 243 |
+
"""
|
| 244 |
+
if session_id not in self._session_locks:
|
| 245 |
+
self._session_locks[session_id] = asyncio.Lock()
|
| 246 |
+
return self._session_locks[session_id]
|
| 247 |
+
|
| 248 |
+
def is_busy(self, session_id: str) -> bool:
|
| 249 |
+
"""Check if a session is currently busy.
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
session_id: Session identifier
|
| 253 |
+
|
| 254 |
+
Returns:
|
| 255 |
+
True if session is processing a request
|
| 256 |
+
"""
|
| 257 |
+
return session_id in self._busy_sessions
|
| 258 |
+
|
| 259 |
+
def any_busy(self) -> bool:
|
| 260 |
+
"""Check if any session is busy.
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
True if any session is processing
|
| 264 |
+
"""
|
| 265 |
+
return len(self._busy_sessions) > 0
|
| 266 |
+
|
| 267 |
+
def get_busy_sessions(self) -> list[str]:
|
| 268 |
+
"""Get list of busy session IDs.
|
| 269 |
+
|
| 270 |
+
Returns:
|
| 271 |
+
List of busy session IDs
|
| 272 |
+
"""
|
| 273 |
+
return list(self._busy_sessions)
|
| 274 |
+
|
| 275 |
+
async def acquire(self, session_id: str) -> bool:
|
| 276 |
+
"""Acquire exclusive access to a session.
|
| 277 |
+
|
| 278 |
+
Use this before processing a request for a session.
|
| 279 |
+
|
| 280 |
+
Args:
|
| 281 |
+
session_id: Session identifier
|
| 282 |
+
|
| 283 |
+
Returns:
|
| 284 |
+
True if acquired, False if session was already busy
|
| 285 |
+
"""
|
| 286 |
+
async with self._global_lock:
|
| 287 |
+
if session_id in self._busy_sessions:
|
| 288 |
+
return False
|
| 289 |
+
self._busy_sessions.add(session_id)
|
| 290 |
+
return True
|
| 291 |
+
|
| 292 |
+
def release(self, session_id: str) -> None:
|
| 293 |
+
"""Release a session after processing.
|
| 294 |
+
|
| 295 |
+
Use this after finishing processing a request.
|
| 296 |
+
|
| 297 |
+
Args:
|
| 298 |
+
session_id: Session identifier
|
| 299 |
+
"""
|
| 300 |
+
self._busy_sessions.discard(session_id)
|
| 301 |
+
|
| 302 |
+
async def enqueue(
|
| 303 |
+
self,
|
| 304 |
+
session_id: str,
|
| 305 |
+
prompt: str,
|
| 306 |
+
attachments: list[dict[str, Any]] | None = None,
|
| 307 |
+
priority: MessagePriority = MessagePriority.NORMAL,
|
| 308 |
+
**metadata: Any,
|
| 309 |
+
) -> QueuedMessage:
|
| 310 |
+
"""Add a message to a session's queue.
|
| 311 |
+
|
| 312 |
+
Use this when a session is busy and cannot process immediately.
|
| 313 |
+
|
| 314 |
+
Args:
|
| 315 |
+
session_id: Target session ID
|
| 316 |
+
prompt: User's prompt text
|
| 317 |
+
attachments: Optional attachments
|
| 318 |
+
priority: Message priority
|
| 319 |
+
**metadata: Additional metadata
|
| 320 |
+
|
| 321 |
+
Returns:
|
| 322 |
+
The queued message
|
| 323 |
+
"""
|
| 324 |
+
queue = self._get_or_create_queue(session_id)
|
| 325 |
+
message = QueuedMessage.create(
|
| 326 |
+
session_id=session_id,
|
| 327 |
+
prompt=prompt,
|
| 328 |
+
attachments=attachments,
|
| 329 |
+
priority=priority,
|
| 330 |
+
**metadata,
|
| 331 |
+
)
|
| 332 |
+
await queue.enqueue(message)
|
| 333 |
+
return message
|
| 334 |
+
|
| 335 |
+
async def enqueue_if_busy(
|
| 336 |
+
self,
|
| 337 |
+
session_id: str,
|
| 338 |
+
prompt: str,
|
| 339 |
+
attachments: list[dict[str, Any]] | None = None,
|
| 340 |
+
priority: MessagePriority = MessagePriority.NORMAL,
|
| 341 |
+
**metadata: Any,
|
| 342 |
+
) -> tuple[bool, QueuedMessage | None]:
|
| 343 |
+
"""Automatically enqueue a message if session is busy.
|
| 344 |
+
|
| 345 |
+
This is a convenience method that:
|
| 346 |
+
1. Checks if the session is busy
|
| 347 |
+
2. If busy, queues the message and returns (True, message)
|
| 348 |
+
3. If not busy, returns (False, None)
|
| 349 |
+
|
| 350 |
+
Args:
|
| 351 |
+
session_id: Target session ID
|
| 352 |
+
prompt: User's prompt text
|
| 353 |
+
attachments: Optional attachments
|
| 354 |
+
priority: Message priority
|
| 355 |
+
**metadata: Additional metadata
|
| 356 |
+
|
| 357 |
+
Returns:
|
| 358 |
+
Tuple of (was_queued, queued_message)
|
| 359 |
+
"""
|
| 360 |
+
if self.is_busy(session_id):
|
| 361 |
+
message = await self.enqueue(session_id, prompt, attachments, priority, **metadata)
|
| 362 |
+
return (True, message)
|
| 363 |
+
return (False, None)
|
| 364 |
+
|
| 365 |
+
async def dequeue(self, session_id: str) -> QueuedMessage | None:
|
| 366 |
+
"""Get the next queued message for a session.
|
| 367 |
+
|
| 368 |
+
Args:
|
| 369 |
+
session_id: Session identifier
|
| 370 |
+
|
| 371 |
+
Returns:
|
| 372 |
+
Next message or None if queue is empty
|
| 373 |
+
"""
|
| 374 |
+
queue = self._get_or_create_queue(session_id)
|
| 375 |
+
return await queue.dequeue()
|
| 376 |
+
|
| 377 |
+
async def peek(self, session_id: str) -> QueuedMessage | None:
|
| 378 |
+
"""Look at the next message without removing it.
|
| 379 |
+
|
| 380 |
+
Args:
|
| 381 |
+
session_id: Session identifier
|
| 382 |
+
|
| 383 |
+
Returns:
|
| 384 |
+
Next message or None if queue is empty
|
| 385 |
+
"""
|
| 386 |
+
queue = self._get_or_create_queue(session_id)
|
| 387 |
+
return await queue.peek()
|
| 388 |
+
|
| 389 |
+
def queued_count(self, session_id: str) -> int:
|
| 390 |
+
"""Get the number of queued messages for a session.
|
| 391 |
+
|
| 392 |
+
Args:
|
| 393 |
+
session_id: Session identifier
|
| 394 |
+
|
| 395 |
+
Returns:
|
| 396 |
+
Number of queued messages
|
| 397 |
+
"""
|
| 398 |
+
if session_id not in self._session_queues:
|
| 399 |
+
return 0
|
| 400 |
+
return len(self._session_queues[session_id])
|
| 401 |
+
|
| 402 |
+
def queued_prompts(self, session_id: str) -> list[str]:
|
| 403 |
+
"""Get list of queued prompts for a session.
|
| 404 |
+
|
| 405 |
+
Useful for displaying queue status to users.
|
| 406 |
+
|
| 407 |
+
Args:
|
| 408 |
+
session_id: Session identifier
|
| 409 |
+
|
| 410 |
+
Returns:
|
| 411 |
+
List of prompt strings
|
| 412 |
+
"""
|
| 413 |
+
if session_id not in self._session_queues:
|
| 414 |
+
return []
|
| 415 |
+
return self._session_queues[session_id].list_prompts()
|
| 416 |
+
|
| 417 |
+
async def clear_queue(self, session_id: str) -> int:
|
| 418 |
+
"""Clear all queued messages for a session.
|
| 419 |
+
|
| 420 |
+
Args:
|
| 421 |
+
session_id: Session identifier
|
| 422 |
+
|
| 423 |
+
Returns:
|
| 424 |
+
Number of messages cleared
|
| 425 |
+
"""
|
| 426 |
+
if session_id not in self._session_queues:
|
| 427 |
+
return 0
|
| 428 |
+
return await self._session_queues[session_id].clear()
|
| 429 |
+
|
| 430 |
+
def get_queue_status(self, session_id: str) -> dict[str, Any]:
|
| 431 |
+
"""Get detailed queue status for a session.
|
| 432 |
+
|
| 433 |
+
Args:
|
| 434 |
+
session_id: Session identifier
|
| 435 |
+
|
| 436 |
+
Returns:
|
| 437 |
+
Status dictionary
|
| 438 |
+
"""
|
| 439 |
+
return {
|
| 440 |
+
"session_id": session_id,
|
| 441 |
+
"is_busy": self.is_busy(session_id),
|
| 442 |
+
"queued_count": self.queued_count(session_id),
|
| 443 |
+
"queued_prompts": self.queued_prompts(session_id),
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
def get_all_status(self) -> dict[str, Any]:
|
| 447 |
+
"""Get status for all sessions.
|
| 448 |
+
|
| 449 |
+
Returns:
|
| 450 |
+
Status dictionary for all sessions
|
| 451 |
+
"""
|
| 452 |
+
return {
|
| 453 |
+
"busy_sessions": self.get_busy_sessions(),
|
| 454 |
+
"total_queued": sum(len(q) for q in self._session_queues.values()),
|
| 455 |
+
"sessions": {
|
| 456 |
+
sid: self.get_queue_status(sid)
|
| 457 |
+
for sid in set(self._session_queues.keys()) | self._busy_sessions
|
| 458 |
+
},
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
# Global message queue manager singleton
|
| 463 |
+
_queue_manager: MessageQueueManager | None = None
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def get_message_queue_manager() -> MessageQueueManager:
|
| 467 |
+
"""Get the global message queue manager.
|
| 468 |
+
|
| 469 |
+
Returns:
|
| 470 |
+
Global MessageQueueManager instance
|
| 471 |
+
"""
|
| 472 |
+
global _queue_manager
|
| 473 |
+
if _queue_manager is None:
|
| 474 |
+
_queue_manager = MessageQueueManager()
|
| 475 |
+
return _queue_manager
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
async def run_with_queue(
|
| 479 |
+
session_id: str,
|
| 480 |
+
prompt: str,
|
| 481 |
+
processor_fn,
|
| 482 |
+
attachments: list[dict[str, Any]] | None = None,
|
| 483 |
+
priority: MessagePriority = MessagePriority.NORMAL,
|
| 484 |
+
) -> Any | None:
|
| 485 |
+
"""Run a prompt through the queue system.
|
| 486 |
+
|
| 487 |
+
This is a high-level helper that:
|
| 488 |
+
1. Queues the message if session is busy
|
| 489 |
+
2. Otherwise processes immediately
|
| 490 |
+
3. After processing, checks for and processes queued messages
|
| 491 |
+
|
| 492 |
+
Args:
|
| 493 |
+
session_id: Session identifier
|
| 494 |
+
prompt: User's prompt
|
| 495 |
+
processor_fn: Async function(prompt, attachments) to process the message
|
| 496 |
+
attachments: Optional attachments
|
| 497 |
+
priority: Message priority
|
| 498 |
+
|
| 499 |
+
Returns:
|
| 500 |
+
Result from processor_fn, or None if queued
|
| 501 |
+
"""
|
| 502 |
+
manager = get_message_queue_manager()
|
| 503 |
+
|
| 504 |
+
# Check if we should queue
|
| 505 |
+
was_queued, _ = await manager.enqueue_if_busy(session_id, prompt, attachments, priority)
|
| 506 |
+
if was_queued:
|
| 507 |
+
return None
|
| 508 |
+
|
| 509 |
+
# Acquire the session
|
| 510 |
+
acquired = await manager.acquire(session_id)
|
| 511 |
+
if not acquired:
|
| 512 |
+
# Race condition - queue it
|
| 513 |
+
await manager.enqueue(session_id, prompt, attachments, priority)
|
| 514 |
+
return None
|
| 515 |
+
|
| 516 |
+
try:
|
| 517 |
+
# Process the message
|
| 518 |
+
result = await processor_fn(prompt, attachments or [])
|
| 519 |
+
|
| 520 |
+
# Process any queued messages
|
| 521 |
+
while True:
|
| 522 |
+
next_msg = await manager.dequeue(session_id)
|
| 523 |
+
if not next_msg:
|
| 524 |
+
break
|
| 525 |
+
# Process queued message
|
| 526 |
+
await processor_fn(next_msg.prompt, next_msg.attachments)
|
| 527 |
+
|
| 528 |
+
return result
|
| 529 |
+
|
| 530 |
+
finally:
|
| 531 |
+
manager.release(session_id)
|
src/amcp/multi_agent.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Multi-Agent System for AMCP.
|
| 3 |
+
|
| 4 |
+
This module provides support for multiple agents with different capabilities,
|
| 5 |
+
including Primary agents and Subagents with explicit mode differentiation.
|
| 6 |
+
|
| 7 |
+
Inspired by:
|
| 8 |
+
- OpenCode's agent modes (primary, subagent, explore, plan)
|
| 9 |
+
- Crush's Coordinator pattern with message queuing
|
| 10 |
+
- Kimi-CLI's LaborMarket for agent management
|
| 11 |
+
|
| 12 |
+
Features:
|
| 13 |
+
- AgentMode.PRIMARY / AgentMode.SUBAGENT differentiation
|
| 14 |
+
- Built-in agent configurations for common tasks
|
| 15 |
+
- Agent registry for dynamic agent lookup
|
| 16 |
+
- Support for agent delegation
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from dataclasses import dataclass, field
|
| 22 |
+
from enum import Enum
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class AgentMode(Enum):
|
| 28 |
+
"""Agent execution mode."""
|
| 29 |
+
|
| 30 |
+
PRIMARY = "primary"
|
| 31 |
+
"""Primary agent with full capabilities - can delegate to subagents."""
|
| 32 |
+
|
| 33 |
+
SUBAGENT = "subagent"
|
| 34 |
+
"""Subagent with restricted capabilities - focuses on single tasks."""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class AgentConfig:
|
| 39 |
+
"""Configuration for an agent type.
|
| 40 |
+
|
| 41 |
+
Attributes:
|
| 42 |
+
name: Unique identifier for the agent
|
| 43 |
+
mode: PRIMARY for main agents, SUBAGENT for task-specific agents
|
| 44 |
+
description: Human-readable description of agent's purpose
|
| 45 |
+
system_prompt: System prompt template for the agent
|
| 46 |
+
tools: List of tool names the agent can use
|
| 47 |
+
excluded_tools: Tools explicitly disabled for this agent
|
| 48 |
+
max_steps: Maximum execution steps for this agent
|
| 49 |
+
can_delegate: Whether this agent can spawn subagents
|
| 50 |
+
parent_agent: Name of parent agent (for subagents)
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
name: str
|
| 54 |
+
mode: AgentMode
|
| 55 |
+
description: str
|
| 56 |
+
system_prompt: str
|
| 57 |
+
tools: list[str] = field(default_factory=list)
|
| 58 |
+
excluded_tools: list[str] = field(default_factory=list)
|
| 59 |
+
max_steps: int = 100
|
| 60 |
+
can_delegate: bool = True
|
| 61 |
+
parent_agent: str | None = None
|
| 62 |
+
|
| 63 |
+
def __post_init__(self):
|
| 64 |
+
"""Validate agent configuration."""
|
| 65 |
+
# Subagents cannot delegate by default
|
| 66 |
+
if self.mode == AgentMode.SUBAGENT:
|
| 67 |
+
self.can_delegate = False
|
| 68 |
+
|
| 69 |
+
def get_effective_tools(self, available_tools: list[str]) -> list[str]:
|
| 70 |
+
"""Get the effective list of tools for this agent.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
available_tools: All available tools in the system
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
List of tool names this agent can use
|
| 77 |
+
"""
|
| 78 |
+
if self.tools:
|
| 79 |
+
# Use explicit whitelist
|
| 80 |
+
effective = [t for t in self.tools if t in available_tools]
|
| 81 |
+
else:
|
| 82 |
+
# Start with all available tools
|
| 83 |
+
effective = list(available_tools)
|
| 84 |
+
|
| 85 |
+
# Apply exclusions
|
| 86 |
+
return [t for t in effective if t not in self.excluded_tools]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# Default system prompt templates
|
| 90 |
+
PRIMARY_SYSTEM_PROMPT = """You are {agent_name}, an AI coding assistant with full capabilities.
|
| 91 |
+
|
| 92 |
+
You can use all available tools to help users with software engineering tasks.
|
| 93 |
+
When tasks are complex, you may delegate to specialized subagents.
|
| 94 |
+
|
| 95 |
+
Current working directory: {work_dir}
|
| 96 |
+
Current time: {current_time}
|
| 97 |
+
|
| 98 |
+
Guidelines:
|
| 99 |
+
- Use appropriate tools for each task
|
| 100 |
+
- Read files to understand the codebase before making changes
|
| 101 |
+
- Delegate complex sub-tasks to specialized agents when needed
|
| 102 |
+
- Be precise and efficient in your tool usage
|
| 103 |
+
- Explain your actions when helpful"""
|
| 104 |
+
|
| 105 |
+
EXPLORER_SYSTEM_PROMPT = """You are {agent_name}, a fast codebase exploration agent.
|
| 106 |
+
|
| 107 |
+
Your task is to quickly analyze and understand codebases WITHOUT making changes.
|
| 108 |
+
You have READ-ONLY access to files and search tools.
|
| 109 |
+
|
| 110 |
+
Current working directory: {work_dir}
|
| 111 |
+
Current time: {current_time}
|
| 112 |
+
|
| 113 |
+
Guidelines:
|
| 114 |
+
- Focus on quick exploration and understanding
|
| 115 |
+
- Do NOT attempt to modify any files
|
| 116 |
+
- Summarize your findings concisely
|
| 117 |
+
- Report back to the main agent when done"""
|
| 118 |
+
|
| 119 |
+
PLANNER_SYSTEM_PROMPT = """You are {agent_name}, a planning and analysis agent.
|
| 120 |
+
|
| 121 |
+
Your task is to analyze problems and create execution plans WITHOUT implementing them.
|
| 122 |
+
You have READ-ONLY access to the codebase.
|
| 123 |
+
|
| 124 |
+
Current working directory: {work_dir}
|
| 125 |
+
Current time: {current_time}
|
| 126 |
+
|
| 127 |
+
Guidelines:
|
| 128 |
+
- Create detailed, step-by-step plans
|
| 129 |
+
- Identify potential issues and edge cases
|
| 130 |
+
- Do NOT implement the plan yourself
|
| 131 |
+
- Return a clear, actionable plan to the main agent"""
|
| 132 |
+
|
| 133 |
+
CODER_SYSTEM_PROMPT = """You are {agent_name}, a focused coding agent.
|
| 134 |
+
|
| 135 |
+
Your task is to implement specific code changes as directed.
|
| 136 |
+
You have full write access to the codebase.
|
| 137 |
+
|
| 138 |
+
Current working directory: {work_dir}
|
| 139 |
+
Current time: {current_time}
|
| 140 |
+
|
| 141 |
+
Guidelines:
|
| 142 |
+
- Implement exactly what is requested
|
| 143 |
+
- Follow existing code patterns and style
|
| 144 |
+
- Test your changes when possible
|
| 145 |
+
- Keep changes minimal and focused"""
|
| 146 |
+
|
| 147 |
+
# Built-in agent configurations
|
| 148 |
+
BUILTIN_AGENTS: dict[str, AgentConfig] = {
|
| 149 |
+
"coder": AgentConfig(
|
| 150 |
+
name="coder",
|
| 151 |
+
mode=AgentMode.PRIMARY,
|
| 152 |
+
description="Main coding agent with full capabilities",
|
| 153 |
+
system_prompt=PRIMARY_SYSTEM_PROMPT,
|
| 154 |
+
tools=[], # Empty means all available tools
|
| 155 |
+
excluded_tools=[],
|
| 156 |
+
max_steps=300,
|
| 157 |
+
can_delegate=True,
|
| 158 |
+
),
|
| 159 |
+
"explorer": AgentConfig(
|
| 160 |
+
name="explorer",
|
| 161 |
+
mode=AgentMode.SUBAGENT,
|
| 162 |
+
description="Fast codebase exploration agent (read-only)",
|
| 163 |
+
system_prompt=EXPLORER_SYSTEM_PROMPT,
|
| 164 |
+
tools=["read_file", "grep", "glob", "think"],
|
| 165 |
+
excluded_tools=["write_file", "edit_file", "bash"],
|
| 166 |
+
max_steps=50,
|
| 167 |
+
can_delegate=False,
|
| 168 |
+
),
|
| 169 |
+
"planner": AgentConfig(
|
| 170 |
+
name="planner",
|
| 171 |
+
mode=AgentMode.SUBAGENT,
|
| 172 |
+
description="Planning agent for analysis and strategy (read-only)",
|
| 173 |
+
system_prompt=PLANNER_SYSTEM_PROMPT,
|
| 174 |
+
tools=["read_file", "grep", "glob", "think"],
|
| 175 |
+
excluded_tools=["write_file", "edit_file", "bash"],
|
| 176 |
+
max_steps=30,
|
| 177 |
+
can_delegate=False,
|
| 178 |
+
),
|
| 179 |
+
"focused_coder": AgentConfig(
|
| 180 |
+
name="focused_coder",
|
| 181 |
+
mode=AgentMode.SUBAGENT,
|
| 182 |
+
description="Focused coding agent for specific implementation tasks",
|
| 183 |
+
system_prompt=CODER_SYSTEM_PROMPT,
|
| 184 |
+
tools=["read_file", "grep", "write_file", "edit_file", "bash", "think"],
|
| 185 |
+
excluded_tools=[],
|
| 186 |
+
max_steps=100,
|
| 187 |
+
can_delegate=False,
|
| 188 |
+
),
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
class AgentRegistry:
|
| 193 |
+
"""Registry for managing and looking up agent configurations.
|
| 194 |
+
|
| 195 |
+
This class provides centralized management of agent configurations,
|
| 196 |
+
supporting both built-in and custom agents.
|
| 197 |
+
"""
|
| 198 |
+
|
| 199 |
+
def __init__(self):
|
| 200 |
+
"""Initialize the agent registry with built-in agents."""
|
| 201 |
+
self._agents: dict[str, AgentConfig] = dict(BUILTIN_AGENTS)
|
| 202 |
+
self._custom_agents: dict[str, AgentConfig] = {}
|
| 203 |
+
|
| 204 |
+
def register(self, config: AgentConfig) -> None:
|
| 205 |
+
"""Register a custom agent configuration.
|
| 206 |
+
|
| 207 |
+
Args:
|
| 208 |
+
config: Agent configuration to register
|
| 209 |
+
"""
|
| 210 |
+
self._custom_agents[config.name] = config
|
| 211 |
+
self._agents[config.name] = config
|
| 212 |
+
|
| 213 |
+
def get(self, name: str) -> AgentConfig | None:
|
| 214 |
+
"""Get an agent configuration by name.
|
| 215 |
+
|
| 216 |
+
Args:
|
| 217 |
+
name: Agent name
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
Agent configuration or None if not found
|
| 221 |
+
"""
|
| 222 |
+
return self._agents.get(name)
|
| 223 |
+
|
| 224 |
+
def list_agents(self) -> list[str]:
|
| 225 |
+
"""List all registered agent names.
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
List of agent names
|
| 229 |
+
"""
|
| 230 |
+
return list(self._agents.keys())
|
| 231 |
+
|
| 232 |
+
def list_primary_agents(self) -> list[str]:
|
| 233 |
+
"""List all primary agent names.
|
| 234 |
+
|
| 235 |
+
Returns:
|
| 236 |
+
List of primary agent names
|
| 237 |
+
"""
|
| 238 |
+
return [name for name, cfg in self._agents.items() if cfg.mode == AgentMode.PRIMARY]
|
| 239 |
+
|
| 240 |
+
def list_subagents(self) -> list[str]:
|
| 241 |
+
"""List all subagent names.
|
| 242 |
+
|
| 243 |
+
Returns:
|
| 244 |
+
List of subagent names
|
| 245 |
+
"""
|
| 246 |
+
return [name for name, cfg in self._agents.items() if cfg.mode == AgentMode.SUBAGENT]
|
| 247 |
+
|
| 248 |
+
def get_subagents_for(self, parent_name: str) -> list[str]:
|
| 249 |
+
"""Get subagents that can be used by a parent agent.
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
parent_name: Name of the parent agent
|
| 253 |
+
|
| 254 |
+
Returns:
|
| 255 |
+
List of subagent names available to the parent
|
| 256 |
+
"""
|
| 257 |
+
parent = self.get(parent_name)
|
| 258 |
+
if not parent or not parent.can_delegate:
|
| 259 |
+
return []
|
| 260 |
+
|
| 261 |
+
return self.list_subagents()
|
| 262 |
+
|
| 263 |
+
def load_from_file(self, config_file: Path) -> None:
|
| 264 |
+
"""Load agent configurations from a YAML file.
|
| 265 |
+
|
| 266 |
+
Args:
|
| 267 |
+
config_file: Path to YAML configuration file
|
| 268 |
+
"""
|
| 269 |
+
import yaml
|
| 270 |
+
|
| 271 |
+
if not config_file.exists():
|
| 272 |
+
return
|
| 273 |
+
|
| 274 |
+
try:
|
| 275 |
+
with open(config_file, encoding="utf-8") as f:
|
| 276 |
+
data = yaml.safe_load(f)
|
| 277 |
+
|
| 278 |
+
if not data or "agents" not in data:
|
| 279 |
+
return
|
| 280 |
+
|
| 281 |
+
for agent_data in data.get("agents", []):
|
| 282 |
+
# Parse mode
|
| 283 |
+
mode_str = agent_data.get("mode", "primary")
|
| 284 |
+
mode = AgentMode.PRIMARY if mode_str == "primary" else AgentMode.SUBAGENT
|
| 285 |
+
|
| 286 |
+
config = AgentConfig(
|
| 287 |
+
name=agent_data.get("name", "custom"),
|
| 288 |
+
mode=mode,
|
| 289 |
+
description=agent_data.get("description", ""),
|
| 290 |
+
system_prompt=agent_data.get("system_prompt", PRIMARY_SYSTEM_PROMPT),
|
| 291 |
+
tools=agent_data.get("tools", []),
|
| 292 |
+
excluded_tools=agent_data.get("excluded_tools", []),
|
| 293 |
+
max_steps=agent_data.get("max_steps", 100),
|
| 294 |
+
can_delegate=agent_data.get("can_delegate", mode == AgentMode.PRIMARY),
|
| 295 |
+
parent_agent=agent_data.get("parent_agent"),
|
| 296 |
+
)
|
| 297 |
+
self.register(config)
|
| 298 |
+
|
| 299 |
+
except Exception as e:
|
| 300 |
+
# Log but don't fail
|
| 301 |
+
print(f"Warning: Could not load agent config file: {e}")
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# Global agent registry singleton
|
| 305 |
+
_agent_registry: AgentRegistry | None = None
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def get_agent_registry() -> AgentRegistry:
|
| 309 |
+
"""Get the global agent registry.
|
| 310 |
+
|
| 311 |
+
Returns:
|
| 312 |
+
Global AgentRegistry instance
|
| 313 |
+
"""
|
| 314 |
+
global _agent_registry
|
| 315 |
+
if _agent_registry is None:
|
| 316 |
+
_agent_registry = AgentRegistry()
|
| 317 |
+
return _agent_registry
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def get_agent_config(name: str) -> AgentConfig | None:
|
| 321 |
+
"""Get an agent configuration by name.
|
| 322 |
+
|
| 323 |
+
Args:
|
| 324 |
+
name: Agent name
|
| 325 |
+
|
| 326 |
+
Returns:
|
| 327 |
+
Agent configuration or None if not found
|
| 328 |
+
"""
|
| 329 |
+
return get_agent_registry().get(name)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def create_subagent_config(
|
| 333 |
+
parent_name: str,
|
| 334 |
+
task_description: str,
|
| 335 |
+
tools: list[str] | None = None,
|
| 336 |
+
) -> AgentConfig:
|
| 337 |
+
"""Create a dynamic subagent configuration for a specific task.
|
| 338 |
+
|
| 339 |
+
This is used for creating task-specific agents at runtime.
|
| 340 |
+
|
| 341 |
+
Args:
|
| 342 |
+
parent_name: Name of the parent agent
|
| 343 |
+
task_description: Description of the task for the subagent
|
| 344 |
+
tools: Optional list of tools for the subagent
|
| 345 |
+
|
| 346 |
+
Returns:
|
| 347 |
+
New AgentConfig for the subagent
|
| 348 |
+
"""
|
| 349 |
+
import uuid
|
| 350 |
+
|
| 351 |
+
subagent_id = str(uuid.uuid4())[:8]
|
| 352 |
+
subagent_name = f"task_{subagent_id}"
|
| 353 |
+
|
| 354 |
+
system_prompt = f"""You are a specialized task agent for: {task_description}
|
| 355 |
+
|
| 356 |
+
Complete this specific task and report back when done.
|
| 357 |
+
Be focused and efficient in completing the assigned task.
|
| 358 |
+
|
| 359 |
+
Current working directory: {{work_dir}}
|
| 360 |
+
Current time: {{current_time}}
|
| 361 |
+
|
| 362 |
+
Task: {task_description}
|
| 363 |
+
"""
|
| 364 |
+
|
| 365 |
+
return AgentConfig(
|
| 366 |
+
name=subagent_name,
|
| 367 |
+
mode=AgentMode.SUBAGENT,
|
| 368 |
+
description=task_description,
|
| 369 |
+
system_prompt=system_prompt,
|
| 370 |
+
tools=tools or ["read_file", "grep", "write_file", "edit_file", "think"],
|
| 371 |
+
excluded_tools=[],
|
| 372 |
+
max_steps=50,
|
| 373 |
+
can_delegate=False,
|
| 374 |
+
parent_agent=parent_name,
|
| 375 |
+
)
|
tests/test_agent_spec.py
CHANGED
|
@@ -1,7 +1,247 @@
|
|
| 1 |
-
|
| 2 |
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the agent_spec module."""
|
| 2 |
|
| 3 |
+
import pytest
|
| 4 |
+
from pathlib import Path
|
| 5 |
|
| 6 |
+
from amcp.agent_spec import (
|
| 7 |
+
AgentSpec,
|
| 8 |
+
AgentSpecError,
|
| 9 |
+
ResolvedAgentSpec,
|
| 10 |
+
get_default_agent_spec,
|
| 11 |
+
load_agent_spec,
|
| 12 |
+
list_available_agents,
|
| 13 |
+
)
|
| 14 |
+
from amcp.multi_agent import AgentMode
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class TestGetDefaultAgentSpec:
|
| 18 |
+
"""Tests for get_default_agent_spec function."""
|
| 19 |
+
|
| 20 |
+
def test_returns_resolved_spec(self):
|
| 21 |
+
"""Test that get_default_agent_spec returns a ResolvedAgentSpec."""
|
| 22 |
+
spec = get_default_agent_spec()
|
| 23 |
+
assert isinstance(spec, ResolvedAgentSpec)
|
| 24 |
+
|
| 25 |
+
def test_default_name(self):
|
| 26 |
+
"""Test default agent name."""
|
| 27 |
+
spec = get_default_agent_spec()
|
| 28 |
+
assert spec.name == "default"
|
| 29 |
+
|
| 30 |
+
def test_default_description(self):
|
| 31 |
+
"""Test default agent description."""
|
| 32 |
+
spec = get_default_agent_spec()
|
| 33 |
+
assert spec.description == "Default AMCP agent"
|
| 34 |
+
|
| 35 |
+
def test_default_mode_is_primary(self):
|
| 36 |
+
"""Test that default agent is PRIMARY mode."""
|
| 37 |
+
spec = get_default_agent_spec()
|
| 38 |
+
assert spec.mode == AgentMode.PRIMARY
|
| 39 |
+
|
| 40 |
+
def test_default_can_delegate(self):
|
| 41 |
+
"""Test that default agent can delegate."""
|
| 42 |
+
spec = get_default_agent_spec()
|
| 43 |
+
assert spec.can_delegate is True
|
| 44 |
+
|
| 45 |
+
def test_has_system_prompt(self):
|
| 46 |
+
"""Test that default agent has a system prompt."""
|
| 47 |
+
spec = get_default_agent_spec()
|
| 48 |
+
assert len(spec.system_prompt) > 0
|
| 49 |
+
assert "{work_dir}" in spec.system_prompt
|
| 50 |
+
assert "{current_time}" in spec.system_prompt
|
| 51 |
+
|
| 52 |
+
def test_default_max_steps(self):
|
| 53 |
+
"""Test default max_steps value."""
|
| 54 |
+
spec = get_default_agent_spec()
|
| 55 |
+
assert spec.max_steps == 300
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class TestAgentSpec:
|
| 59 |
+
"""Tests for AgentSpec Pydantic model."""
|
| 60 |
+
|
| 61 |
+
def test_minimal_spec(self):
|
| 62 |
+
"""Test creating a minimal agent spec."""
|
| 63 |
+
spec = AgentSpec(
|
| 64 |
+
name="test",
|
| 65 |
+
system_prompt="You are a test agent.",
|
| 66 |
+
)
|
| 67 |
+
assert spec.name == "test"
|
| 68 |
+
assert spec.mode == "primary" # Default
|
| 69 |
+
assert spec.can_delegate is True # Default
|
| 70 |
+
|
| 71 |
+
def test_spec_with_mode(self):
|
| 72 |
+
"""Test creating spec with explicit mode."""
|
| 73 |
+
spec = AgentSpec(
|
| 74 |
+
name="test",
|
| 75 |
+
system_prompt="Test",
|
| 76 |
+
mode="subagent",
|
| 77 |
+
)
|
| 78 |
+
assert spec.mode == "subagent"
|
| 79 |
+
|
| 80 |
+
def test_spec_with_can_delegate(self):
|
| 81 |
+
"""Test creating spec with can_delegate."""
|
| 82 |
+
spec = AgentSpec(
|
| 83 |
+
name="test",
|
| 84 |
+
system_prompt="Test",
|
| 85 |
+
can_delegate=False,
|
| 86 |
+
)
|
| 87 |
+
assert spec.can_delegate is False
|
| 88 |
+
|
| 89 |
+
def test_default_values(self):
|
| 90 |
+
"""Test default values."""
|
| 91 |
+
spec = AgentSpec(
|
| 92 |
+
name="test",
|
| 93 |
+
system_prompt="Test",
|
| 94 |
+
)
|
| 95 |
+
assert spec.description == ""
|
| 96 |
+
assert spec.tools == []
|
| 97 |
+
assert spec.exclude_tools == []
|
| 98 |
+
assert spec.max_steps == 5
|
| 99 |
+
assert spec.model == ""
|
| 100 |
+
assert spec.base_url == ""
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class TestLoadAgentSpec:
|
| 104 |
+
"""Tests for load_agent_spec function."""
|
| 105 |
+
|
| 106 |
+
def test_load_nonexistent_file(self, tmp_path):
|
| 107 |
+
"""Test loading a non-existent file raises error."""
|
| 108 |
+
with pytest.raises(AgentSpecError, match="not found"):
|
| 109 |
+
load_agent_spec(tmp_path / "nonexistent.yaml")
|
| 110 |
+
|
| 111 |
+
def test_load_empty_file(self, tmp_path):
|
| 112 |
+
"""Test loading an empty file raises error."""
|
| 113 |
+
empty_file = tmp_path / "empty.yaml"
|
| 114 |
+
empty_file.write_text("")
|
| 115 |
+
|
| 116 |
+
with pytest.raises(AgentSpecError, match="Empty"):
|
| 117 |
+
load_agent_spec(empty_file)
|
| 118 |
+
|
| 119 |
+
def test_load_invalid_yaml(self, tmp_path):
|
| 120 |
+
"""Test loading invalid YAML raises error."""
|
| 121 |
+
invalid_file = tmp_path / "invalid.yaml"
|
| 122 |
+
invalid_file.write_text("name: [invalid")
|
| 123 |
+
|
| 124 |
+
with pytest.raises(AgentSpecError, match="Invalid YAML"):
|
| 125 |
+
load_agent_spec(invalid_file)
|
| 126 |
+
|
| 127 |
+
def test_load_valid_spec(self, tmp_path):
|
| 128 |
+
"""Test loading a valid agent spec."""
|
| 129 |
+
spec_file = tmp_path / "agent.yaml"
|
| 130 |
+
spec_file.write_text("""
|
| 131 |
+
name: test_agent
|
| 132 |
+
description: A test agent
|
| 133 |
+
mode: primary
|
| 134 |
+
system_prompt: You are a test agent.
|
| 135 |
+
tools:
|
| 136 |
+
- read_file
|
| 137 |
+
- grep
|
| 138 |
+
max_steps: 50
|
| 139 |
+
can_delegate: true
|
| 140 |
+
""")
|
| 141 |
+
spec = load_agent_spec(spec_file)
|
| 142 |
+
assert spec.name == "test_agent"
|
| 143 |
+
assert spec.description == "A test agent"
|
| 144 |
+
assert spec.mode == AgentMode.PRIMARY
|
| 145 |
+
assert "read_file" in spec.tools
|
| 146 |
+
assert spec.max_steps == 50
|
| 147 |
+
assert spec.can_delegate is True
|
| 148 |
+
|
| 149 |
+
def test_load_subagent_spec(self, tmp_path):
|
| 150 |
+
"""Test loading a subagent spec sets can_delegate to False."""
|
| 151 |
+
spec_file = tmp_path / "subagent.yaml"
|
| 152 |
+
spec_file.write_text("""
|
| 153 |
+
name: test_subagent
|
| 154 |
+
mode: subagent
|
| 155 |
+
system_prompt: You are a test subagent.
|
| 156 |
+
can_delegate: true # This should be overridden
|
| 157 |
+
""")
|
| 158 |
+
spec = load_agent_spec(spec_file)
|
| 159 |
+
assert spec.mode == AgentMode.SUBAGENT
|
| 160 |
+
assert spec.can_delegate is False # Should be overridden
|
| 161 |
+
|
| 162 |
+
def test_load_spec_with_template(self, tmp_path):
|
| 163 |
+
"""Test loading spec with system prompt template."""
|
| 164 |
+
spec_file = tmp_path / "template.yaml"
|
| 165 |
+
spec_file.write_text("""
|
| 166 |
+
name: template_agent
|
| 167 |
+
system_prompt: ""
|
| 168 |
+
system_prompt_template: "Hello, {name}! You work in {location}."
|
| 169 |
+
system_prompt_vars:
|
| 170 |
+
name: TestBot
|
| 171 |
+
location: TestLand
|
| 172 |
+
""")
|
| 173 |
+
spec = load_agent_spec(spec_file)
|
| 174 |
+
assert "Hello, TestBot!" in spec.system_prompt
|
| 175 |
+
assert "TestLand" in spec.system_prompt
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class TestListAvailableAgents:
|
| 179 |
+
"""Tests for list_available_agents function."""
|
| 180 |
+
|
| 181 |
+
def test_empty_directory(self, tmp_path):
|
| 182 |
+
"""Test listing agents in empty directory."""
|
| 183 |
+
agents = list_available_agents(tmp_path)
|
| 184 |
+
assert agents == []
|
| 185 |
+
|
| 186 |
+
def test_nonexistent_directory(self, tmp_path):
|
| 187 |
+
"""Test listing agents in non-existent directory."""
|
| 188 |
+
agents = list_available_agents(tmp_path / "nonexistent")
|
| 189 |
+
assert agents == []
|
| 190 |
+
|
| 191 |
+
def test_finds_yaml_files(self, tmp_path):
|
| 192 |
+
"""Test that YAML files are found."""
|
| 193 |
+
(tmp_path / "agent1.yaml").write_text("name: agent1\nsystem_prompt: test")
|
| 194 |
+
(tmp_path / "agent2.yaml").write_text("name: agent2\nsystem_prompt: test")
|
| 195 |
+
(tmp_path / "not_yaml.txt").write_text("not yaml")
|
| 196 |
+
|
| 197 |
+
agents = list_available_agents(tmp_path)
|
| 198 |
+
assert len(agents) == 2
|
| 199 |
+
assert any(a.name == "agent1.yaml" for a in agents)
|
| 200 |
+
assert any(a.name == "agent2.yaml" for a in agents)
|
| 201 |
+
|
| 202 |
+
def test_finds_nested_yaml_files(self, tmp_path):
|
| 203 |
+
"""Test that nested YAML files are found."""
|
| 204 |
+
subdir = tmp_path / "subdir"
|
| 205 |
+
subdir.mkdir()
|
| 206 |
+
(subdir / "nested.yaml").write_text("name: nested\nsystem_prompt: test")
|
| 207 |
+
|
| 208 |
+
agents = list_available_agents(tmp_path)
|
| 209 |
+
assert len(agents) == 1
|
| 210 |
+
assert "nested.yaml" in str(agents[0])
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
class TestResolvedAgentSpec:
|
| 214 |
+
"""Tests for ResolvedAgentSpec dataclass."""
|
| 215 |
+
|
| 216 |
+
def test_all_fields(self):
|
| 217 |
+
"""Test creating with all fields."""
|
| 218 |
+
spec = ResolvedAgentSpec(
|
| 219 |
+
name="test",
|
| 220 |
+
description="Test description",
|
| 221 |
+
mode=AgentMode.PRIMARY,
|
| 222 |
+
system_prompt="Test prompt",
|
| 223 |
+
tools=["read_file"],
|
| 224 |
+
exclude_tools=["bash"],
|
| 225 |
+
max_steps=100,
|
| 226 |
+
model="test-model",
|
| 227 |
+
base_url="https://test.api",
|
| 228 |
+
can_delegate=True,
|
| 229 |
+
)
|
| 230 |
+
assert spec.name == "test"
|
| 231 |
+
assert spec.mode == AgentMode.PRIMARY
|
| 232 |
+
assert spec.can_delegate is True
|
| 233 |
+
|
| 234 |
+
def test_default_can_delegate(self):
|
| 235 |
+
"""Test that can_delegate defaults to True."""
|
| 236 |
+
spec = ResolvedAgentSpec(
|
| 237 |
+
name="test",
|
| 238 |
+
description="",
|
| 239 |
+
mode=AgentMode.PRIMARY,
|
| 240 |
+
system_prompt="",
|
| 241 |
+
tools=[],
|
| 242 |
+
exclude_tools=[],
|
| 243 |
+
max_steps=100,
|
| 244 |
+
model="",
|
| 245 |
+
base_url="",
|
| 246 |
+
)
|
| 247 |
+
assert spec.can_delegate is True
|
tests/test_message_queue.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the message_queue module."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from amcp.message_queue import (
|
| 8 |
+
MessagePriority,
|
| 9 |
+
MessageQueueManager,
|
| 10 |
+
QueuedMessage,
|
| 11 |
+
SessionQueue,
|
| 12 |
+
get_message_queue_manager,
|
| 13 |
+
run_with_queue,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class TestMessagePriority:
|
| 18 |
+
"""Tests for MessagePriority enum."""
|
| 19 |
+
|
| 20 |
+
def test_priority_values(self):
|
| 21 |
+
"""Test that priority values are ordered correctly."""
|
| 22 |
+
assert MessagePriority.LOW.value < MessagePriority.NORMAL.value
|
| 23 |
+
assert MessagePriority.NORMAL.value < MessagePriority.HIGH.value
|
| 24 |
+
assert MessagePriority.HIGH.value < MessagePriority.URGENT.value
|
| 25 |
+
|
| 26 |
+
def test_priority_comparison(self):
|
| 27 |
+
"""Test priority comparison."""
|
| 28 |
+
assert MessagePriority.URGENT.value > MessagePriority.LOW.value
|
| 29 |
+
assert MessagePriority.NORMAL.value == 1
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class TestQueuedMessage:
|
| 33 |
+
"""Tests for QueuedMessage dataclass."""
|
| 34 |
+
|
| 35 |
+
def test_create_with_defaults(self):
|
| 36 |
+
"""Test creating a message with default values."""
|
| 37 |
+
msg = QueuedMessage.create(
|
| 38 |
+
session_id="test-session",
|
| 39 |
+
prompt="Hello, world!",
|
| 40 |
+
)
|
| 41 |
+
assert msg.session_id == "test-session"
|
| 42 |
+
assert msg.prompt == "Hello, world!"
|
| 43 |
+
assert msg.priority == MessagePriority.NORMAL
|
| 44 |
+
assert msg.attachments == []
|
| 45 |
+
assert msg.metadata == {}
|
| 46 |
+
assert msg.id is not None
|
| 47 |
+
assert msg.created_at is not None
|
| 48 |
+
|
| 49 |
+
def test_create_with_priority(self):
|
| 50 |
+
"""Test creating a message with custom priority."""
|
| 51 |
+
msg = QueuedMessage.create(
|
| 52 |
+
session_id="test-session",
|
| 53 |
+
prompt="Urgent!",
|
| 54 |
+
priority=MessagePriority.URGENT,
|
| 55 |
+
)
|
| 56 |
+
assert msg.priority == MessagePriority.URGENT
|
| 57 |
+
|
| 58 |
+
def test_create_with_attachments(self):
|
| 59 |
+
"""Test creating a message with attachments."""
|
| 60 |
+
attachments = [{"file": "test.txt", "content": "Hello"}]
|
| 61 |
+
msg = QueuedMessage.create(
|
| 62 |
+
session_id="test-session",
|
| 63 |
+
prompt="With attachment",
|
| 64 |
+
attachments=attachments,
|
| 65 |
+
)
|
| 66 |
+
assert msg.attachments == attachments
|
| 67 |
+
|
| 68 |
+
def test_create_with_metadata(self):
|
| 69 |
+
"""Test creating a message with metadata."""
|
| 70 |
+
msg = QueuedMessage.create(
|
| 71 |
+
session_id="test-session",
|
| 72 |
+
prompt="Test",
|
| 73 |
+
work_dir="/home/user",
|
| 74 |
+
stream=True,
|
| 75 |
+
)
|
| 76 |
+
assert msg.metadata["work_dir"] == "/home/user"
|
| 77 |
+
assert msg.metadata["stream"] is True
|
| 78 |
+
|
| 79 |
+
def test_unique_ids(self):
|
| 80 |
+
"""Test that each message gets a unique ID."""
|
| 81 |
+
msg1 = QueuedMessage.create("session", "Prompt 1")
|
| 82 |
+
msg2 = QueuedMessage.create("session", "Prompt 2")
|
| 83 |
+
assert msg1.id != msg2.id
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class TestSessionQueue:
|
| 87 |
+
"""Tests for SessionQueue class."""
|
| 88 |
+
|
| 89 |
+
@pytest.fixture
|
| 90 |
+
def queue(self):
|
| 91 |
+
"""Create a fresh queue for each test."""
|
| 92 |
+
return SessionQueue("test-session")
|
| 93 |
+
|
| 94 |
+
@pytest.mark.asyncio
|
| 95 |
+
async def test_enqueue_dequeue(self, queue):
|
| 96 |
+
"""Test basic enqueue and dequeue."""
|
| 97 |
+
msg = QueuedMessage.create("test-session", "Hello")
|
| 98 |
+
await queue.enqueue(msg)
|
| 99 |
+
assert len(queue) == 1
|
| 100 |
+
|
| 101 |
+
dequeued = await queue.dequeue()
|
| 102 |
+
assert dequeued is not None
|
| 103 |
+
assert dequeued.prompt == "Hello"
|
| 104 |
+
assert len(queue) == 0
|
| 105 |
+
|
| 106 |
+
@pytest.mark.asyncio
|
| 107 |
+
async def test_is_empty(self, queue):
|
| 108 |
+
"""Test is_empty method."""
|
| 109 |
+
assert queue.is_empty()
|
| 110 |
+
await queue.enqueue(QueuedMessage.create("test-session", "Hello"))
|
| 111 |
+
assert not queue.is_empty()
|
| 112 |
+
|
| 113 |
+
@pytest.mark.asyncio
|
| 114 |
+
async def test_priority_ordering(self, queue):
|
| 115 |
+
"""Test that messages are ordered by priority."""
|
| 116 |
+
await queue.enqueue(QueuedMessage.create("s", "Normal", priority=MessagePriority.NORMAL))
|
| 117 |
+
await queue.enqueue(QueuedMessage.create("s", "Low", priority=MessagePriority.LOW))
|
| 118 |
+
await queue.enqueue(QueuedMessage.create("s", "Urgent", priority=MessagePriority.URGENT))
|
| 119 |
+
await queue.enqueue(QueuedMessage.create("s", "High", priority=MessagePriority.HIGH))
|
| 120 |
+
|
| 121 |
+
# Should dequeue in priority order: URGENT, HIGH, NORMAL, LOW
|
| 122 |
+
prompts = []
|
| 123 |
+
while not queue.is_empty():
|
| 124 |
+
msg = await queue.dequeue()
|
| 125 |
+
prompts.append(msg.prompt)
|
| 126 |
+
|
| 127 |
+
assert prompts == ["Urgent", "High", "Normal", "Low"]
|
| 128 |
+
|
| 129 |
+
@pytest.mark.asyncio
|
| 130 |
+
async def test_peek(self, queue):
|
| 131 |
+
"""Test peek without removing."""
|
| 132 |
+
msg = QueuedMessage.create("test-session", "Hello")
|
| 133 |
+
await queue.enqueue(msg)
|
| 134 |
+
|
| 135 |
+
peeked = await queue.peek()
|
| 136 |
+
assert peeked is not None
|
| 137 |
+
assert peeked.prompt == "Hello"
|
| 138 |
+
assert len(queue) == 1 # Still in queue
|
| 139 |
+
|
| 140 |
+
@pytest.mark.asyncio
|
| 141 |
+
async def test_dequeue_empty(self, queue):
|
| 142 |
+
"""Test dequeue from empty queue returns None."""
|
| 143 |
+
result = await queue.dequeue()
|
| 144 |
+
assert result is None
|
| 145 |
+
|
| 146 |
+
@pytest.mark.asyncio
|
| 147 |
+
async def test_clear(self, queue):
|
| 148 |
+
"""Test clearing the queue."""
|
| 149 |
+
await queue.enqueue(QueuedMessage.create("s", "1"))
|
| 150 |
+
await queue.enqueue(QueuedMessage.create("s", "2"))
|
| 151 |
+
await queue.enqueue(QueuedMessage.create("s", "3"))
|
| 152 |
+
|
| 153 |
+
count = await queue.clear()
|
| 154 |
+
assert count == 3
|
| 155 |
+
assert queue.is_empty()
|
| 156 |
+
|
| 157 |
+
def test_list_messages(self, queue):
|
| 158 |
+
"""Test listing messages without async."""
|
| 159 |
+
# We need to run async operations
|
| 160 |
+
asyncio.run(queue.enqueue(QueuedMessage.create("s", "Hello")))
|
| 161 |
+
messages = queue.list_messages()
|
| 162 |
+
assert len(messages) == 1
|
| 163 |
+
assert messages[0].prompt == "Hello"
|
| 164 |
+
|
| 165 |
+
def test_list_prompts(self, queue):
|
| 166 |
+
"""Test listing prompts."""
|
| 167 |
+
asyncio.run(queue.enqueue(QueuedMessage.create("s", "Hello")))
|
| 168 |
+
asyncio.run(queue.enqueue(QueuedMessage.create("s", "World")))
|
| 169 |
+
prompts = queue.list_prompts()
|
| 170 |
+
assert prompts == ["Hello", "World"]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class TestMessageQueueManager:
|
| 174 |
+
"""Tests for MessageQueueManager class."""
|
| 175 |
+
|
| 176 |
+
@pytest.fixture
|
| 177 |
+
def manager(self):
|
| 178 |
+
"""Create a fresh manager for each test."""
|
| 179 |
+
return MessageQueueManager()
|
| 180 |
+
|
| 181 |
+
@pytest.mark.asyncio
|
| 182 |
+
async def test_acquire_release(self, manager):
|
| 183 |
+
"""Test session acquire and release."""
|
| 184 |
+
assert not manager.is_busy("session-1")
|
| 185 |
+
|
| 186 |
+
acquired = await manager.acquire("session-1")
|
| 187 |
+
assert acquired
|
| 188 |
+
assert manager.is_busy("session-1")
|
| 189 |
+
|
| 190 |
+
manager.release("session-1")
|
| 191 |
+
assert not manager.is_busy("session-1")
|
| 192 |
+
|
| 193 |
+
@pytest.mark.asyncio
|
| 194 |
+
async def test_acquire_fails_when_busy(self, manager):
|
| 195 |
+
"""Test that acquire fails when session is already busy."""
|
| 196 |
+
await manager.acquire("session-1")
|
| 197 |
+
assert manager.is_busy("session-1")
|
| 198 |
+
|
| 199 |
+
# Try to acquire again
|
| 200 |
+
acquired_again = await manager.acquire("session-1")
|
| 201 |
+
assert not acquired_again
|
| 202 |
+
|
| 203 |
+
@pytest.mark.asyncio
|
| 204 |
+
async def test_multiple_sessions(self, manager):
|
| 205 |
+
"""Test managing multiple sessions."""
|
| 206 |
+
await manager.acquire("session-1")
|
| 207 |
+
await manager.acquire("session-2")
|
| 208 |
+
|
| 209 |
+
assert manager.is_busy("session-1")
|
| 210 |
+
assert manager.is_busy("session-2")
|
| 211 |
+
assert not manager.is_busy("session-3")
|
| 212 |
+
|
| 213 |
+
busy_sessions = manager.get_busy_sessions()
|
| 214 |
+
assert "session-1" in busy_sessions
|
| 215 |
+
assert "session-2" in busy_sessions
|
| 216 |
+
|
| 217 |
+
@pytest.mark.asyncio
|
| 218 |
+
async def test_any_busy(self, manager):
|
| 219 |
+
"""Test any_busy method."""
|
| 220 |
+
assert not manager.any_busy()
|
| 221 |
+
|
| 222 |
+
await manager.acquire("session-1")
|
| 223 |
+
assert manager.any_busy()
|
| 224 |
+
|
| 225 |
+
manager.release("session-1")
|
| 226 |
+
assert not manager.any_busy()
|
| 227 |
+
|
| 228 |
+
@pytest.mark.asyncio
|
| 229 |
+
async def test_enqueue(self, manager):
|
| 230 |
+
"""Test enqueueing messages."""
|
| 231 |
+
msg = await manager.enqueue("session-1", "Hello")
|
| 232 |
+
assert msg.prompt == "Hello"
|
| 233 |
+
assert manager.queued_count("session-1") == 1
|
| 234 |
+
|
| 235 |
+
@pytest.mark.asyncio
|
| 236 |
+
async def test_enqueue_if_busy(self, manager):
|
| 237 |
+
"""Test enqueue_if_busy method."""
|
| 238 |
+
# Not busy - should not queue
|
| 239 |
+
was_queued, msg = await manager.enqueue_if_busy("session-1", "Hello")
|
| 240 |
+
assert not was_queued
|
| 241 |
+
assert msg is None
|
| 242 |
+
|
| 243 |
+
# Now make it busy
|
| 244 |
+
await manager.acquire("session-1")
|
| 245 |
+
|
| 246 |
+
# Should queue now
|
| 247 |
+
was_queued, msg = await manager.enqueue_if_busy("session-1", "World")
|
| 248 |
+
assert was_queued
|
| 249 |
+
assert msg is not None
|
| 250 |
+
assert msg.prompt == "World"
|
| 251 |
+
|
| 252 |
+
@pytest.mark.asyncio
|
| 253 |
+
async def test_dequeue(self, manager):
|
| 254 |
+
"""Test dequeueing messages."""
|
| 255 |
+
await manager.enqueue("session-1", "Hello")
|
| 256 |
+
await manager.enqueue("session-1", "World")
|
| 257 |
+
|
| 258 |
+
msg = await manager.dequeue("session-1")
|
| 259 |
+
assert msg.prompt == "Hello"
|
| 260 |
+
|
| 261 |
+
msg = await manager.dequeue("session-1")
|
| 262 |
+
assert msg.prompt == "World"
|
| 263 |
+
|
| 264 |
+
msg = await manager.dequeue("session-1")
|
| 265 |
+
assert msg is None
|
| 266 |
+
|
| 267 |
+
@pytest.mark.asyncio
|
| 268 |
+
async def test_peek(self, manager):
|
| 269 |
+
"""Test peeking messages."""
|
| 270 |
+
await manager.enqueue("session-1", "Hello")
|
| 271 |
+
|
| 272 |
+
msg = await manager.peek("session-1")
|
| 273 |
+
assert msg.prompt == "Hello"
|
| 274 |
+
assert manager.queued_count("session-1") == 1
|
| 275 |
+
|
| 276 |
+
@pytest.mark.asyncio
|
| 277 |
+
async def test_clear_queue(self, manager):
|
| 278 |
+
"""Test clearing a session's queue."""
|
| 279 |
+
await manager.enqueue("session-1", "1")
|
| 280 |
+
await manager.enqueue("session-1", "2")
|
| 281 |
+
await manager.enqueue("session-1", "3")
|
| 282 |
+
|
| 283 |
+
count = await manager.clear_queue("session-1")
|
| 284 |
+
assert count == 3
|
| 285 |
+
assert manager.queued_count("session-1") == 0
|
| 286 |
+
|
| 287 |
+
def test_queued_count_empty(self, manager):
|
| 288 |
+
"""Test queued_count for non-existent session."""
|
| 289 |
+
assert manager.queued_count("nonexistent") == 0
|
| 290 |
+
|
| 291 |
+
def test_queued_prompts_empty(self, manager):
|
| 292 |
+
"""Test queued_prompts for non-existent session."""
|
| 293 |
+
assert manager.queued_prompts("nonexistent") == []
|
| 294 |
+
|
| 295 |
+
@pytest.mark.asyncio
|
| 296 |
+
async def test_queued_prompts(self, manager):
|
| 297 |
+
"""Test queued_prompts."""
|
| 298 |
+
await manager.enqueue("session-1", "Hello")
|
| 299 |
+
await manager.enqueue("session-1", "World")
|
| 300 |
+
|
| 301 |
+
prompts = manager.queued_prompts("session-1")
|
| 302 |
+
assert prompts == ["Hello", "World"]
|
| 303 |
+
|
| 304 |
+
@pytest.mark.asyncio
|
| 305 |
+
async def test_get_queue_status(self, manager):
|
| 306 |
+
"""Test get_queue_status method."""
|
| 307 |
+
await manager.enqueue("session-1", "Hello")
|
| 308 |
+
await manager.acquire("session-1")
|
| 309 |
+
|
| 310 |
+
status = manager.get_queue_status("session-1")
|
| 311 |
+
assert status["session_id"] == "session-1"
|
| 312 |
+
assert status["is_busy"] is True
|
| 313 |
+
assert status["queued_count"] == 1
|
| 314 |
+
assert "Hello" in status["queued_prompts"]
|
| 315 |
+
|
| 316 |
+
@pytest.mark.asyncio
|
| 317 |
+
async def test_get_all_status(self, manager):
|
| 318 |
+
"""Test get_all_status method."""
|
| 319 |
+
await manager.acquire("session-1")
|
| 320 |
+
await manager.enqueue("session-2", "Hello")
|
| 321 |
+
|
| 322 |
+
status = manager.get_all_status()
|
| 323 |
+
assert "session-1" in status["busy_sessions"]
|
| 324 |
+
assert status["total_queued"] == 1
|
| 325 |
+
assert "sessions" in status
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
class TestGlobalQueueManager:
|
| 329 |
+
"""Tests for global queue manager singleton."""
|
| 330 |
+
|
| 331 |
+
def test_singleton(self):
|
| 332 |
+
"""Test that get_message_queue_manager returns a singleton."""
|
| 333 |
+
manager1 = get_message_queue_manager()
|
| 334 |
+
manager2 = get_message_queue_manager()
|
| 335 |
+
assert manager1 is manager2
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
class TestRunWithQueue:
|
| 339 |
+
"""Tests for run_with_queue helper function."""
|
| 340 |
+
|
| 341 |
+
@pytest.mark.asyncio
|
| 342 |
+
async def test_processes_immediately_when_not_busy(self):
|
| 343 |
+
"""Test that messages are processed immediately when not busy."""
|
| 344 |
+
processed = []
|
| 345 |
+
|
| 346 |
+
async def processor(prompt, attachments):
|
| 347 |
+
processed.append(prompt)
|
| 348 |
+
return f"Result: {prompt}"
|
| 349 |
+
|
| 350 |
+
# Use a unique session for this test
|
| 351 |
+
result = await run_with_queue("run-test-1", "Hello", processor)
|
| 352 |
+
assert result == "Result: Hello"
|
| 353 |
+
assert "Hello" in processed
|
| 354 |
+
|
| 355 |
+
@pytest.mark.asyncio
|
| 356 |
+
async def test_returns_none_when_queued(self):
|
| 357 |
+
"""Test that queued messages return None."""
|
| 358 |
+
manager = get_message_queue_manager()
|
| 359 |
+
|
| 360 |
+
# Acquire the session first
|
| 361 |
+
await manager.acquire("run-test-2")
|
| 362 |
+
|
| 363 |
+
async def processor(prompt, attachments):
|
| 364 |
+
return f"Result: {prompt}"
|
| 365 |
+
|
| 366 |
+
try:
|
| 367 |
+
result = await run_with_queue("run-test-2", "Hello", processor)
|
| 368 |
+
assert result is None # Queued, not processed
|
| 369 |
+
finally:
|
| 370 |
+
manager.release("run-test-2")
|
| 371 |
+
# Clean up
|
| 372 |
+
await manager.clear_queue("run-test-2")
|
tests/test_multi_agent.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the multi_agent module."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from amcp.multi_agent import (
|
| 6 |
+
AgentConfig,
|
| 7 |
+
AgentMode,
|
| 8 |
+
AgentRegistry,
|
| 9 |
+
BUILTIN_AGENTS,
|
| 10 |
+
PRIMARY_SYSTEM_PROMPT,
|
| 11 |
+
create_subagent_config,
|
| 12 |
+
get_agent_config,
|
| 13 |
+
get_agent_registry,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class TestAgentMode:
|
| 18 |
+
"""Tests for AgentMode enum."""
|
| 19 |
+
|
| 20 |
+
def test_mode_values(self):
|
| 21 |
+
"""Test that mode values are correct."""
|
| 22 |
+
assert AgentMode.PRIMARY.value == "primary"
|
| 23 |
+
assert AgentMode.SUBAGENT.value == "subagent"
|
| 24 |
+
|
| 25 |
+
def test_mode_comparison(self):
|
| 26 |
+
"""Test mode comparison."""
|
| 27 |
+
assert AgentMode.PRIMARY != AgentMode.SUBAGENT
|
| 28 |
+
assert AgentMode.PRIMARY == AgentMode.PRIMARY
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class TestAgentConfig:
|
| 32 |
+
"""Tests for AgentConfig dataclass."""
|
| 33 |
+
|
| 34 |
+
def test_basic_creation(self):
|
| 35 |
+
"""Test basic AgentConfig creation."""
|
| 36 |
+
config = AgentConfig(
|
| 37 |
+
name="test_agent",
|
| 38 |
+
mode=AgentMode.PRIMARY,
|
| 39 |
+
description="Test agent",
|
| 40 |
+
system_prompt="You are a test agent.",
|
| 41 |
+
)
|
| 42 |
+
assert config.name == "test_agent"
|
| 43 |
+
assert config.mode == AgentMode.PRIMARY
|
| 44 |
+
assert config.description == "Test agent"
|
| 45 |
+
assert config.can_delegate is True # Default for PRIMARY
|
| 46 |
+
|
| 47 |
+
def test_subagent_cannot_delegate(self):
|
| 48 |
+
"""Test that subagents have can_delegate set to False by __post_init__."""
|
| 49 |
+
config = AgentConfig(
|
| 50 |
+
name="test_subagent",
|
| 51 |
+
mode=AgentMode.SUBAGENT,
|
| 52 |
+
description="Test subagent",
|
| 53 |
+
system_prompt="You are a test subagent.",
|
| 54 |
+
can_delegate=True, # This should be overridden
|
| 55 |
+
)
|
| 56 |
+
# __post_init__ should set can_delegate to False for subagents
|
| 57 |
+
assert config.can_delegate is False
|
| 58 |
+
|
| 59 |
+
def test_default_values(self):
|
| 60 |
+
"""Test default values are set correctly."""
|
| 61 |
+
config = AgentConfig(
|
| 62 |
+
name="test",
|
| 63 |
+
mode=AgentMode.PRIMARY,
|
| 64 |
+
description="Test",
|
| 65 |
+
system_prompt="Test prompt",
|
| 66 |
+
)
|
| 67 |
+
assert config.tools == []
|
| 68 |
+
assert config.excluded_tools == []
|
| 69 |
+
assert config.max_steps == 100
|
| 70 |
+
assert config.parent_agent is None
|
| 71 |
+
|
| 72 |
+
def test_get_effective_tools_with_whitelist(self):
|
| 73 |
+
"""Test get_effective_tools with explicit tool whitelist."""
|
| 74 |
+
config = AgentConfig(
|
| 75 |
+
name="test",
|
| 76 |
+
mode=AgentMode.SUBAGENT,
|
| 77 |
+
description="Test",
|
| 78 |
+
system_prompt="Test",
|
| 79 |
+
tools=["read_file", "grep"],
|
| 80 |
+
)
|
| 81 |
+
available = ["read_file", "grep", "bash", "write_file"]
|
| 82 |
+
effective = config.get_effective_tools(available)
|
| 83 |
+
assert effective == ["read_file", "grep"]
|
| 84 |
+
|
| 85 |
+
def test_get_effective_tools_with_exclusions(self):
|
| 86 |
+
"""Test get_effective_tools with tool exclusions."""
|
| 87 |
+
config = AgentConfig(
|
| 88 |
+
name="test",
|
| 89 |
+
mode=AgentMode.PRIMARY,
|
| 90 |
+
description="Test",
|
| 91 |
+
system_prompt="Test",
|
| 92 |
+
tools=[], # Empty means all
|
| 93 |
+
excluded_tools=["bash", "write_file"],
|
| 94 |
+
)
|
| 95 |
+
available = ["read_file", "grep", "bash", "write_file"]
|
| 96 |
+
effective = config.get_effective_tools(available)
|
| 97 |
+
assert "read_file" in effective
|
| 98 |
+
assert "grep" in effective
|
| 99 |
+
assert "bash" not in effective
|
| 100 |
+
assert "write_file" not in effective
|
| 101 |
+
|
| 102 |
+
def test_get_effective_tools_whitelist_and_exclusions(self):
|
| 103 |
+
"""Test get_effective_tools with both whitelist and exclusions."""
|
| 104 |
+
config = AgentConfig(
|
| 105 |
+
name="test",
|
| 106 |
+
mode=AgentMode.SUBAGENT,
|
| 107 |
+
description="Test",
|
| 108 |
+
system_prompt="Test",
|
| 109 |
+
tools=["read_file", "grep", "bash"],
|
| 110 |
+
excluded_tools=["bash"],
|
| 111 |
+
)
|
| 112 |
+
available = ["read_file", "grep", "bash", "write_file"]
|
| 113 |
+
effective = config.get_effective_tools(available)
|
| 114 |
+
assert effective == ["read_file", "grep"]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class TestBuiltinAgents:
|
| 118 |
+
"""Tests for built-in agent configurations."""
|
| 119 |
+
|
| 120 |
+
def test_coder_agent(self):
|
| 121 |
+
"""Test coder agent configuration."""
|
| 122 |
+
assert "coder" in BUILTIN_AGENTS
|
| 123 |
+
coder = BUILTIN_AGENTS["coder"]
|
| 124 |
+
assert coder.mode == AgentMode.PRIMARY
|
| 125 |
+
assert coder.can_delegate is True
|
| 126 |
+
assert coder.max_steps == 300
|
| 127 |
+
assert coder.tools == [] # All tools available
|
| 128 |
+
|
| 129 |
+
def test_explorer_agent(self):
|
| 130 |
+
"""Test explorer agent configuration."""
|
| 131 |
+
assert "explorer" in BUILTIN_AGENTS
|
| 132 |
+
explorer = BUILTIN_AGENTS["explorer"]
|
| 133 |
+
assert explorer.mode == AgentMode.SUBAGENT
|
| 134 |
+
assert explorer.can_delegate is False
|
| 135 |
+
assert "read_file" in explorer.tools
|
| 136 |
+
assert "grep" in explorer.tools
|
| 137 |
+
assert "write_file" in explorer.excluded_tools
|
| 138 |
+
assert "edit_file" in explorer.excluded_tools
|
| 139 |
+
assert "bash" in explorer.excluded_tools
|
| 140 |
+
|
| 141 |
+
def test_planner_agent(self):
|
| 142 |
+
"""Test planner agent configuration."""
|
| 143 |
+
assert "planner" in BUILTIN_AGENTS
|
| 144 |
+
planner = BUILTIN_AGENTS["planner"]
|
| 145 |
+
assert planner.mode == AgentMode.SUBAGENT
|
| 146 |
+
assert planner.can_delegate is False
|
| 147 |
+
assert planner.max_steps == 30
|
| 148 |
+
|
| 149 |
+
def test_focused_coder_agent(self):
|
| 150 |
+
"""Test focused_coder agent configuration."""
|
| 151 |
+
assert "focused_coder" in BUILTIN_AGENTS
|
| 152 |
+
focused = BUILTIN_AGENTS["focused_coder"]
|
| 153 |
+
assert focused.mode == AgentMode.SUBAGENT
|
| 154 |
+
assert "write_file" in focused.tools
|
| 155 |
+
assert "edit_file" in focused.tools
|
| 156 |
+
assert "bash" in focused.tools
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class TestAgentRegistry:
|
| 160 |
+
"""Tests for AgentRegistry."""
|
| 161 |
+
|
| 162 |
+
def test_init_with_builtin_agents(self):
|
| 163 |
+
"""Test registry is initialized with built-in agents."""
|
| 164 |
+
registry = AgentRegistry()
|
| 165 |
+
assert "coder" in registry.list_agents()
|
| 166 |
+
assert "explorer" in registry.list_agents()
|
| 167 |
+
assert "planner" in registry.list_agents()
|
| 168 |
+
assert "focused_coder" in registry.list_agents()
|
| 169 |
+
|
| 170 |
+
def test_register_custom_agent(self):
|
| 171 |
+
"""Test registering a custom agent."""
|
| 172 |
+
registry = AgentRegistry()
|
| 173 |
+
custom = AgentConfig(
|
| 174 |
+
name="custom_agent",
|
| 175 |
+
mode=AgentMode.PRIMARY,
|
| 176 |
+
description="Custom test agent",
|
| 177 |
+
system_prompt="Custom prompt",
|
| 178 |
+
)
|
| 179 |
+
registry.register(custom)
|
| 180 |
+
assert "custom_agent" in registry.list_agents()
|
| 181 |
+
assert registry.get("custom_agent") == custom
|
| 182 |
+
|
| 183 |
+
def test_get_nonexistent_agent(self):
|
| 184 |
+
"""Test getting a non-existent agent returns None."""
|
| 185 |
+
registry = AgentRegistry()
|
| 186 |
+
assert registry.get("nonexistent") is None
|
| 187 |
+
|
| 188 |
+
def test_list_primary_agents(self):
|
| 189 |
+
"""Test listing primary agents."""
|
| 190 |
+
registry = AgentRegistry()
|
| 191 |
+
primary = registry.list_primary_agents()
|
| 192 |
+
assert "coder" in primary
|
| 193 |
+
assert "explorer" not in primary
|
| 194 |
+
assert "planner" not in primary
|
| 195 |
+
|
| 196 |
+
def test_list_subagents(self):
|
| 197 |
+
"""Test listing subagents."""
|
| 198 |
+
registry = AgentRegistry()
|
| 199 |
+
subagents = registry.list_subagents()
|
| 200 |
+
assert "explorer" in subagents
|
| 201 |
+
assert "planner" in subagents
|
| 202 |
+
assert "focused_coder" in subagents
|
| 203 |
+
assert "coder" not in subagents
|
| 204 |
+
|
| 205 |
+
def test_get_subagents_for_delegating_agent(self):
|
| 206 |
+
"""Test getting subagents for an agent that can delegate."""
|
| 207 |
+
registry = AgentRegistry()
|
| 208 |
+
subagents = registry.get_subagents_for("coder")
|
| 209 |
+
assert len(subagents) > 0
|
| 210 |
+
assert "explorer" in subagents
|
| 211 |
+
|
| 212 |
+
def test_get_subagents_for_non_delegating_agent(self):
|
| 213 |
+
"""Test getting subagents for an agent that cannot delegate."""
|
| 214 |
+
registry = AgentRegistry()
|
| 215 |
+
subagents = registry.get_subagents_for("explorer")
|
| 216 |
+
assert subagents == []
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
class TestGlobalFunctions:
|
| 220 |
+
"""Tests for global helper functions."""
|
| 221 |
+
|
| 222 |
+
def test_get_agent_registry_singleton(self):
|
| 223 |
+
"""Test that get_agent_registry returns a singleton."""
|
| 224 |
+
registry1 = get_agent_registry()
|
| 225 |
+
registry2 = get_agent_registry()
|
| 226 |
+
assert registry1 is registry2
|
| 227 |
+
|
| 228 |
+
def test_get_agent_config(self):
|
| 229 |
+
"""Test get_agent_config helper."""
|
| 230 |
+
config = get_agent_config("coder")
|
| 231 |
+
assert config is not None
|
| 232 |
+
assert config.name == "coder"
|
| 233 |
+
|
| 234 |
+
def test_get_agent_config_nonexistent(self):
|
| 235 |
+
"""Test get_agent_config returns None for non-existent agent."""
|
| 236 |
+
config = get_agent_config("nonexistent")
|
| 237 |
+
assert config is None
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
class TestCreateSubagentConfig:
|
| 241 |
+
"""Tests for create_subagent_config function."""
|
| 242 |
+
|
| 243 |
+
def test_creates_unique_name(self):
|
| 244 |
+
"""Test that subagent config has a unique name."""
|
| 245 |
+
config1 = create_subagent_config("coder", "Task 1")
|
| 246 |
+
config2 = create_subagent_config("coder", "Task 2")
|
| 247 |
+
assert config1.name != config2.name
|
| 248 |
+
assert config1.name.startswith("task_")
|
| 249 |
+
assert config2.name.startswith("task_")
|
| 250 |
+
|
| 251 |
+
def test_sets_correct_mode(self):
|
| 252 |
+
"""Test that subagent config has SUBAGENT mode."""
|
| 253 |
+
config = create_subagent_config("coder", "Test task")
|
| 254 |
+
assert config.mode == AgentMode.SUBAGENT
|
| 255 |
+
|
| 256 |
+
def test_sets_parent_agent(self):
|
| 257 |
+
"""Test that parent agent is set correctly."""
|
| 258 |
+
config = create_subagent_config("coder", "Test task")
|
| 259 |
+
assert config.parent_agent == "coder"
|
| 260 |
+
|
| 261 |
+
def test_cannot_delegate(self):
|
| 262 |
+
"""Test that subagent cannot delegate."""
|
| 263 |
+
config = create_subagent_config("coder", "Test task")
|
| 264 |
+
assert config.can_delegate is False
|
| 265 |
+
|
| 266 |
+
def test_custom_tools(self):
|
| 267 |
+
"""Test custom tools can be specified."""
|
| 268 |
+
config = create_subagent_config(
|
| 269 |
+
"coder",
|
| 270 |
+
"Test task",
|
| 271 |
+
tools=["read_file", "grep"],
|
| 272 |
+
)
|
| 273 |
+
assert config.tools == ["read_file", "grep"]
|
| 274 |
+
|
| 275 |
+
def test_task_description_in_prompt(self):
|
| 276 |
+
"""Test task description is included in system prompt."""
|
| 277 |
+
config = create_subagent_config("coder", "Analyze the codebase")
|
| 278 |
+
assert "Analyze the codebase" in config.system_prompt
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
class TestSystemPromptTemplates:
|
| 282 |
+
"""Tests for system prompt templates."""
|
| 283 |
+
|
| 284 |
+
def test_primary_system_prompt_has_placeholders(self):
|
| 285 |
+
"""Test that PRIMARY prompt has expected placeholders."""
|
| 286 |
+
assert "{agent_name}" in PRIMARY_SYSTEM_PROMPT
|
| 287 |
+
assert "{work_dir}" in PRIMARY_SYSTEM_PROMPT
|
| 288 |
+
assert "{current_time}" in PRIMARY_SYSTEM_PROMPT
|