Skip to content

Commit

Permalink
Add a group chat example with '@' mention feature. (#55)
Browse files Browse the repository at this point in the history
  • Loading branch information
rayrayraykk authored Mar 14, 2024
1 parent 197d597 commit 1d4c6f9
Show file tree
Hide file tree
Showing 10 changed files with 281 additions and 7 deletions.
8 changes: 7 additions & 1 deletion examples/distributed/user_proxy_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,22 @@
class UserProxyAgent(UserAgent):
"""User proxy agent class"""

def reply(
def reply( # type: ignore [override]
self,
x: dict = None,
required_keys: Optional[Union[list[str], str]] = None,
) -> dict:
"""
Reply with `self.speak(x)`
"""
if x is not None:
self.speak(x)
return super().reply(x, required_keys)

def observe(self, x: Union[dict, Sequence[dict]]) -> None:
"""
Observe with `self.speak(x)`
"""
if x is not None:
self.speak(x) # type: ignore[arg-type]
self.memory.add(x)
73 changes: 73 additions & 0 deletions examples/groupchat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Multi-Agent Group Conversation in AgentScope

This example demonstrates a multi-agent group conversation facilitated by AgentScope. The script `main.py` sets up a virtual chat room where a user agent interacts with several NPC (non-player character) agents. The chat utilizes a special **"@"** mention functionality, which allows participants to address specific agents and have a more directed conversation.

## Key Features

- **Real-time Group Conversation**: Engage in a chat with multiple agents responding in real time.
- **@ Mention Functionality**: Use the "@" symbol followed by an agent's name to specifically address that agent within the conversation.
- **Dynamic Flow**: User-driven conversation with agents responding based on the context and mentions.
- **Configurable Agent Roles**: Easily modify agent roles and behaviors by editing the `sys_prompt` in the configuration files.
- **User Timeout**: If the user does not respond within a specified time, the conversation continues with the next agent.

## How to Use

To start the group conversation, follow these steps:

1. Make sure to set your `api_key` in the `configs/model_configs.json` file.
2. Run the script using the following command:

```bash
python main.py

# or launch agentscope studio
as_studio main.py
```

1. To address a specific agent in the chat, type "@" followed by the agent's name in your message.
2. To exit the chat, simply type "exit" when it's your turn to speak.

## Background and Conversation Flow

The conversation takes place in a simulated chat room environment with roles defined for each participant. The user acts as a regular chat member with the ability to speak freely and address any agent. NPC agents are pre-configured with specific roles that determine their responses and behavior in the chat. The topic of the conversation is open-ended and can evolve organically based on the user's input and agents' programmed personas.

### Example Interaction

```
User input: Hi, everyone! I'm excited to join this chat.
AgentA: Welcome! We're glad to have you here.
User input: @AgentB, what do you think about the new technology trends?
AgentB: It's an exciting time for tech! There are so many innovations on the horizon.
...
```

## Customization Options

The group conversation script provides several options for customization, allowing you to tailor the chat experience to your preferences.

You can customize the conversation by editing the agent configurations and model parameters. The `agent_configs.json` file allows you to set specific behaviors for each NPC agent, while `model_configs.json` contains the parameters for the conversation model.

### Changing User Input Time Limit

The `USER_TIME_TO_SPEAK` variable sets the time limit (in seconds) for the user to input their message during each round. By default, this is set to 10 seconds. You can adjust this time limit by modifying the value of `USER_TIME_TO_SPEAK` in the `main.py` script.

For example, to change the time limit to 20 seconds, update the line in `main.py` as follows:

```
USER_TIME_TO_SPEAK = 20 # User has 20 seconds to type their message
```

### Setting a Default Topic for the Chat Room

The `DEFAULT_TOPIC` variable defines the initial message or topic of the chat room. It sets the stage for the conversation and is announced at the beginning of the chat session. You can change this message to prompt a specific discussion topic or to provide instructions to the agents.

To customize this message, modify the `DEFAULT_TOPIC` variable in the `main.py` script. For instance, if you want to set the default topic to discuss "The Future of Artificial Intelligence," you would change the code as follows:

```python
DEFAULT_TOPIC = """
This is a chat room about the Future of Artificial Intelligence and you can
speak freely and briefly.
"""
```

With these customizations, the chat room can be tailored to fit specific themes or time constraints, enhancing the user's control over the chat experience.
29 changes: 29 additions & 0 deletions examples/groupchat/configs/agent_configs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[
{
"class": "DialogAgent",
"args": {
"name": "Lingfeng",
"sys_prompt":"You are Lingfeng, a noble in the imperial court, known for your wisdom and strategic acumen. You often engage in complex political intrigues and have recently suspected the Queen’s adviser of treachery. Your speaking style is reminiscent of classical literature.",
"model_config_name": "gpt-4",
"use_memory": true
}
},
{
"class": "DialogAgent",
"args": {
"name": "Boyu",
"sys_prompt":"You are Boyu, a friend of Lingfeng and an enthusiast of court dramas. Your speech is modern but with a flair for the dramatic, matching your love for emotive storytelling. You've been closely following Lingfeng’s political maneuvers in the imperial court through secret correspondence.",
"model_config_name": "gpt-4",
"use_memory": true
}
},
{
"class": "DialogAgent",
"args": {
"name": "Haotian",
"sys_prompt":"You are Haotian, Lingfeng’s cousin who prefers the open fields to the confines of court life. As a celebrated athlete, your influence has protected Lingfeng in times of political strife. You promote physical training as a way to prepare for life's battles, often using sports metaphors in conversation.",
"model_config_name": "gpt-4",
"use_memory": true
}
}
]
19 changes: 19 additions & 0 deletions examples/groupchat/configs/model_configs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[
{
"model_type": "openai",
"config_name": "gpt-4",
"model_name": "gpt-4",
"api_key": "xxx",
"organization": "xxx",
"generate_args": {
"temperature": 0.5
}
},
{
"model_type": "post_api_chat",
"config_name": "my_post_api",
"api_url": "https://xxx",
"headers": {},
"json_args": {}
}
]
37 changes: 37 additions & 0 deletions examples/groupchat/groupchat_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
""" Group chat utils."""
import re
from typing import Sequence


def select_next_one(agents: Sequence, rnd: int) -> Sequence:
"""
Select next agent.
"""
return agents[rnd % len(agents)]


def filter_agents(string: str, agents: Sequence) -> Sequence:
"""
This function filters the input string for occurrences of the given names
prefixed with '@' and returns a list of the found names.
"""
if len(agents) == 0:
return []

# Create a pattern that matches @ followed by any of the candidate names
pattern = (
r"@(" + "|".join(re.escape(agent.name) for agent in agents) + r")\b"
)

# Find all occurrences of the pattern in the string
matches = re.findall(pattern, string)

# Create a dictionary mapping agent names to agent objects for quick lookup
agent_dict = {agent.name: agent for agent in agents}

# Return the list of matched agent objects preserving the order
ordered_agents = [
agent_dict[name] for name in matches if name in agent_dict
]
return ordered_agents
77 changes: 77 additions & 0 deletions examples/groupchat/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
""" A group chat where user can talk any time implemented by agentscope. """
from loguru import logger
from groupchat_utils import (
select_next_one,
filter_agents,
)

import agentscope
from agentscope.agents import UserAgent
from agentscope.message import Msg
from agentscope.msghub import msghub

USER_TIME_TO_SPEAK = 10
DEFAULT_TOPIC = """
This is a chat room and you can speak freely and briefly.
"""

SYS_PROMPT = """
You can designate a member to reply to your message, you can use the @ symbol.
This means including the @ symbol in your message, followed by
that person's name, and leaving a space after the name.
All participants are: {agent_names}
"""


def main() -> None:
"""group chat"""
npc_agents = agentscope.init(
model_configs="./configs/model_configs.json",
agent_configs="./configs/agent_configs.json",
)

user = UserAgent()

agents = list(npc_agents) + [user]

hint = Msg(
name="Host",
content=DEFAULT_TOPIC
+ SYS_PROMPT.format(
agent_names=[agent.name for agent in agents],
),
)

rnd = 0
speak_list = []
with msghub(agents, announcement=hint):
while True:
try:
x = user(timeout=USER_TIME_TO_SPEAK)
if x.content == "exit":
break
except TimeoutError:
x = {"content": ""}
logger.info(
f"User has not typed text for "
f"{USER_TIME_TO_SPEAK} seconds, skip.",
)

speak_list += filter_agents(x.get("content", ""), npc_agents)

if len(speak_list) > 0:
next_agent = speak_list.pop(0)
x = next_agent()
else:
next_agent = select_next_one(npc_agents, rnd)
x = next_agent()

speak_list += filter_agents(x.content, npc_agents)

rnd += 1


if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"tiktoken",
"Pillow",
"requests",
"inputimeout",
"openai>=1.3.0",
"numpy",
"Flask==3.0.0",
"Flask-Cors==4.0.0",
Expand Down
6 changes: 5 additions & 1 deletion src/agentscope/agents/user_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def reply(
self,
x: dict = None,
required_keys: Optional[Union[list[str], str]] = None,
timeout: Optional[int] = None,
) -> dict:
"""
Processes the input provided by the user and stores it in memory,
Expand All @@ -51,6 +52,9 @@ def reply(
(`Optional[Union[list[str], str]]`, defaults to `None`):
Strings that requires user to input, which will be used as
the key of the returned dict. Defaults to None.
timeout (`Optional[int]`, defaults to `None`):
Raise `TimeoutError` if user exceed input time, set to None
for no limit.
Returns:
`dict`: A dictionary representing the message object that contains
Expand All @@ -63,7 +67,7 @@ def reply(
# TODO: To avoid order confusion, because `input` print much quicker
# than logger.chat
time.sleep(0.5)
content = user_input()
content = user_input(timeout=timeout)

kwargs = {}
if required_keys is not None:
Expand Down
10 changes: 8 additions & 2 deletions src/agentscope/utils/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import signal
import sys
import tempfile
import threading
from typing import Any, Generator, Optional, Union
from loguru import logger
import requests
Expand All @@ -24,10 +25,15 @@ def timer(seconds: Optional[Union[int, float]] = None) -> Generator:
https://github.com/openai/human-eval/blob/master/human_eval/execution.py
Note:
This function only works in Unix,
This function only works in Unix and MainThread,
since `signal.setitimer` is only available in Unix.
"""
if seconds is None or sys.platform == "win32":
if (
seconds is None
or sys.platform == "win32"
or threading.currentThread().name != "MainThread"
):
yield
return

Expand Down
27 changes: 24 additions & 3 deletions src/agentscope/web/studio/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Optional
import hashlib
from multiprocessing import Queue
from queue import Empty
from collections import defaultdict

from PIL import Image
Expand Down Expand Up @@ -91,12 +92,20 @@ def send_player_input(msg: str, uid: Optional[str] = None) -> None:


def get_player_input(
timeout: Optional[int] = None,
uid: Optional[str] = None,
) -> str:
"""Gets player input from the web UI or command line."""
global glb_uid_dict
glb_queue_user_input = glb_uid_dict[uid]["glb_queue_user_input"]
content = glb_queue_user_input.get(block=True)[1]

if timeout:
try:
content = glb_queue_user_input.get(block=True, timeout=timeout)[1]
except Empty as exc:
raise TimeoutError("timed out") from exc
else:
content = glb_queue_user_input.get(block=True)[1]
if content == "**Reset**":
glb_uid_dict[uid] = init_uid_queues()
raise ResetException
Expand Down Expand Up @@ -180,12 +189,24 @@ def audio2text(audio_path: str) -> str:
return " ".join([s["text"] for s in result["output"]["sentence"]])


def user_input(prefix: str = "User input: ") -> str:
def user_input(
prefix: str = "User input: ",
timeout: Optional[int] = None,
) -> str:
"""get user input"""
if hasattr(thread_local_data, "uid"):
content = get_player_input(
timeout=timeout,
uid=thread_local_data.uid,
)
else:
content = input(prefix)
if timeout:
from inputimeout import inputimeout, TimeoutOccurred

try:
content = inputimeout(prefix, timeout=timeout)
except TimeoutOccurred as exc:
raise TimeoutError("timed out") from exc
else:
content = input(prefix)
return content

0 comments on commit 1d4c6f9

Please sign in to comment.