moelove commited on
Commit
e61bba1
·
1 Parent(s): 8e089e0

add multiple agents

Browse files

Signed-off-by: Jintao Zhang <zhangjintao9020@gmail.com>

src/amcp/__init__.py CHANGED
@@ -1,2 +1,56 @@
1
- __all__ = ["__version__"]
2
- __version__ = "0.1.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, user_input: str, work_dir: Path | None = None, stream: bool = True, show_progress: bool = True
 
 
 
 
 
 
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
- agent_files = list_available_agents(agents_dir)
159
  local_agent_files = list_available_agents(local_agents_dir)
160
 
161
  # Combine both lists
162
- all_agent_files = agent_files + local_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 agent_file in all_agent_files:
172
  try:
173
- spec = load_agent_spec(agent_file)
174
- console.print(f"📄 {agent_file.name}")
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"❌ {agent_file.name}: {e}")
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
- # Load agent specification
 
 
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
- console.print(f"[green]Loaded agent: {agent_spec.name}[/green]")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- from amcp.agent_spec import get_default_agent_spec
2
 
 
 
3
 
4
- def test_agent_spec_default():
5
- spec = get_default_agent_spec()
6
- assert spec.name == "default"
7
- assert len(spec.system_prompt) > 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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