Merge pull request #50 from rowboatlabs/dev

Dev changes
This commit is contained in:
Ramnique Singh 2025-04-01 14:44:48 +05:30 committed by GitHub
commit bc7898a3d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 14191 additions and 6739 deletions

View file

@ -2,11 +2,19 @@
# ------------------------------------------------------------
MONGODB_CONNECTION_STRING=mongodb://127.0.0.1:27017/rowboat
OPENAI_API_KEY=<OPENAI_API_KEY>
AUTH0_SECRET=<AUTH0_SECRET>
# Uncomment to enable auth using Auth0
# ------------------------------------------------------------
# USE_AUTH=true
# Even though auth is disabled by default, these test values are needed for the auth0 imports
# --------------------------------------------------------------------------------------------
AUTH0_SECRET=test_secret
AUTH0_BASE_URL=http://localhost:3000
AUTH0_ISSUER_BASE_URL=<AUTH0_ISSUER_BASE_URL>
AUTH0_CLIENT_ID=<AUTH0_CLIENT_ID>
AUTH0_CLIENT_SECRET=<AUTH0_CLIENT_SECRET>
AUTH0_ISSUER_BASE_URL=https://test.com
AUTH0_CLIENT_ID=test
AUTH0_CLIENT_SECRET=test
# Uncomment to enable RAG:
# ------------------------------------------------------------

288
README.md
View file

@ -1,27 +1,7 @@
# RowBoat
[![RowBoat Logo](/assets/rb-logo.png)](https://www.rowboatlabs.com/)
This guide will help you set up and run the RowBoat applications locally using Docker. Please see our [docs](https://docs.rowboatlabs.com/) for more details.
RowBoat offers several optional services that can be enabled using Docker Compose profiles. You can run multiple profiles simultaneously using:
```bash
docker compose --profile rag_urls_worker --profile chat_widget --profile tools_webhook up -d
```
See the relevant sections below for details on each service.
## Table of Contents
- [Prerequisites](#prerequisites)
- [Local Development Setup](#local-development-setup)
- [Python SDK](#option-1-python-sdk)
- [HTTP API](#option-2-http-api)
- [Optional Features](#enable-rag)
- [Enable RAG](#enable-rag)
- [URL Scraping](#url-scraping)
- [File Uploads](#file-uploads)
- [Enable Chat Widget](#enable-chat-widget)
- [Enable Tools Webhook](#enable-tools-webhook)
- [Troubleshooting](#troubleshooting)
- [Attribution](#attribution)
RowBoat is the fastest way to build production-ready multi-agent systems with OpenAI's Agents SDK.
## Prerequisites
@ -34,29 +14,15 @@ Before running RowBoat, ensure you have:
- Obtain from your OpenAI account.
3. **MongoDB**
- **Option 1**: Use an existing MongoDB deployment with your connection string.
- **Option 2**: Install MongoDB locally:
- macOS (Homebrew)
```bash
brew tap mongodb/brew
brew install mongodb-community@8.0
brew services start mongodb-community@8.0
```
- Other platforms: Refer to the MongoDB documentation for details.
4. **Auth0 Account and Application Setup**
- **Create an Auth0 Account**: Sign up at [Auth0](https://auth0.com).
- **Create a New Application**: Choose "Regular Web Application", select "Next.js" as the application type, and name it "RowBoat".
- **Configure Application**:
- **Allowed Callback URLs**: In the Auth0 Dashboard, go to your "RowBoat" application settings and set `http://localhost:3000/api/auth/callback` as an Allowed Callback URL.
- **Get Credentials**: Collect the following from your Auth0 application settings:
- **Domain**: Copy your Auth0 domain (ensure you append `https://` to the Domain that the Auth0 dashboard shows you)
- **Client ID**: Your application's unique identifier
- **Client Secret**: Your application's secret key
- **Generate secret**: Generate a session encryption secret in your terminal and note the output for later:
```bash
openssl rand -hex 32
```
## Local Development Setup
## Quickstart
1. **Clone the Repository**
```bash
@ -69,24 +35,11 @@ Before running RowBoat, ensure you have:
```bash
cp .env.example .env
```
- Update your `.env` file with the following configurations:
- Open the new .env file and update the OPENAI_API_KEY:
```ini
# OpenAI Configuration
OPENAI_API_KEY=your-openai-api-key
# Auth0 Configuration
AUTH0_SECRET=your-generated-secret # Generated using openssl command
AUTH0_BASE_URL=http://localhost:3000 # Your application's base URL
AUTH0_ISSUER_BASE_URL=https://example.auth0.com # Your Auth0 domain (ensure it is prefixed with https://)
AUTH0_CLIENT_ID=your-client-id
AUTH0_CLIENT_SECRET=your-client-secret
# MongoDB Configuration (choose one based on your setup)
# For local MongoDB
MONGODB_CONNECTION_STRING=mongodb://host.docker.internal:27017/rowboat
# or, for remote MongoDB
MONGODB_CONNECTION_STRING=mongodb+srv://<username>:<password>@<cluster>.mongodb.net/rowboat
```
3. **Start the App**
@ -97,82 +50,6 @@ Before running RowBoat, ensure you have:
4. **Access the App**
- Visit [http://localhost:3000](http://localhost:3000).
5. **Interact with RowBoat**
There are two ways to interact with RowBoat:
### Option 1: Python SDK
For Python applications, we provide an official SDK for easier integration:
```bash
pip install rowboat
```
```python
from rowboat import Client
client = Client(
host="http://localhost:3000",
project_id="<PROJECT_ID>",
api_key="<API_KEY>" # Generate this from /projects/<PROJECT_ID>/config
)
# Simple chat interaction
messages = [{"role": "user", "content": "Tell me the weather in London"}]
response_messages, state = client.chat(messages=messages)
```
For more details, see the [Python SDK documentation](./apps/python-sdk/README.md).
### Option 2: HTTP API
You can use the API directly at [http://localhost:3000/api/v1/](http://localhost:3000/api/v1/)
- Project ID is available in the URL of the project page
- API Key can be generated from the project config page at `/projects/<PROJECT_ID>/config`
```bash
curl --location 'http://localhost:3000/api/v1/<PROJECT_ID>/chat' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <API_KEY>' \
--data '{
"messages": [
{
"role": "user",
"content": "tell me the weather in london in metric units"
}
]
}'
```
which gives:
```json
{
"messages": [
{
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"location\":\"London\",\"units\":\"metric\"}",
"name": "weather_lookup_tool"
},
"id": "call_r6XKuVxmGRogofkyFZIacdL0",
"type": "function"
}
],
"agenticSender": "Example Agent",
"agenticResponseType": "internal"
}
],
"state": {
// .. state data
}
}
```
6. **Documentation**
The documentation site is available at [http://localhost:8000](http://localhost:8000)
## Enable RAG
RowBoat supports RAG capabilities to enhance responses with your custom knowledge base. To enable RAG, you'll need:
@ -298,32 +175,6 @@ Enable file upload support (PDF, DOCX, TXT) for your knowledge base:
After enabling RAG and starting the required workers, you can manage your knowledge base through the RowBoat UI at `/projects/<PROJECT_ID>/sources`.
## Enable Chat Widget
RowBoat provides an embeddable chat widget that you can add to any website. To enable and use the chat widget:
1. **Generate JWT Secret**
Generate a secret for securing chat widget sessions:
```bash
openssl rand -hex 32
```
2. **Update Environment Variables**
```ini
USE_CHAT_WIDGET=true
CHAT_WIDGET_SESSION_JWT_SECRET=<your-generated-secret>
```
3. **Start the Chat Widget Service**
```bash
docker compose --profile chat_widget up -d
```
4. **Add Widget to Your Website**
You can find the chat-widget embed code under `/projects/<PROJECT_ID>/config`
After setup, the chat widget will appear on your website and connect to your RowBoat project.
## Enable Tools Webhook
RowBoat includes a built-in webhook service that allows you to implement custom tool functions. To use this feature:
@ -371,6 +222,135 @@ RowBoat includes a built-in webhook service that allows you to implement custom
The webhook service handles all the security and parameter validation, allowing you to focus on implementing your tool logic.
## Enable Chat Widget
RowBoat provides an embeddable chat widget that you can add to any website. To enable and use the chat widget:
1. **Generate JWT Secret**
Generate a secret for securing chat widget sessions:
```bash
openssl rand -hex 32
```
2. **Update Environment Variables**
```ini
USE_CHAT_WIDGET=true
CHAT_WIDGET_SESSION_JWT_SECRET=<your-generated-secret>
```
3. **Start the Chat Widget Service**
```bash
docker compose --profile chat_widget up -d
```
4. **Add Widget to Your Website**
You can find the chat-widget embed code under `/projects/<PROJECT_ID>/config`
After setup, the chat widget will appear on your website and connect to your RowBoat project.
## Enable Authentication
By default, RowBoat runs without authentication. To enable user authentication using Auth0:
1. **Auth0 Setup**
- **Create an Auth0 Account**: Sign up at [Auth0](https://auth0.com).
- **Create a New Application**: Choose "Regular Web Application", select "Next.js" as the application type, and name it "RowBoat".
- **Configure Application**:
- **Allowed Callback URLs**: In the Auth0 Dashboard, go to your "RowBoat" application settings and set `http://localhost:3000/api/auth/callback` as an Allowed Callback URL.
- **Get Credentials**: Collect the following from your Auth0 application settings:
- **Domain**: Copy your Auth0 domain (ensure you append `https://` to the Domain that the Auth0 dashboard shows you)
- **Client ID**: Your application's unique identifier
- **Client Secret**: Your application's secret key
- **Generate secret**: Generate a session encryption secret in your terminal and note the output for later:
```bash
openssl rand -hex 32
```
2. **Update Environment Variables**
Add the following to your `.env` file:
```ini
USE_AUTH=true
AUTH0_SECRET=your-generated-secret # Generated using openssl command
AUTH0_BASE_URL=http://localhost:3000 # Your application's base URL
AUTH0_ISSUER_BASE_URL=https://example.auth0.com # Your Auth0 domain (ensure it is prefixed with https://)
AUTH0_CLIENT_ID=your-client-id
AUTH0_CLIENT_SECRET=your-client-secret
```
After enabling authentication, users will need to sign in to access the application.
## Interact with RowBoat API
There are two ways to interact with RowBoat's API:
1. **Option 1: Python SDK**
For Python applications, we provide an official SDK for easier integration:
```bash
pip install rowboat
```
```python
from rowboat import Client
client = Client(
host="http://localhost:3000",
project_id="<PROJECT_ID>",
api_key="<API_KEY>" # Generate this from /projects/<PROJECT_ID>/config
)
# Simple chat interaction
messages = [{"role": "user", "content": "Tell me the weather in London"}]
response_messages, state = client.chat(messages=messages)
```
For more details, see the [Python SDK documentation](./apps/python-sdk/README.md).
1. **Option 2: HTTP API**
You can use the API directly at [http://localhost:3000/api/v1/](http://localhost:3000/api/v1/)
- Project ID is available in the URL of the project page
- API Key can be generated from the project config page at `/projects/<PROJECT_ID>/config`
```bash
curl --location 'http://localhost:3000/api/v1/<PROJECT_ID>/chat' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <API_KEY>' \
--data '{
"messages": [
{
"role": "user",
"content": "tell me the weather in london in metric units"
}
]
}'
```
which gives:
```json
{
"messages": [
{
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"location\":\"London\",\"units\":\"metric\"}",
"name": "weather_lookup_tool"
},
"id": "call_r6XKuVxmGRogofkyFZIacdL0",
"type": "function"
}
],
"agenticSender": "Example Agent",
"agenticResponseType": "internal"
}
],
"state": {
// .. state data
}
}
```
## Troubleshooting
1. **MongoDB Connection Issues**

1658
apps/agents/poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,67 +0,0 @@
[tool.poetry]
name = "agents"
version = "0.1.0"
description = "RowBoat Labs Agent OS"
authors = ["Akhilesh <akhilesh@rowboatlabs.com>"]
license = "MIT"
readme = "README.md"
homepage = "https://github.com/rowboatlabs/agents"
package-mode = false
[tool.poetry.dependencies]
# Python
python = ">=3.10,<4.0"
# Dependencies
annotated-types = "^0.7.0"
anyio = "^4.6.2"
beautifulsoup4 = "^4.12.3"
blinker = "^1.8.2"
certifi = "^2024.8.30"
charset-normalizer = "^3.4.0"
click = "^8.1.7"
distro = "^1.9.0"
dnspython = "^2.7.0"
et_xmlfile = "^2.0.0"
eval_type_backport = "^0.2.0"
firecrawl = "^1.4.0"
Flask = "^3.0.3"
h11 = "^0.14.0"
httpcore = "^1.0.6"
httpx = "^0.27.2"
idna = "^3.10"
itsdangerous = "^2.2.0"
Jinja2 = "^3.1.4"
jiter = "^0.6.1"
jsonpath-python = "^1.0.6"
lxml = "^5.3.0"
markdownify = "^0.13.1"
MarkupSafe = "^3.0.2"
mypy-extensions = "^1.0.0"
nest-asyncio = "^1.6.0"
numpy = "^2.1.2"
openai = "^1.52.2"
openpyxl = "^3.1.5"
pandas = "^2.2.3"
pydantic = "^2.9.2"
pydantic_core = "^2.23.4"
pymongo = "^4.10.1"
python-dateutil = "^2.8.2"
python-docx = "^1.1.2"
python-dotenv = "^1.0.1"
pytz = "^2024.2"
requests = "^2.32.3"
setuptools = "^75.1.0"
six = "^1.16.0"
sniffio = "^1.3.1"
soupsieve = "^2.6"
tabulate = "^0.9.0"
tqdm = "^4.66.5"
typing-inspect = "^0.9.0"
typing_extensions = "^4.12.2"
tzdata = "^2024.2"
urllib3 = "^2.2.3"
websockets = "^13.1"
Werkzeug = "^3.0.5"
wheel = "^0.44.0"
gunicorn = "^23.0.0"

View file

@ -1,106 +0,0 @@
from flask import Flask, request, jsonify
from datetime import datetime
from functools import wraps
import os
from src.graph.core import run_turn
from src.graph.tools import RAG_TOOL, CLOSE_CHAT_TOOL
from src.utils.common import common_logger, read_json_from_file
logger = common_logger
app = Flask(__name__)
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
@app.route("/")
def home():
return "Hello, World!"
def require_api_key(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Missing or invalid authorization header'}), 401
token = auth_header.split('Bearer ')[1]
actual = os.environ.get('API_KEY', '').strip()
if actual and token != actual:
return jsonify({'error': 'Invalid API key'}), 403
return f(*args, **kwargs)
return decorated
@app.route("/chat", methods=["POST"])
@require_api_key
def chat():
print('='*200)
logger.info('='*200)
try:
data = request.get_json()
print('Complete request:')
logger.info('Complete request')
print(data)
logger.info(data)
print('-'*200)
logger.info('-'*200)
start_time = datetime.now()
config = read_json_from_file("./configs/default_config.json")
resp_messages, resp_tokens_used, resp_state = run_turn(
messages=data.get("messages", []),
start_agent_name=data.get("startAgent", ""),
agent_configs=data.get("agents", []),
tool_configs=data.get("tools", []),
localize_history=config.get("localize_history", True),
return_diff_messages=config.get("return_diff_messages", True),
prompt_configs=data.get("prompts", []),
start_turn_with_start_agent=config.get("start_turn_with_start_agent", False),
children_aware_of_parent=config.get("children_aware_of_parent", False),
parent_has_child_history=config.get("parent_has_child_history", True),
state=data.get("state", {}),
additional_tool_configs=[RAG_TOOL, CLOSE_CHAT_TOOL],
max_messages_per_turn=config.get("max_messages_per_turn", 2),
max_messages_per_error_escalation_turn=config.get("max_messages_per_error_escalation_turn", 2),
escalate_errors=config.get("escalate_errors", True),
max_overall_turns=config.get("max_overall_turns", 10)
)
print('-'*200)
logger.info('-'*200)
out = {
"messages": resp_messages,
"tokens_used": resp_tokens_used,
"state": resp_state,
}
print("Output: ")
logger.info(f"Output: ")
for k, v in out.items():
print(f"{k}: {v}")
print('*'*200)
logger.info(f"{k}: {v}")
logger.info('*'*200)
print("Processing time:")
print('='*200)
logger.info('='*200)
print(f"Processing time: {datetime.now() - start_time}")
logger.info(f"Processing time: {datetime.now() - start_time}")
return jsonify(out)
except Exception as e:
print(e)
logger.error(f"Error: {e}")
return jsonify({"error": str(e)}), 500
if __name__ == "__main__":
print("Starting Flask server...")
app.run(port=4040, debug=True)

View file

@ -1,504 +0,0 @@
import os
import sys
from copy import deepcopy
from src.swarm.types import Agent
from src.swarm.core import Swarm
from .guardrails import post_process_response
from .tools import create_error_tool_call
from .types import AgentRole, PromptType, ErrorType
from .helpers.access import get_agent_data_by_name, get_agent_by_name, get_agent_config_by_name, get_tool_config_by_name, get_tool_config_by_type, get_external_tools, get_prompt_by_type, pop_agent_config_by_type, get_agent_by_type
from .helpers.transfer import create_transfer_function_to_agent, create_transfer_function_to_parent_agent
from .helpers.state import add_recent_messages_to_history, construct_state_from_response, reset_current_turn, reset_current_turn_agent_history
from .helpers.instructions import add_transfer_instructions_to_child_agents, add_transfer_instructions_to_parent_agents, add_rag_instructions_to_agent, add_error_escalation_instructions, get_universal_system_message, add_universal_system_message_to_agent
from .helpers.control import get_latest_assistant_msg, get_latest_non_assistant_messages, get_last_agent_name
from src.swarm.types import Response
from src.utils.common import common_logger
logger = common_logger
def order_messages(messages):
# Arrange keys in specified order
ordered_messages = []
for msg in messages:
ordered = {}
msg = {k: v for k, v in msg.items() if v is not None}
# Add keys in specified order if they exist
for key in ['role', 'sender', 'content', 'created_at', 'timestamp']:
if key in msg:
ordered[key] = msg[key]
# Add remaining keys in alphabetical order
for key in sorted(msg.keys()):
if key not in ['role', 'sender', 'content', 'created_at', 'timestamp']:
ordered[key] = msg[key]
ordered_messages.append(ordered)
return ordered_messages
def clean_up_history(agent_data):
for data in agent_data:
data["history"] = order_messages(data["history"])
return agent_data
def clear_agent_fields(agent):
agent.children = {}
agent.parent_function = None
agent.candidate_parent_functions = {}
agent.child_functions = {}
if agent.most_recent_parent:
agent.history = []
return agent
def get_agents(agent_configs, tool_configs, localize_history, available_tool_mappings, agent_data, start_turn_with_start_agent, children_aware_of_parent, universal_sys_msg):
# Create Agent objects
agents = []
if not isinstance(agent_configs, list):
raise ValueError("Agents config is not a list in get_agents")
if not isinstance(tool_configs, list):
raise ValueError("Tools config is not a list in get_agents")
for agent_config in agent_configs:
logger.debug(f"Processing config for agent: {agent_config['name']}")
# Get tools for this agent
external_tools = []
internal_tools = []
candidate_parent_functions = {}
child_functions = {}
logger.debug(f"Finding tools for agent {agent_config['name']}")
logger.debug(f"Agent {agent_config['name']} has {len(agent_config['tools'])} configured tools")
if agent_config.get("hasRagSources", False):
rag_tool_name = get_tool_config_by_type(tool_configs, "rag").get("name", "")
agent_config["tools"].append(rag_tool_name)
agent_config = add_rag_instructions_to_agent(agent_config, rag_tool_name)
for tool_name in agent_config["tools"]:
logger.debug(f"Looking for tool config: {tool_name}")
tool_config = get_tool_config_by_name(tool_configs, tool_name)
if tool_config:
if tool_name in available_tool_mappings:
internal_tools.append(available_tool_mappings[tool_name])
else:
external_tools.append({
"type": "function",
"function": tool_config
})
logger.debug(f"Added tool {tool_name} to agent {agent_config['name']}")
else:
logger.warning(f"Tool {tool_name} not found in tool_configs")
history = []
this_agent_data = get_agent_data_by_name(agent_config["name"], agent_data)
if this_agent_data:
if localize_history:
history = this_agent_data.get("history", [])
# Create agent
logger.debug(f"Creating Agent object for {agent_config['name']}")
logger.debug(f"Using model: {agent_config['model']}")
logger.debug(f"Number of tools being added: Internal - {len(internal_tools)} | External - {len(external_tools)}")
try:
agent = Agent(
name=agent_config["name"],
type=agent_config.get("type", "default"),
instructions=agent_config["instructions"],
description=agent_config.get("description", ""),
internal_tools=internal_tools,
external_tools=external_tools,
candidate_parent_functions=candidate_parent_functions,
child_functions=child_functions,
model=agent_config["model"],
respond_to_user=agent_config.get("respond_to_user", False),
history=history,
children_names=agent_config.get("connectedAgents", []),
most_recent_parent=None
)
agents.append(agent)
logger.debug(f"Successfully created agent: {agent_config['name']}")
except Exception as e:
logger.error(f"Failed to create agent {agent_config['name']}: {str(e)}")
raise
# Adding most recent parents to agents
for agent in agents:
most_recent_parent = None
this_agent_data = get_agent_data_by_name(agent.name, agent_data)
if this_agent_data:
most_recent_parent_name = this_agent_data.get("most_recent_parent_name", "")
if most_recent_parent_name:
most_recent_parent = get_agent_by_name(most_recent_parent_name, agents) if most_recent_parent_name else None
if most_recent_parent:
agent.most_recent_parent = most_recent_parent
# Adding children agents to parent agents
logger.info("Adding children agents to parent agents")
for agent in agents:
agent.children = {agent_.name: agent_ for agent_ in agents if agent_.name in agent.children_names}
# Generate transfer functions for transferring to children agents
logger.info("Generating transfer functions for transferring to children agents")
transfer_functions = {
agent.name: create_transfer_function_to_agent(agent)
for agent in agents
}
# Add transfer functions for parents to transfer to children
logger.info("Adding transfer functions for parents to transfer to children")
for agent in agents:
for child in agent.children.values():
agent.child_functions[child.name] = transfer_functions[child.name]
# Add transfer-related instructions to parent agents
logger.info("Adding child transfer-related instructions to parent agents")
for agent in agents:
if agent.children:
agent = add_transfer_instructions_to_parent_agents(agent, agent.children, transfer_functions)
# Generate and append duplicate transfer functions for children to transfer to parent agents
logger.info("Generating duplicate transfer functions for children to transfer to parent agents")
for agent in agents:
for child in agent.children.values():
func = create_transfer_function_to_parent_agent(
parent_agent=agent,
children_aware_of_parent=children_aware_of_parent,
transfer_functions=transfer_functions
)
child.candidate_parent_functions[agent.name] = func
for agent in agents:
if agent.candidate_parent_functions and agent.type != "escalation":
agent = add_transfer_instructions_to_child_agents(
child=agent,
children_aware_of_parent=children_aware_of_parent
)
for agent in agents:
if agent.most_recent_parent:
assert agent.most_recent_parent.name in agent.candidate_parent_functions, f"Most recent parent {agent.most_recent_parent.name} not found in candidate parent functions for agent {agent.name}"
agent.parent_function = agent.candidate_parent_functions[agent.most_recent_parent.name]
for agent in agents:
agent = add_universal_system_message_to_agent(agent, universal_sys_msg)
return agents
def check_request_validity(messages, agent_configs, tool_configs, prompt_configs, max_overall_turns):
error_msg = ""
error_type = ErrorType.ESCALATE.value
# Limits checks
external_messages_count = sum(1 for msg in messages if msg.get("response_type") == "external")
if external_messages_count >= max_overall_turns:
error_msg = f"Max overall turns reached: {max_overall_turns}"
# Empty checks
if not messages:
error_msg = "Messages list is empty"
# Empty checks --> Fatal
if not agent_configs:
error_msg = "Agent configs list is empty"
error_type = ErrorType.FATAL.value
# Type checks --> Fatal
for arg in [messages, agent_configs, tool_configs, prompt_configs]:
if not isinstance(arg, list):
error_msg = f"{arg} is not a list"
error_type = ErrorType.FATAL.value
# Post processing agent, guardrails and escalation agent check - there should be at max one agent with type "post_processing_agent", "guardrails_agent" and "escalation_agent" respectively --> Fatal
post_processing_agent_count = sum(1 for ac in agent_configs if ac.get("type", "") == AgentRole.POST_PROCESSING.value)
guardrails_agent_count = sum(1 for ac in agent_configs if ac.get("type", "") == AgentRole.GUARDRAILS.value)
escalation_agent_count = sum(1 for ac in agent_configs if ac.get("type", "") == AgentRole.ESCALATION.value)
if post_processing_agent_count > 1 or guardrails_agent_count > 1 or escalation_agent_count > 1:
error_msg = "Invalid post processing agent or guardrails agent count - expected at most 1"
error_type = ErrorType.FATAL.value
# All agent config should have: name, instructions, model --> Fatal
for agent_config in agent_configs:
if not all(key in agent_config for key in ["name", "instructions", "model"]):
missing_keys = [key for key in ["name", "instructions", "tools", "model"] if key not in agent_config]
error_msg = f"Invalid agent config - missing keys: {missing_keys}"
error_type = ErrorType.FATAL.value
# All tool configs should have: name, parameters --> Fatal
for tool_config in tool_configs:
if not all(key in tool_config for key in ["name", "parameters"]):
missing_keys = [key for key in ["name", "parameters"] if key not in tool_config]
error_msg = f"Invalid tool config - missing keys: {missing_keys}"
error_type = ErrorType.FATAL.value
# Check for cycles in the agent config graph. Raise error if cycle is found, along with the agents involved in the cycle.
def find_cycles(agent_name, agent_configs, visited=None, path=None):
if visited is None:
visited = set()
if path is None:
path = []
visited.add(agent_name)
path.append(agent_name)
agent_config = get_agent_config_by_name(agent_name, agent_configs)
if not agent_config:
return None
for child_name in agent_config.get("connectedAgents", []):
if child_name in path:
cycle = path[path.index(child_name):]
cycle.append(child_name)
return cycle
if child_name not in visited:
cycle = find_cycles(child_name, agent_configs, visited, path)
if cycle:
return cycle
path.pop()
return None
for agent_config in agent_configs:
if agent_config.get("name") in agent_config.get("connectedAgents", []):
error_msg = f"Cycle detected in agent config graph - agent {agent_config.get('name')} is connected to itself"
cycle = find_cycles(agent_config.get("name"), agent_configs)
if cycle:
cycle_str = " -> ".join(cycle)
error_msg = f"Cycle detected in agent config graph: {cycle_str}"
return error_msg, error_type
def handle_error(error_tool_call, error_msg, return_diff_messages, messages, turn_messages, state, tokens_used):
resp_messages = turn_messages if return_diff_messages else messages + turn_messages
resp_messages.extend([create_error_tool_call(error_msg)])
if error_tool_call:
return resp_messages, tokens_used, state
else:
raise ValueError(error_msg)
def run_turn(messages, start_agent_name, agent_configs, tool_configs, available_tool_mappings={}, localize_history=True, return_diff_messages=True, prompt_configs=[], start_turn_with_start_agent=False, children_aware_of_parent=False, parent_has_child_history=True, state={}, additional_tool_configs=[], error_tool_call=True, max_messages_per_turn=10, max_messages_per_error_escalation_turn=4, escalate_errors=True, max_overall_turns=10):
logger.info("Running stateless turn")
turn_messages = []
tokens_used = {}
messages = order_messages(messages)
tool_configs = tool_configs + additional_tool_configs
validation_error_msg, validation_error_type = check_request_validity(
messages=messages,
agent_configs=agent_configs,
tool_configs=tool_configs,
prompt_configs=prompt_configs,
max_overall_turns=max_overall_turns
)
if validation_error_msg and validation_error_type == ErrorType.FATAL.value:
logger.error(validation_error_msg)
return handle_error(
error_tool_call=error_tool_call,
error_msg=validation_error_msg,
return_diff_messages=return_diff_messages,
messages=messages,
turn_messages=turn_messages,
state=state,
tokens_used=tokens_used
)
post_processing_agent_config, agent_configs = pop_agent_config_by_type(agent_configs, AgentRole.POST_PROCESSING.value)
guardrails_agent_config, agent_configs = pop_agent_config_by_type(agent_configs, AgentRole.GUARDRAILS.value)
latest_assistant_msg = get_latest_assistant_msg(messages)
universal_sys_msg = get_universal_system_message(messages)
latest_non_assistant_msgs = get_latest_non_assistant_messages(messages)
msg_type = latest_non_assistant_msgs[-1]["role"]
last_agent_name = get_last_agent_name(
state=state,
agent_configs=agent_configs,
start_agent_name=start_agent_name,
msg_type=msg_type,
latest_assistant_msg=latest_assistant_msg,
start_turn_with_start_agent=start_turn_with_start_agent
)
logger.info("Localizing message history")
agent_data = state.get("agent_data", [])
if msg_type == "user":
messages = reset_current_turn(messages)
agent_data = reset_current_turn_agent_history(agent_data, [last_agent_name])
agent_data = clean_up_history(agent_data)
agent_data = add_recent_messages_to_history(
recent_messages=latest_non_assistant_msgs,
last_agent_name=last_agent_name,
agent_data=agent_data,
messages=messages,
parent_has_child_history=parent_has_child_history
)
state["agent_data"] = agent_data
logger.info("Initializing agents")
all_agents = get_agents(
agent_configs=agent_configs,
tool_configs=tool_configs,
available_tool_mappings=available_tool_mappings,
agent_data=state.get("agent_data", []),
localize_history=localize_history,
start_turn_with_start_agent=start_turn_with_start_agent,
children_aware_of_parent=children_aware_of_parent,
universal_sys_msg=universal_sys_msg
)
if not all_agents:
logger.error("No agents initialized")
return handle_error(
error_tool_call=error_tool_call,
error_msg="No agents initialized"
)
error_escalation_agent = deepcopy(get_agent_by_type(all_agents, AgentRole.ESCALATION.value))
if not error_escalation_agent:
logger.error("Escalation agent not found")
return handle_error(
error_tool_call=error_tool_call,
error_msg="Escalation agent not found",
return_diff_messages=return_diff_messages,
messages=messages,
turn_messages=turn_messages,
state=state,
tokens_used=tokens_used
)
error_escalation_agent = clear_agent_fields(error_escalation_agent)
error_escalation_agent = add_error_escalation_instructions(error_escalation_agent)
logger.info(f"Initialized {len(all_agents)} agents")
logger.debug("Getting last agent")
last_agent = get_agent_by_name(last_agent_name, all_agents)
if not last_agent:
logger.error("Last agent not found")
return handle_error(
error_tool_call=error_tool_call,
error_msg="Last agent not found",
return_diff_messages=return_diff_messages,
messages=messages,
state=state
)
external_tools = get_external_tools(tool_configs)
logger.info(f"Found {len(external_tools)} external tools")
logger.debug("Initializing Swarm client")
swarm_client = Swarm()
if not validation_error_msg:
response = swarm_client.run(
agent=last_agent,
messages=messages,
execute_tools=True,
external_tools=external_tools,
localize_history=localize_history,
parent_has_child_history=parent_has_child_history,
max_messages_per_turn=max_messages_per_turn,
tokens_used=tokens_used
)
tokens_used = response.tokens_used
last_agent = response.agent
response.messages = order_messages(response.messages)
turn_messages.extend(response.messages)
logger.info(f"Completed run of agent: {last_agent.name}")
if validation_error_msg and validation_error_type == ErrorType.ESCALATE.value or response.error_msg:
logger.info(f"Error raised in turn: {response.error_msg}")
response_sender_agent_name = response.agent.name
if escalate_errors and response_sender_agent_name != error_escalation_agent.name:
response = client.run(
agent=error_escalation_agent,
messages=[],
execute_tools=True,
external_tools=external_tools,
localize_history=False,
parent_has_child_history=False,
max_messages_per_turn=max_messages_per_error_escalation_turn,
tokens_used=tokens_used
)
tokens_used = response.tokens_used
last_agent = response.agent
response.messages = order_messages(response.messages)
turn_messages.extend(response.messages)
logger.info(f"Completed run of escalation agent: {error_escalation_agent.name}")
if response.error_msg:
logger.info(f"Error raised in escalation turn: {response.error_msg}")
return handle_error(
error_tool_call=error_tool_call,
error_msg=response.error_msg,
return_diff_messages=return_diff_messages,
messages=messages,
turn_messages=turn_messages,
state=state,
tokens_used=tokens_used
)
else:
logger.info(f"Error raised in turn: {response.error_msg}")
return handle_error(
error_tool_call=error_tool_call,
error_msg=response.error_msg,
return_diff_messages=return_diff_messages,
messages=messages,
turn_messages=turn_messages,
state=state,
tokens_used=tokens_used
)
if post_processing_agent_config:
response = post_process_response(
messages=turn_messages,
post_processing_agent_name=post_processing_agent_config.get("name", "Post Processing agent"),
post_process_instructions=post_processing_agent_config.get("instructions", ""),
style_prompt=get_prompt_by_type(prompt_configs, PromptType.STYLE.value),
context='',
model=post_processing_agent_config.get("model", "gpt-4o"),
tokens_used=tokens_used,
last_agent=last_agent
)
tokens_used = response.tokens_used
response.messages = order_messages(response.messages)
turn_messages.extend(response.messages)
logger.info("Response post-processed")
else:
logger.info("No post-processing agent found. Duplicating last response and setting to external.")
duplicate_msg = deepcopy(turn_messages[-1])
duplicate_msg["response_type"] = "external"
duplicate_msg["sender"] = duplicate_msg["sender"] + ' >> External'
response = Response(
messages=[duplicate_msg],
tokens_used=tokens_used,
agent=last_agent,
error_msg=''
)
response.messages = order_messages(response.messages)
turn_messages.extend(response.messages)
logger.info("Last response duplicated and set to external")
if guardrails_agent_config:
logger.info("Guardrails agent not implemented (ignoring)")
pass
if not state or not state.get("last_agent_name"):
logger.error("State is empty or last agent name is not set")
raise ValueError("State is empty or last agent name is not set")
response.messages = turn_messages if return_diff_messages else messages + turn_messages
response.tokens_used = tokens_used
new_state = construct_state_from_response(response, all_agents)
return response.messages, response.tokens_used, new_state

View file

@ -1,4 +0,0 @@
from .core import Swarm
from .types import Agent, Response
__all__ = ["Swarm", "Agent", "Response"]

View file

@ -1,275 +0,0 @@
# Standard library imports
import copy
import json
from collections import defaultdict
from typing import List, Callable, Union
from datetime import datetime
# Package/library imports
from openai import OpenAI
import random
# Local imports
from .util import *
from .types import (
Agent,
AgentFunction,
ChatCompletionMessage,
ChatCompletionMessageToolCall,
Function,
Response,
Result,
)
__CTX_VARS_NAME__ = "context_variables"
class Swarm:
def __init__(self, client=None):
if not client:
client = OpenAI(api_key=OPENAI_API_KEY)
self.client = client
self.history = defaultdict(lambda : [])
def get_chat_completion(
self,
agent: Agent,
history: List,
context_variables: dict,
model_override: str,
stream: bool,
debug: bool,
temperature: float
) -> ChatCompletionMessage:
context_variables = defaultdict(str, context_variables)
instructions = (
agent.instructions(context_variables)
if callable(agent.instructions)
else agent.instructions
)
messages = [{"role": "system", "content": instructions}] + history
debug_print(debug, "Getting chat completion for...:", messages)
all_functions = list(agent.child_functions.values()) + ([agent.parent_function] if agent.parent_function else [])
all_tools = agent.external_tools + agent.internal_tools
funcs_and_tools = [function_to_json(f) for f in all_functions] + [t for t in all_tools]
# hide context_variables from model
for tool in funcs_and_tools:
params = tool["function"]["parameters"]
params["properties"].pop(__CTX_VARS_NAME__, None)
if __CTX_VARS_NAME__ in params.get("required", []):
params["required"].remove(__CTX_VARS_NAME__)
create_params = {
"model": model_override or agent.model,
"messages": messages,
"tools": funcs_and_tools or None,
"tool_choice": agent.tool_choice,
"stream": stream,
"temperature": temperature
}
if funcs_and_tools:
create_params["parallel_tool_calls"] = agent.parallel_tool_calls
return self.client.chat.completions.create(**create_params)
def handle_function_result(self, result, debug) -> Result:
# Check if result is already a Result instance
if isinstance(result, Result):
return result
# Check if result is an Agent instance
if isinstance(result, Agent):
return Result(
value=json.dumps({"assistant": result.name}),
agent=result,
)
# Handle all other cases
try:
return Result(value=str(result))
except Exception as e:
error_message = f"Failed to cast response to string: {result}. Make sure agent functions return a string or Result object. Error: {str(e)}"
debug_print(debug, error_message)
raise TypeError(error_message)
def handle_function_calls(
self,
tool_calls: List[ChatCompletionMessageToolCall],
functions: List[AgentFunction],
context_variables: dict,
debug: bool,
) -> Response:
function_map = {f.__name__: f for f in functions}
partial_response = Response(
messages=[], agent=None, context_variables={})
for tool_call in tool_calls:
name = tool_call.function.name
# handle missing tool case, skip to next tool
if name not in function_map:
debug_print(debug, f"Tool {name} not found in function map.")
partial_response.messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"tool_name": name,
"content": f"Error: Tool {name} not found.",
}
)
continue
args = json.loads(tool_call.function.arguments)
debug_print(
debug, f"Processing tool call: {name} with arguments {args}")
func = function_map[name]
# pass context_variables to agent functions
if __CTX_VARS_NAME__ in func.__code__.co_varnames:
args[__CTX_VARS_NAME__] = context_variables
raw_result = function_map[name](**args)
result: Result = self.handle_function_result(raw_result, debug)
partial_response.messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"tool_name": name,
"content": result.value,
}
)
partial_response.context_variables.update(result.context_variables)
if result.agent:
partial_response.agent = result.agent
return partial_response
def run(
self,
agent: Agent,
messages: List,
context_variables: dict = {},
model_override: str = None,
stream: bool = False,
debug: bool = False,
max_messages_per_turn: int = 10,
execute_tools: bool = True,
external_tools: List[str] = [],
localize_history: bool = True,
parent_has_child_history: bool = True,
tokens_used: dict = {},
temperature: float = 0.0
) -> Response:
active_agent = agent
context_variables = copy.deepcopy(context_variables)
global_history = copy.deepcopy(messages)
init_len = len(messages)
while len(global_history) - init_len < max_messages_per_turn and active_agent:
history = active_agent.history if localize_history else global_history
history = arrange_messages_keys_in_order(history)
parent = active_agent.most_recent_parent
children_names_backup, children_backup, child_functions_backup = copy.deepcopy(active_agent.children_names), copy.deepcopy(active_agent.children), copy.deepcopy(active_agent.child_functions)
active_agent = check_and_remove_repeat_tool_call_to_child(active_agent, history)
# get completion with current history, agent
completion = self.get_chat_completion(
agent=active_agent,
history=history,
context_variables=context_variables,
model_override=model_override,
stream=stream,
debug=debug,
temperature=temperature
)
tokens_used = update_tokens_used(provider="openai", model=model_override or active_agent.model, tokens_used=tokens_used, completion=completion)
# Restore children and child functions
active_agent.children_names, active_agent.children, active_agent.child_functions = children_names_backup, children_backup, child_functions_backup
message = completion.choices[0].message
debug_print(debug, "Received completion:", message)
message.sender = active_agent.name
message_json = json.loads(message.model_dump_json())
message_json = add_message_metadata(message_json, active_agent)
if localize_history:
active_agent = update_histories(active_agent, message_json)
if parent and parent_has_child_history:
parent = update_histories(parent, message_json)
global_history.append(message_json)
external_tool_calls = []
internal_tool_calls = []
if message.tool_calls:
message_json["response_type"] = "internal"
for tool_call in message.tool_calls:
tool_name = tool_call.function.name
if tool_name in external_tools:
external_tool_calls.append(tool_call)
else:
internal_tool_calls.append(tool_call)
message.tool_calls = internal_tool_calls
if not message.tool_calls or not execute_tools:
if external_tool_calls:
message.tool_calls.extend(external_tool_calls)
debug_print(debug, "Ending turn.")
break
# handle function calls, updating context_variables, and switching agents
all_functions = list(active_agent.child_functions.values()) + ([active_agent.parent_function] if active_agent.parent_function else [])
partial_response = self.handle_function_calls(
message.tool_calls, all_functions, context_variables, debug
)
for msg in partial_response.messages:
msg = add_message_metadata(msg, active_agent)
if localize_history:
active_agent = update_histories(active_agent, msg)
if parent and parent_has_child_history:
parent = update_histories(parent, msg)
global_history.extend(partial_response.messages)
context_variables.update(partial_response.context_variables)
# Parent to child transfer
if partial_response.agent:
prev_agent = active_agent
active_agent = partial_response.agent
# Parent to child transfer
if active_agent.name in prev_agent.children_names:
active_agent.most_recent_parent = prev_agent
active_agent.parent_function = active_agent.candidate_parent_functions[active_agent.most_recent_parent.name]
if localize_history:
if not parent_has_child_history:
prev_agent.history = remove_irrelevant_messages(prev_agent.history)
new_active_agent_history = get_current_turn_messages(global_history, only_user = True)
active_agent.history.extend(new_active_agent_history)
# Child to parent transfer
else:
assert parent == active_agent, "Parent and active agent do not match when active agent is not a child of previous agent"
child = prev_agent
if localize_history:
child.history = remove_irrelevant_messages(child.history)
return_messages = global_history[init_len:]
error_msg = ""
if len(global_history) - init_len >= max_messages_per_turn:
error_msg = "Max messages per turn reached"
return Response(
messages=return_messages,
agent=active_agent,
context_variables=context_variables,
error_msg=error_msg,
tokens_used=tokens_used
)

View file

@ -1 +0,0 @@
from .repl import run_demo_loop

View file

@ -1,87 +0,0 @@
import json
from swarm import Swarm
def process_and_print_streaming_response(response):
content = ""
last_sender = ""
for chunk in response:
if "sender" in chunk:
last_sender = chunk["sender"]
if "content" in chunk and chunk["content"] is not None:
if not content and last_sender:
print(f"\033[94m{last_sender}:\033[0m", end=" ", flush=True)
last_sender = ""
print(chunk["content"], end="", flush=True)
content += chunk["content"]
if "tool_calls" in chunk and chunk["tool_calls"] is not None:
for tool_call in chunk["tool_calls"]:
f = tool_call["function"]
name = f["name"]
if not name:
continue
print(f"\033[94m{last_sender}: \033[95m{name}\033[0m()")
if "delim" in chunk and chunk["delim"] == "end" and content:
print() # End of response message
content = ""
if "response" in chunk:
return chunk["response"]
def pretty_print_messages(messages) -> None:
for message in messages:
if message["role"] != "assistant":
continue
# print agent name in blue
print(f"\033[94m{message['sender']}\033[0m:", end=" ")
# print response, if any
if message["content"]:
print(message["content"])
# print tool calls in purple, if any
tool_calls = message.get("tool_calls") or []
if len(tool_calls) > 1:
print()
for tool_call in tool_calls:
f = tool_call["function"]
name, args = f["name"], f["arguments"]
arg_str = json.dumps(json.loads(args)).replace(":", "=")
print(f"\033[95m{name}\033[0m({arg_str[1:-1]})")
def run_demo_loop(
starting_agent, context_variables=None, stream=False, debug=False
) -> None:
client = Swarm()
print("Starting Swarm CLI 🐝")
messages = []
agent = starting_agent
while True:
user_input = input("\033[90mUser\033[0m: ")
messages.append({"role": "user", "content": user_input})
response = client.run(
agent=agent,
messages=messages,
context_variables=context_variables or {},
stream=stream,
debug=debug,
)
if stream:
response = process_and_print_streaming_response(response)
else:
pretty_print_messages(response.messages)
messages.extend(response.messages)
agent = response.agent

View file

@ -1,54 +0,0 @@
from __future__ import annotations
from openai.types.chat import ChatCompletionMessage
from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall,
Function,
)
from typing import List, Callable, Union, Optional, Dict
# Third-party imports
from pydantic import BaseModel
AgentFunction = Callable[[], Union[str, "Agent", dict]]
class Agent(BaseModel):
name: str = "Agent"
model: str = "gpt-4o"
type: str = ""
instructions: Union[str, Callable[[], str]] = "You are a helpful agent.",
description: str = "This is a helpful agent."
candidate_parent_functions: Dict[str, AgentFunction] = {}
parent_function: AgentFunction = None
child_functions: Dict[str, AgentFunction] = {}
internal_tools: List[Dict] = []
external_tools: List[Dict] = []
tool_choice: str = None
parallel_tool_calls: bool = True
respond_to_user: bool = True
history: List[Dict] = []
children_names: List[str] = []
children: Dict[str, "Agent"] = {}
most_recent_parent: Optional["Agent"] = None
parent: "Agent" = None
class Response(BaseModel):
messages: List = []
agent: Optional[Agent] = None
context_variables: dict = {}
error_msg: Optional[str] = ""
tokens_used: dict = {}
class Result(BaseModel):
"""
Encapsulates the possible return values for an agent function.
Attributes:
value (str): The result value as a string.
agent (Agent): The agent instance, if applicable.
context_variables (dict): A dictionary of context variables.
"""
value: str = ""
agent: Optional[Agent] = None
context_variables: dict = {}

View file

@ -1,175 +0,0 @@
import inspect
import json
from datetime import datetime
import os
from dotenv import load_dotenv
from src.utils.common import read_json_from_file, get_api_key
load_dotenv()
OPENAI_API_KEY = get_api_key("OPENAI_API_KEY")
def debug_print(debug: bool, *args: str) -> None:
if not debug:
return
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
message = " ".join(map(str, args))
print(f"\033[97m[\033[90m{timestamp}\033[97m]\033[90m {message}\033[0m")
def merge_fields(target, source):
for key, value in source.items():
if isinstance(value, str):
target[key] += value
elif value is not None and isinstance(value, dict):
merge_fields(target[key], value)
def merge_chunk(final_response: dict, delta: dict) -> None:
delta.pop("role", None)
merge_fields(final_response, delta)
tool_calls = delta.get("tool_calls")
if tool_calls and len(tool_calls) > 0:
index = tool_calls[0].pop("index")
merge_fields(final_response["tool_calls"][index], tool_calls[0])
def function_to_json(func) -> dict:
"""
Converts a Python function into a JSON-serializable dictionary
that describes the function's signature, including its name,
description, and parameters.
Args:
func: The function to be converted.
Returns:
A dictionary representing the function's signature in JSON format.
"""
type_map = {
str: "string",
int: "integer",
float: "number",
bool: "boolean",
list: "array",
dict: "object",
type(None): "null",
}
try:
signature = inspect.signature(func)
except ValueError as e:
raise ValueError(
f"Failed to get signature for function {func.__name__}: {str(e)}"
)
parameters = {}
for param in signature.parameters.values():
try:
param_type = type_map.get(param.annotation, "string")
except KeyError as e:
raise KeyError(
f"Unknown type annotation {param.annotation} for parameter {param.name}: {str(e)}"
)
parameters[param.name] = {"type": param_type}
required = [
param.name
for param in signature.parameters.values()
if param.default == inspect._empty
]
return {
"type": "function",
"function": {
"name": func.__name__,
"description": func.__doc__ or "",
"parameters": {
"type": "object",
"properties": parameters,
"required": required,
},
},
}
def get_current_turn_messages(messages, only_user = False):
if only_user:
return [msg for msg in messages if msg.get("current_turn") and msg.get("role") == "user"]
else:
return [msg for msg in messages if msg.get("current_turn")]
def arrange_messages_keys_in_order(messages):
"""Arranges message keys in a specific order: id, role, sender, relevant_agents, content, created_at, timestamp, followed by rest alphabetically"""
key_order = ['role', 'sender', 'content', 'created_at']
def sort_keys(message):
# Create new dict with specified key order
ordered = {}
# Add keys in specified order if they exist
for key in key_order:
if key in message:
ordered[key] = message[key]
# Add remaining keys in alphabetical order
for key in sorted(message.keys()):
if key not in key_order:
ordered[key] = message[key]
return ordered
return [sort_keys(message) for message in messages]
def remove_irrelevant_messages(messages):
"""Removes all messages from and including the latest user message"""
for i in range(len(messages)-1, -1, -1):
if messages[i].get("role") == "user":
return messages[:i]
return messages
def update_histories(active_agent, message):
active_agent.history.append(message)
return active_agent
def remove_none_fields(message):
return {k: v for k, v in message.items() if v is not None}
def add_message_metadata(message, active_agent):
message = remove_none_fields(message)
message["created_at"] = datetime.now().isoformat()
message["current_turn"] = True
if active_agent.respond_to_user:
message["response_type"] = "external"
else:
message["response_type"] = "internal"
return message
def check_and_remove_repeat_tool_call_to_child(agent, messages):
# If in the current turn, the most recent assistant message (need not be the last message overall, just needs to be the last message with role as assistant) is a tool call from a child agent, which transfers control to the agent using its parent function, then remove the tool call to transfer to that child again from this agent. This is to prevent back and forth between this agent and the child agent.
for message in reversed(messages):
if message.get("role") == "assistant" and message.get("sender") in agent.children_names and message.get("tool_calls"):
tool_call = message.get("tool_calls")[0]
child_agent = agent.children.get(message.get("sender"), None)
if not child_agent:
continue
child_agent_name = child_agent.name
if tool_call.get("function").get("name") == child_agent.parent_function:
agent.children_names.remove(child_agent_name)
agent.children.pop(child_agent_name)
agent.child_functions.pop(child_agent_name)
break
return agent
def update_tokens_used(provider, model, tokens_used, completion):
provider_model = f"{provider}/{model}"
input_tokens = completion.usage.prompt_tokens
output_tokens = completion.usage.completion_tokens
if provider_model not in tokens_used:
tokens_used[provider_model] = {
'input_tokens': 0,
'output_tokens': 0,
}
tokens_used[provider_model]['input_tokens'] += input_tokens
tokens_used[provider_model]['output_tokens'] += output_tokens
return tokens_used

View file

@ -1,424 +0,0 @@
{
"lastRequest": {
"messages": [
{
"content": "hi",
"role": "user",
"sender": null,
"tool_calls": null,
"tool_call_id": null,
"tool_name": null
},
{
"content": "Hello! How can I assist you today with your XYZ Bike?",
"role": "assistant",
"sender": "Main agent",
"tool_calls": null,
"tool_call_id": null,
"tool_name": null,
"response_type": "internal"
},
{
"content": "Hello! How can I assist you today with your XYZ Bike?",
"role": "assistant",
"sender": "Main agent >> Post process",
"tool_calls": null,
"tool_call_id": null,
"tool_name": null,
"response_type": "external"
},
{
"content": "i want to know about the range",
"role": "user",
"sender": null,
"tool_calls": null,
"tool_call_id": null,
"tool_name": null
},
{
"content": null,
"role": "assistant",
"sender": "Main agent",
"tool_calls": [
{
"function": {
"arguments": "{\"args\":\"\",\"kwargs\":\"\"}",
"name": "transfer_to_product_info_agent"
},
"id": "call_0MJHin0XCMyEJjA7T2FTJLZL",
"type": "function"
}
],
"tool_call_id": null,
"tool_name": null,
"response_type": "internal"
},
{
"content": "{\"assistant\": \"Product info agent\"}",
"role": "tool",
"sender": null,
"tool_calls": null,
"tool_call_id": "call_0MJHin0XCMyEJjA7T2FTJLZL",
"tool_name": "transfer_to_product_info_agent"
},
{
"content": null,
"role": "assistant",
"sender": "Product info agent",
"tool_calls": [
{
"function": {
"arguments": "{\"question\":\"XYZ Bike travel range\"}",
"name": "getArticleInfo"
},
"id": "call_CcNzb2N3lBt4JOCVrzyHdpdL",
"type": "function"
}
],
"tool_call_id": null,
"tool_name": null,
"response_type": "internal"
},
{
"content": "{\"results\":[{\"title\":\"XYZ Electric Bike\",\"content\":\"# XYZ Electric Bike\\n\\n### Transforming Transportation with the XYZ Electric Bike ### Revolutionizing Urban Mobility XYZ Electric Bike reimagines how we navigate cities, offering a seamless, stress-free alternative to traffic jams, pricey rideshares, rigid schedules, and the hassle of finding parking. --- #### **Instant Foldability** With a single press, XYZ's proprietary hinge mechanism folds the bike smoothly and securely in one swift motion. This innovation makes carrying and storing the bike effortless—outperforming the competition in both speed and ease of use. --- #### **Exceptional Handlebars** The sleek magnesium alloy handlebars are a marvel of design, housing intuitive controls for acceleration, braking, the horn, and LED lights, all within a streamlined, wire-free structure. Magnesium's lightweight properties—33% lighter than aluminum—make XYZ one of the most portable electric bikes available. --- #### **Unmatched Frame Design** Crafted with precision using TORAY carbon fiber, the frame achieves the perfect balance between strength and minimal weight. The material, meticulously layered for durability, is the same advanced composite used in aerospace engineering. --- #### **Impressive Range** Powered by premium electric batteries, XYZ bikes are designed for extended use with fast charging times. Their energy management system ensures long-lasting performance, providing ranges of up to 25 miles per charge, depending on riding conditions. --- #### **Dynamic Power** Equipped with dual motors delivering up to 1,000 watts at peak output, XYZ effortlessly handles steep inclines and challenging terrains. Rare-earth magnets and thermal regulation technology ensure high efficiency and reliability. --- #### **Puncture-Proof Tires** Say goodbye to flat tires. XYZ's solid rubber tires incorporate innovative air pockets for built-in shock absorption, delivering a smooth yet responsive ride across various surfaces. --- #### **Advanced Braking System** XYZ's braking system combines electronic anti-lock functionality with a user-friendly friction brake. Riders can enjoy a customizable braking experience, whether relying on fingertip controls or a traditional foot brake. --- #### **Durable and Comfortable Deck** The single-piece aluminum deck integrates a silicon surface for superior grip, eliminating unnecessary bulk or harsh finishes for a clean, modern look. --- #### **Invisible Kickstand** XYZ's custom-designed kickstand is seamlessly integrated, providing stability without disrupting the bike's sleek aesthetics. --- ### Models Comparison #### **XYZ Classic** - Price: $990 - Range: Up to 12 miles - Charge Time: 3.5 hours (80%) - Weight: 28.5 lbs #### **XYZ Voyager** - Price: $1,490 - Range: Up to 25 miles - Charge Time: 2 hours (80%) - Weight: 29.6 lbs - Features: App integration for enhanced control and ride stats --- XYZ Electric Bike is not just a mode of transport—it's the future of urban mobility, combining cutting-edge technology, top-tier materials, and unparalleled design for a ride that's as stylish as it is functional.\"}]}",
"role": "tool",
"sender": null,
"tool_calls": null,
"tool_call_id": "call_CcNzb2N3lBt4JOCVrzyHdpdL",
"tool_name": "getArticleInfo"
}
],
"state": {
"agent_data": [
{
"child_functions": [
"transfer_to_product_info_agent",
"transfer_to_delivery_info_agent",
"transfer_to_subscriptions_agent"
],
"external_tools": [],
"history": [
{
"content": "hi",
"current_turn": false,
"role": "user"
},
{
"content": "Hello! How can I assist you today with your XYZ Bike?",
"created_at": "2024-12-18T07:45:03.670088",
"current_turn": false,
"response_type": "internal",
"role": "assistant",
"sender": "Main agent"
},
{
"content": "i want to know about the range",
"current_turn": true,
"role": "user"
},
{
"created_at": "2024-12-18T07:45:13.240846",
"current_turn": true,
"response_type": "internal",
"role": "assistant",
"sender": "Main agent",
"tool_calls": [
{
"function": {
"arguments": "{\"args\":\"\",\"kwargs\":\"\"}",
"name": "transfer_to_product_info_agent"
},
"id": "call_0MJHin0XCMyEJjA7T2FTJLZL",
"type": "function"
}
]
},
{
"content": "{\"assistant\": \"Product info agent\"}",
"created_at": "2024-12-18T07:45:13.241184",
"current_turn": true,
"response_type": "internal",
"role": "tool",
"tool_call_id": "call_0MJHin0XCMyEJjA7T2FTJLZL",
"tool_name": "transfer_to_product_info_agent"
},
{
"created_at": "2024-12-18T07:45:13.821351",
"current_turn": true,
"response_type": "internal",
"role": "assistant",
"sender": "Product info agent",
"tool_calls": [
{
"function": {
"arguments": "{\"question\":\"XYZ Bike travel range\"}",
"name": "getArticleInfo"
},
"id": "call_CcNzb2N3lBt4JOCVrzyHdpdL",
"type": "function"
}
]
}
],
"instructions": "Role:\nYou are a customer support agent for XYZ Bikes. Your primary task is to facilitate conversations by passing control to specialized worker agents when needed.\n\n---\n\nTasks to Follow:\n- Engage in small talk if no specific question is asked.\n- Pass control to the appropriate worker agents for specialized conversations.\n\n---\n\nSmall Talk:\nYou are welcome to engage in basic small talk to build rapport.\n\n---\n\nExamples:\n\n---\nIn Scope Example 1:\nUser: How are you?\nAnswer: \"I'm doing well, thank you! How can I assist you today?\"\n\n---\nIn Scope Example 2:\nUser: What can you do?\nAnswer: \"I can help with customer support-related issues for XYZ Bikes. Let me know if you have any questions.\"\n\n---\nIn Scope Example 3:\nUser: I want a XYZ Bike.\nAnswer: \"What would you like to know about XYZ Bikes?\"\n\n---\nPass Control Example 1:\nUser: Tell me about the product features.\nAction: Pass control to the Product info agent.\n\n---\nPass Control Example 2:\nUser: Where is my scooter?\nAction: Pass control to the Delivery info agent.\n\n---\nPass Control Example 3:\nUser: I need help with my return.\nAction: Pass control to the Returns agent.\n\n---\nPass Control Example 4:\nUser: How does the Unagi subscription work?\nAction: Pass control to the Subscriptions agent.\n\n---\n✅ Dos:\n- Engage in small talk when necessary.\n- Pass control to the appropriate agent based on the user's query.\n\n---\n❌ Don'ts:\n- Do not focus excessively on greetings during ongoing conversations.\n- Do not continue the conversation if you suspect the user is confused or uninterested in Unagi support.\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"internal_tools": [],
"most_recent_parent_name": "",
"name": "Main agent",
"parent_function": null
},
{
"child_functions": [],
"external_tools": [
"getArticleInfo"
],
"history": [
{
"content": "i want to know about the range",
"current_turn": true,
"role": "user"
},
{
"created_at": "2024-12-18T07:45:13.821351",
"current_turn": true,
"response_type": "internal",
"role": "assistant",
"sender": "Product info agent",
"tool_calls": [
{
"function": {
"arguments": "{\"question\":\"XYZ Bike travel range\"}",
"name": "getArticleInfo"
},
"id": "call_CcNzb2N3lBt4JOCVrzyHdpdL",
"type": "function"
}
]
}
],
"instructions": "🧑‍💼 Role:\nYou are a product information agent for XYZ Bikes. Your job is to answer search for the right article and answer questions strictly based on the article about Unagi products. Feel free to ask the user clarification questions if needed.\n\n---\n\n📜 Instructions:\n\n✅ In Scope:\n- Answer questions strictly about Unagi product information.\n\n❌ Out of Scope:\n- Questions about delivery, returns, subscriptions, and promotions.\n- Any topic unrelated to Unagi products.\n- If a question is out of scope, call give_up_control and do not attempt to answer it.\n\n---\n\n✔ Dos:\n- Stick to the facts provided in the articles.\n- Provide complete and direct answers to the user's questions.\n- Call the Greeting agent after each interaction.\n\n---\n\n🚫 Don'ts:\n- Do not partially answer questions or direct users to a URL for more information.\n- Do not provide information outside of the given context.\n\n---\n\n📝 Examples:\n\n---\nIn Scope Example 1:\nUser: What is the maximum speed of the Unagi E500?\nAction: Call get_article_info followed by <answer based on the retrieved context about the maximum speed of the Unagi E500>.\n\n---\nIn Scope Example 2:\nUser: How long does it take to charge a XYZ Bike fully?\nAction: Call get_article_info followed by <answer based on the retrieved context about the charging duration>.\n\n---\nIn Scope Example 3:\nUser: Can you tell me about the weight-carrying capacity of XYZ Bikes?\nAction: Call get_article_info followed by <answer based on the retrieved context about weight capacity>.\n\n---\nIn Scope Example 4:\nUser: What are the differences between the E250 and E500 models?\nAction: Call get_article_info followed by <answer based on the retrieved context about model differences>.\n\n---\nIn Scope Example 5:\nUser: How far can I travel on a single charge with the E500?\nAction: Call get_article_info followed by <answer based on the retrieved context about travel range>.\n\n---\nIn Scope Example 6:\nUser: Is the scooter waterproof?\nAction: Call get_article_info followed by <answer based on the retrieved context about waterproofing>.\n\n---\nIn Scope Example 7:\nUser: Does the scooter have any safety features?\nAction: Call get_article_info followed by <answer based on the retrieved context about safety features>.\n\n---\nIn Scope Example 8:\nUser: What materials are used to make XYZ Bikes?\nAction: Call get_article_info followed by <answer based on the retrieved context about materials>.\n\n---\nIn Scope Example 9:\nUser: Can the scooter be used off-road?\nAction: Call get_article_info followed by <answer based on the retrieved context about off-road capability>.\n\n---\nIn Scope Example 10:\nUser: Are spare parts available for purchase?\nAction: Call get_article_info followed by <answer based on the retrieved context about spare parts availability>.\n\n---\nOut of Scope Example 1:\nUser: What is the status of my order delivery?\nAction: Call give_up_control.\n\n---\nOut of Scope Example 2:\nUser: How do I process a return?\nAction: Call give_up_control.\n\n---\nOut of Scope Example 3:\nUser: Can you tell me more about the subscription plans?\nAction: Call give_up_control.\n\n---\nOut of Scope Example 4:\nUser: Are there any promotions or discounts?\nAction: Call give_up_control.\n\n---\nOut of Scope Example 5:\nUser: Who won the last election?\nAction: Call give_up_control.\n\nProvide your output in the following structured JSON format:\n\n{\n \"steps_completed\": <number of steps completed, e.g., 1, 2, etc.>,\n \"current_step\": <current step number, e.g., 1>,\n \"reasoning\": \"<reasoning behind the response>\",\n \"error_count\": <number of errors encountered>,\n \"response_to_user\": \"<response to the user, ensure any detailed information such as tables or lists is included within this field>\"\n}\n\nAlways ensure that all pertinent details, including tables or structured lists, are contained within the response_to_user field to maintain clarity and a comprehensive response for the user.\n\nRetrieval instructions:\n\nIn every turn, retrieve a relevant article and use the information from that article to answer the user's question.\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"internal_tools": [],
"most_recent_parent_name": "Main agent",
"name": "Product info agent",
"parent_function": "give_up_chat_control"
},
{
"child_functions": [],
"external_tools": [
"get_delivery_details",
"getArticleInfo"
],
"history": [],
"instructions": "Role:\nYou are responsible for providing delivery information to the user.\n\n---\n\n⚙ Steps to Follow:\n1. Fetch the delivery details using the function: get_shipping_details.\n2. Answer the user's question based on the fetched delivery details.\n3. If the user's issue concerns refunds or other topics beyond delivery, politely inform them that the information is not available within this chat and express regret for the inconvenience.\n\n---\n\n✅ In Scope:\nQuestions about delivery status, shipping timelines, and delivery processes.\nGeneric delivery/shipping-related questions where answers can be sourced from articles.\n\n---\n\n❌ Out of Scope:\nQuestions unrelated to delivery or shipping.\nQuestions about products features, returns, subscriptions, or promotions.\nIf a question is out of scope, politely inform the user and avoid providing an answer.\n\n---\n\nExample 1:\nUser: What is the status of my delivery?\nAction: Call get_delivery_details to fetch the current delivery status and inform the user.\n\nExample 2:\nUser: Can you explain the delivery process?\nAction: Provide a detailed answer and clarify any user questions based on the articles.\n\nExample 3:\nUser: I have a question about product features such as range, durability etc.\nAction: give_up_control as this is not in your scope.\n\n---\n\n✅ Dos:\nUse get_shipping_details to fetch accurate delivery information.\nProvide complete and clear answers based on the delivery details.\nFor generic delivery questions, refer to relevant articles if necessary.\nStick to factual information when answering.\n\n---\n\n❌ Don'ts:\nDo not provide answers without fetching delivery details when required.\nDo not leave the user with partial information.\nRefrain from phrases like 'please contact support'; instead, relay information limitations gracefully.\n\nProvide your output in the following structured JSON format:\n\n{\n \"steps_completed\": <number of steps completed, e.g., 1, 2, etc.>,\n \"current_step\": <current step number, e.g., 1>,\n \"reasoning\": \"<reasoning behind the response>\",\n \"error_count\": <number of errors encountered>,\n \"response_to_user\": \"<response to the user, ensure any detailed information such as tables or lists is included within this field>\"\n}\n\nAlways ensure that all pertinent details, including tables or structured lists, are contained within the response_to_user field to maintain clarity and a comprehensive response for the user.\n\nRetrieval instructions:\n\nIn every turn, retrieve a relevant article and use the information from that article to answer the user's question.\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"internal_tools": [],
"most_recent_parent_name": "",
"name": "Delivery info agent",
"parent_function": null
},
{
"child_functions": [],
"external_tools": [],
"history": [],
"instructions": "talk about returns\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"internal_tools": [],
"most_recent_parent_name": "",
"name": "Returns agent",
"parent_function": null
},
{
"child_functions": [],
"external_tools": [],
"history": [],
"instructions": "talk about subscriptions\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"internal_tools": [],
"most_recent_parent_name": "",
"name": "Subscriptions agent",
"parent_function": null
},
{
"child_functions": [],
"external_tools": [],
"history": [],
"instructions": "Talk about promotions\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"internal_tools": [],
"most_recent_parent_name": "",
"name": "Promotions agent",
"parent_function": null
},
{
"child_functions": [],
"external_tools": [],
"history": [],
"instructions": "Role:\nYou are a test agent for XYZ Bikes. Your job is to help test the functionality of different operations within the system.\n\n---\n\nTasks to Follow:\n- Assist in simulating various scenarios and operations to ensure smooth functioning.\n- Report any discrepancies or issues observed during testing.\n\n---\n\nIn Scope:\n- Conduct user interaction tests.\n- Evaluate agent response accuracy.\n- Validate agent transition accuracy.\n\n---\n\nOut of Scope:\n- Direct customer interactions outside of test scenarios.\n- Handling of live customer support queries.\n\n---\n\nDos:\n- Conduct comprehensive tests to cover all expected operations and scenarios.\n- Document test outcomes clearly.\n\n---\n\nDon'ts:\n- Do not intervene in live interactions unless part of a test scenario.\n- Ensure test operations do not affect live customer service functions.\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"internal_tools": [],
"most_recent_parent_name": "",
"name": "Test agent",
"parent_function": null
}
],
"last_agent_name": "Product info agent"
},
"agents": [
{
"name": "Main agent",
"type": "conversation",
"description": "The Main agent orchestrates interactions between various specialized worker agents to ensure efficient handling of user queries and support needs.",
"instructions": "Role:\nYou are a customer support agent for XYZ Bikes. Your primary task is to facilitate conversations by passing control to specialized worker agents when needed.\n\n---\n\nTasks to Follow:\n- Engage in small talk if no specific question is asked.\n- Pass control to the appropriate worker agents for specialized conversations.\n\n---\n\nSmall Talk:\nYou are welcome to engage in basic small talk to build rapport.\n\n---\n\nExamples:\n\n---\nIn Scope Example 1:\nUser: How are you?\nAnswer: \"I'm doing well, thank you! How can I assist you today?\"\n\n---\nIn Scope Example 2:\nUser: What can you do?\nAnswer: \"I can help with customer support-related issues for XYZ Bikes. Let me know if you have any questions.\"\n\n---\nIn Scope Example 3:\nUser: I want a XYZ Bike.\nAnswer: \"What would you like to know about XYZ Bikes?\"\n\n---\nPass Control Example 1:\nUser: Tell me about the product features.\nAction: Pass control to the Product info agent.\n\n---\nPass Control Example 2:\nUser: Where is my scooter?\nAction: Pass control to the Delivery info agent.\n\n---\nPass Control Example 3:\nUser: I need help with my return.\nAction: Pass control to the Returns agent.\n\n---\nPass Control Example 4:\nUser: How does the Unagi subscription work?\nAction: Pass control to the Subscriptions agent.\n\n---\n✅ Dos:\n- Engage in small talk when necessary.\n- Pass control to the appropriate agent based on the user's query.\n\n---\n❌ Don'ts:\n- Do not focus excessively on greetings during ongoing conversations.\n- Do not continue the conversation if you suspect the user is confused or uninterested in Unagi support.\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"tools": [],
"model": "gpt-4o-mini",
"hasRagSources": false,
"connectedAgents": [
"Product info agent",
"Delivery info agent",
"Subscriptions agent"
],
"controlType": "retain"
},
{
"name": "Post process",
"type": "post_process",
"instructions": "- Extract the response_to_user field from the provided structured JSON and ensure that this is the only content you use for the final output.\n- Ensure that the agent response covers all the details the user asked for.\n- When providing long details, use bullets to distinguish the different points. \n- Focus specifically on the response_to_user field in its input.\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"tools": [],
"model": "gpt-4o",
"hasRagSources": false,
"connectedAgents": []
},
{
"name": "Product info agent",
"type": "conversation",
"description": "You assist with product-related questions by retrieving relevant articles and information.",
"instructions": "🧑‍💼 Role:\nYou are a product information agent for XYZ Bikes. Your job is to answer search for the right article and answer questions strictly based on the article about Unagi products. Feel free to ask the user clarification questions if needed.\n\n---\n\n📜 Instructions:\n\n✅ In Scope:\n- Answer questions strictly about Unagi product information.\n\n❌ Out of Scope:\n- Questions about delivery, returns, subscriptions, and promotions.\n- Any topic unrelated to Unagi products.\n- If a question is out of scope, call give_up_control and do not attempt to answer it.\n\n---\n\n✔ Dos:\n- Stick to the facts provided in the articles.\n- Provide complete and direct answers to the user's questions.\n- Call the Greeting agent after each interaction.\n\n---\n\n🚫 Donts:\n- Do not partially answer questions or direct users to a URL for more information.\n- Do not provide information outside of the given context.\n\n---\n\n📝 Examples:\n\n---\nIn Scope Example 1:\nUser: What is the maximum speed of the Unagi E500?\nAction: Call get_article_info followed by <answer based on the retrieved context about the maximum speed of the Unagi E500>.\n\n---\nIn Scope Example 2:\nUser: How long does it take to charge a XYZ Bike fully?\nAction: Call get_article_info followed by <answer based on the retrieved context about the charging duration>.\n\n---\nIn Scope Example 3:\nUser: Can you tell me about the weight-carrying capacity of XYZ Bikes?\nAction: Call get_article_info followed by <answer based on the retrieved context about weight capacity>.\n\n---\nIn Scope Example 4:\nUser: What are the differences between the E250 and E500 models?\nAction: Call get_article_info followed by <answer based on the retrieved context about model differences>.\n\n---\nIn Scope Example 5:\nUser: How far can I travel on a single charge with the E500?\nAction: Call get_article_info followed by <answer based on the retrieved context about travel range>.\n\n---\nIn Scope Example 6:\nUser: Is the scooter waterproof?\nAction: Call get_article_info followed by <answer based on the retrieved context about waterproofing>.\n\n---\nIn Scope Example 7:\nUser: Does the scooter have any safety features?\nAction: Call get_article_info followed by <answer based on the retrieved context about safety features>.\n\n---\nIn Scope Example 8:\nUser: What materials are used to make XYZ Bikes?\nAction: Call get_article_info followed by <answer based on the retrieved context about materials>.\n\n---\nIn Scope Example 9:\nUser: Can the scooter be used off-road?\nAction: Call get_article_info followed by <answer based on the retrieved context about off-road capability>.\n\n---\nIn Scope Example 10:\nUser: Are spare parts available for purchase?\nAction: Call get_article_info followed by <answer based on the retrieved context about spare parts availability>.\n\n---\nOut of Scope Example 1:\nUser: What is the status of my order delivery?\nAction: Call give_up_control.\n\n---\nOut of Scope Example 2:\nUser: How do I process a return?\nAction: Call give_up_control.\n\n---\nOut of Scope Example 3:\nUser: Can you tell me more about the subscription plans?\nAction: Call give_up_control.\n\n---\nOut of Scope Example 4:\nUser: Are there any promotions or discounts?\nAction: Call give_up_control.\n\n---\nOut of Scope Example 5:\nUser: Who won the last election?\nAction: Call give_up_control.\n\nProvide your output in the following structured JSON format:\n\n{\n \"steps_completed\": <number of steps completed, e.g., 1, 2, etc.>,\n \"current_step\": <current step number, e.g., 1>,\n \"reasoning\": \"<reasoning behind the response>\",\n \"error_count\": <number of errors encountered>,\n \"response_to_user\": \"<response to the user, ensure any detailed information such as tables or lists is included within this field>\"\n}\n\nAlways ensure that all pertinent details, including tables or structured lists, are contained within the response_to_user field to maintain clarity and a comprehensive response for the user.\n\nRetrieval instructions:\n\nIn every turn, retrieve a relevant article and use the information from that article to answer the user's question.\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"tools": [],
"model": "gpt-4o-mini",
"hasRagSources": true,
"connectedAgents": [],
"controlType": "relinquish_to_parent"
},
{
"name": "Delivery info agent",
"type": "conversation",
"description": "You are responsible for providing accurate delivery status and shipping details for orders.",
"instructions": "Role:\nYou are responsible for providing delivery information to the user.\n\n---\n\n⚙ Steps to Follow:\n1. Fetch the delivery details using the function: get_shipping_details.\n2. Answer the user's question based on the fetched delivery details.\n3. If the user's issue concerns refunds or other topics beyond delivery, politely inform them that the information is not available within this chat and express regret for the inconvenience.\n\n---\n\n✅ In Scope:\nQuestions about delivery status, shipping timelines, and delivery processes.\nGeneric delivery/shipping-related questions where answers can be sourced from articles.\n\n---\n\n❌ Out of Scope:\nQuestions unrelated to delivery or shipping.\nQuestions about products features, returns, subscriptions, or promotions.\nIf a question is out of scope, politely inform the user and avoid providing an answer.\n\n---\n\nExample 1:\nUser: What is the status of my delivery?\nAction: Call get_delivery_details to fetch the current delivery status and inform the user.\n\nExample 2:\nUser: Can you explain the delivery process?\nAction: Provide a detailed answer and clarify any user questions based on the articles.\n\nExample 3:\nUser: I have a question about product features such as range, durability etc.\nAction: give_up_control as this is not in your scope.\n\n---\n\n✅ Dos:\nUse get_shipping_details to fetch accurate delivery information.\nProvide complete and clear answers based on the delivery details.\nFor generic delivery questions, refer to relevant articles if necessary.\nStick to factual information when answering.\n\n---\n\n❌ Donts:\nDo not provide answers without fetching delivery details when required.\nDo not leave the user with partial information.\nRefrain from phrases like 'please contact support'; instead, relay information limitations gracefully.\n\nProvide your output in the following structured JSON format:\n\n{\n \"steps_completed\": <number of steps completed, e.g., 1, 2, etc.>,\n \"current_step\": <current step number, e.g., 1>,\n \"reasoning\": \"<reasoning behind the response>\",\n \"error_count\": <number of errors encountered>,\n \"response_to_user\": \"<response to the user, ensure any detailed information such as tables or lists is included within this field>\"\n}\n\nAlways ensure that all pertinent details, including tables or structured lists, are contained within the response_to_user field to maintain clarity and a comprehensive response for the user.\n\nRetrieval instructions:\n\nIn every turn, retrieve a relevant article and use the information from that article to answer the user's question.\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"tools": [
"get_delivery_details"
],
"model": "gpt-4o-mini",
"hasRagSources": true,
"connectedAgents": [],
"controlType": "retain"
},
{
"name": "Returns agent",
"type": "conversation",
"description": "You provide assistance for inquiries and processes related to product returns.",
"instructions": "talk about returns\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"tools": [],
"model": "gpt-4o-mini",
"hasRagSources": false,
"connectedAgents": []
},
{
"name": "Subscriptions agent",
"type": "conversation",
"description": "You handle all subscription-related queries from customers.",
"instructions": "talk about subscriptions\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"tools": [],
"model": "gpt-4o-mini",
"hasRagSources": false,
"connectedAgents": []
},
{
"name": "Promotions agent",
"type": "conversation",
"description": "You provide current promotions and discounts details to the customers.",
"instructions": "Talk about promotions\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"tools": [],
"model": "gpt-4o-mini",
"hasRagSources": false,
"connectedAgents": []
},
{
"name": "Test agent",
"type": "conversation",
"description": "Your job is to simulate various customer interactions and test system operations for quality assurance purposes.",
"instructions": "Role:\nYou are a test agent for XYZ Bikes. Your job is to help test the functionality of different operations within the system.\n\n---\n\nTasks to Follow:\n- Assist in simulating various scenarios and operations to ensure smooth functioning.\n- Report any discrepancies or issues observed during testing.\n\n---\n\nIn Scope:\n- Conduct user interaction tests.\n- Evaluate agent response accuracy.\n- Validate agent transition accuracy.\n\n---\n\nOut of Scope:\n- Direct customer interactions outside of test scenarios.\n- Handling of live customer support queries.\n\n---\n\nDos:\n- Conduct comprehensive tests to cover all expected operations and scenarios.\n- Document test outcomes clearly.\n\n---\n\nDonts:\n- Do not intervene in live interactions unless part of a test scenario.\n- Ensure test operations do not affect live customer service functions.\n\nSelf Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'.",
"tools": [],
"model": "gpt-4o-mini",
"hasRagSources": false,
"connectedAgents": []
},
{
"name": "Escalation",
"type": "escalation",
"description": "",
"instructions": "Get the user's contact information and let them know that their request has been escalated.\n\n",
"tools": [],
"model": "gpt-4o-mini",
"hasRagSources": false,
"connectedAgents": [],
"controlType": "retain"
}
],
"tools": [
{
"name": "get_delivery_details",
"description": "Return a estimated delivery date for the XYZ Bike.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
},
{
"name": "get_subscription_plan_details",
"description": "Return details of the available subscription plans for XYZ Bikes.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
},
{
"name": "get_current_date",
"description": "Return the current date.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
],
"prompts": [
{
"name": "Style prompt",
"type": "style_prompt",
"prompt": "You should be empathetic and helpful."
},
{
"name": "reasoning_output",
"type": "base_prompt",
"prompt": "Give your output in the following format:\n\nreason : <a single sentence reasoning behind your response>\n\nresponse_to_user : <response to user>"
},
{
"name": "get_delivery_details",
"type": "base_prompt",
"prompt": "Return a estimated delivery date for XYZ Bike."
},
{
"name": "structured_output",
"type": "base_prompt",
"prompt": "Provide your output in the following structured JSON format:\n\n{\n \"steps_completed\": <number of steps completed, e.g., 1, 2, etc.>,\n \"current_step\": <current step number, e.g., 1>,\n \"reasoning\": \"<reasoning behind the response>\",\n \"error_count\": <number of errors encountered>,\n \"response_to_user\": \"<response to the user, ensure any detailed information such as tables or lists is included within this field>\"\n}\n\nAlways ensure that all pertinent details, including tables or structured lists, are contained within the response_to_user field to maintain clarity and a comprehensive response for the user."
},
{
"name": "rag_article_prompt",
"type": "base_prompt",
"prompt": "Retrieval instructions:\n\nIn every turn, retrieve a relevant article and use the information from that article to answer the user's question."
},
{
"name": "self_support_prompt",
"type": "base_prompt",
"prompt": "Self Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'."
}
],
"startAgent": "Main agent"
}
}

View file

@ -322,7 +322,7 @@ export function App({
chat,
messages: allMessages,
};
}, [sessionId]);
}, [sessionId, apiUrl]);
async function resetState() {
setChatId(null);

View file

@ -10,7 +10,7 @@
"dependencies": {
"@nextui-org/react": "^2.4.8",
"framer-motion": "^11.11.11",
"next": "^14.2.16",
"next": "^14.2.25",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
@ -308,9 +308,9 @@
}
},
"node_modules/@next/env": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.16.tgz",
"integrity": "sha512-fLrX5TfJzHCbnZ9YUSnGW63tMV3L4nSfhgOQ0iCcX21Pt+VSTDuaLsSuL8J/2XAiVA5AnzvXDpf6pMs60QxOag=="
"version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.25.tgz",
"integrity": "sha512-JnzQ2cExDeG7FxJwqAksZ3aqVJrHjFwZQAEJ9gQZSoEhIow7SNoKZzju/AwQ+PLIR4NY8V0rhcVozx/2izDO0w=="
},
"node_modules/@next/eslint-plugin-next": {
"version": "15.0.2",
@ -322,9 +322,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.16.tgz",
"integrity": "sha512-uFT34QojYkf0+nn6MEZ4gIWQ5aqGF11uIZ1HSxG+cSbj+Mg3+tYm8qXYd3dKN5jqKUm5rBVvf1PBRO/MeQ6rxw==",
"version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.25.tgz",
"integrity": "sha512-09clWInF1YRd6le00vt750s3m7SEYNehz9C4PUcSu3bAdCTpjIV4aTYQZ25Ehrr83VR1rZeqtKUPWSI7GfuKZQ==",
"cpu": [
"arm64"
],
@ -337,9 +337,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.16.tgz",
"integrity": "sha512-mCecsFkYezem0QiZlg2bau3Xul77VxUD38b/auAjohMA22G9KTJneUYMv78vWoCCFkleFAhY1NIvbyjj1ncG9g==",
"version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.25.tgz",
"integrity": "sha512-V+iYM/QR+aYeJl3/FWWU/7Ix4b07ovsQ5IbkwgUK29pTHmq+5UxeDr7/dphvtXEq5pLB/PucfcBNh9KZ8vWbug==",
"cpu": [
"x64"
],
@ -352,9 +352,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.16.tgz",
"integrity": "sha512-yhkNA36+ECTC91KSyZcgWgKrYIyDnXZj8PqtJ+c2pMvj45xf7y/HrgI17hLdrcYamLfVt7pBaJUMxADtPaczHA==",
"version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.25.tgz",
"integrity": "sha512-LFnV2899PJZAIEHQ4IMmZIgL0FBieh5keMnriMY1cK7ompR+JUd24xeTtKkcaw8QmxmEdhoE5Mu9dPSuDBgtTg==",
"cpu": [
"arm64"
],
@ -367,9 +367,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.16.tgz",
"integrity": "sha512-X2YSyu5RMys8R2lA0yLMCOCtqFOoLxrq2YbazFvcPOE4i/isubYjkh+JCpRmqYfEuCVltvlo+oGfj/b5T2pKUA==",
"version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.25.tgz",
"integrity": "sha512-QC5y5PPTmtqFExcKWKYgUNkHeHE/z3lUsu83di488nyP0ZzQ3Yse2G6TCxz6nNsQwgAx1BehAJTZez+UQxzLfw==",
"cpu": [
"arm64"
],
@ -382,9 +382,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.16.tgz",
"integrity": "sha512-9AGcX7VAkGbc5zTSa+bjQ757tkjr6C/pKS7OK8cX7QEiK6MHIIezBLcQ7gQqbDW2k5yaqba2aDtaBeyyZh1i6Q==",
"version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.25.tgz",
"integrity": "sha512-y6/ML4b9eQ2D/56wqatTJN5/JR8/xdObU2Fb1RBidnrr450HLCKr6IJZbPqbv7NXmje61UyxjF5kvSajvjye5w==",
"cpu": [
"x64"
],
@ -397,9 +397,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.16.tgz",
"integrity": "sha512-Klgeagrdun4WWDaOizdbtIIm8khUDQJ/5cRzdpXHfkbY91LxBXeejL4kbZBrpR/nmgRrQvmz4l3OtttNVkz2Sg==",
"version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.25.tgz",
"integrity": "sha512-sPX0TSXHGUOZFvv96GoBXpB3w4emMqKeMgemrSxI7A6l55VBJp/RKYLwZIB9JxSqYPApqiREaIIap+wWq0RU8w==",
"cpu": [
"x64"
],
@ -412,9 +412,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.16.tgz",
"integrity": "sha512-PwW8A1UC1Y0xIm83G3yFGPiOBftJK4zukTmk7DI1CebyMOoaVpd8aSy7K6GhobzhkjYvqS/QmzcfsWG2Dwizdg==",
"version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.25.tgz",
"integrity": "sha512-ReO9S5hkA1DU2cFCsGoOEp7WJkhFzNbU/3VUF6XxNGUCQChyug6hZdYL/istQgfT/GWE6PNIg9cm784OI4ddxQ==",
"cpu": [
"arm64"
],
@ -427,9 +427,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.16.tgz",
"integrity": "sha512-jhPl3nN0oKEshJBNDAo0etGMzv0j3q3VYorTSFqH1o3rwv1MQRdor27u1zhkgsHPNeY1jxcgyx1ZsCkDD1IHgg==",
"version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.25.tgz",
"integrity": "sha512-DZ/gc0o9neuCDyD5IumyTGHVun2dCox5TfPQI/BJTYwpSNYM3CZDI4i6TOdjeq1JMo+Ug4kPSMuZdwsycwFbAw==",
"cpu": [
"ia32"
],
@ -442,9 +442,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.16.tgz",
"integrity": "sha512-OA7NtfxgirCjfqt+02BqxC3MIgM/JaGjw9tOe4fyZgPsqfseNiMPnCRP44Pfs+Gpo9zPN+SXaFsgP6vk8d571A==",
"version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.25.tgz",
"integrity": "sha512-KSznmS6eFjQ9RJ1nEc66kJvtGIL1iZMYmGEXsZPh2YtnLtqrgdVvKXJY2ScjjoFnG6nGLyPFR0UiEvDwVah4Tw==",
"cpu": [
"x64"
],
@ -7499,11 +7499,11 @@
"dev": true
},
"node_modules/next": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.16.tgz",
"integrity": "sha512-LcO7WnFu6lYSvCzZoo1dB+IO0xXz5uEv52HF1IUN0IqVTUIZGHuuR10I5efiLadGt+4oZqTcNZyVVEem/TM5nA==",
"version": "14.2.25",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.25.tgz",
"integrity": "sha512-N5M7xMc4wSb4IkPvEV5X2BRRXUmhVHNyaXwEM86+voXthSZz8ZiRyQW4p9mwAoAPIm6OzuVZtn7idgEJeAJN3Q==",
"dependencies": {
"@next/env": "14.2.16",
"@next/env": "14.2.25",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
@ -7518,15 +7518,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.16",
"@next/swc-darwin-x64": "14.2.16",
"@next/swc-linux-arm64-gnu": "14.2.16",
"@next/swc-linux-arm64-musl": "14.2.16",
"@next/swc-linux-x64-gnu": "14.2.16",
"@next/swc-linux-x64-musl": "14.2.16",
"@next/swc-win32-arm64-msvc": "14.2.16",
"@next/swc-win32-ia32-msvc": "14.2.16",
"@next/swc-win32-x64-msvc": "14.2.16"
"@next/swc-darwin-arm64": "14.2.25",
"@next/swc-darwin-x64": "14.2.25",
"@next/swc-linux-arm64-gnu": "14.2.25",
"@next/swc-linux-arm64-musl": "14.2.25",
"@next/swc-linux-x64-gnu": "14.2.25",
"@next/swc-linux-x64-musl": "14.2.25",
"@next/swc-win32-arm64-msvc": "14.2.25",
"@next/swc-win32-ia32-msvc": "14.2.25",
"@next/swc-win32-x64-msvc": "14.2.25"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",

View file

@ -11,7 +11,7 @@
"dependencies": {
"@nextui-org/react": "^2.4.8",
"framer-motion": "^11.11.11",
"next": "^14.2.16",
"next": "^14.2.25",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",

View file

@ -51,6 +51,7 @@ def health():
def chat():
try:
request_data = ApiRequest(**request.json)
print(f"received /chat request: {request_data}")
validate_request(request_data)
response = get_response(
@ -61,7 +62,7 @@ def chat():
copilot_instructions=copilot_instructions
)
api_response = ApiResponse(response=response).model_dump()
print(f"sending /chat response: {api_response}")
return jsonify(api_response)
except ValidationError as ve:
@ -88,6 +89,7 @@ def chat():
def edit_agent_instructions():
try:
request_data = ApiRequest(**request.json)
print(f"received /edit_agent_instructions request: {request_data}")
validate_request(request_data)
response = get_response(
@ -99,6 +101,7 @@ def edit_agent_instructions():
)
api_response = ApiResponse(response=response).model_dump()
print(f"sending /edit_agent_instructions response: {api_response}")
return jsonify(api_response)
except ValidationError as ve:

View file

@ -20,7 +20,7 @@ copilot_instructions = """
## Overview
You are a helpful co-pilot for building and deploying customer support AI agents. Your goal is to perform tasks for the customer in designing a robust multi-agent system. You can perform the following tasks:
You are a helpful co-pilot for building and deploying multi-agent systems. Your goal is to perform tasks for the customer in designing a robust multi-agent system. You can perform the following tasks:
1. Plan and creating a multi-agent system
2. Create a new agent
@ -44,7 +44,7 @@ You are not equipped to perform the following tasks:
Agents in the system be of the following types:
1. Conversation
Carries out the core customer support related conversations. All new agents you create should be of type 'Conversation'.
Carries out the core customer conversations. All new agents you create should be of type 'Conversation'.
2. Post-processing
Ensures the output aligns with specific format and style requirements.
@ -257,22 +257,11 @@ Note : Always add a text section that describes the changes before each action.
**NOTE**: The output should be a valid JSON object. Do not include any other text or comments. Do not wrap the output in a code block.
## Section 11: State of the Current Multi-Agent System
The design of the multi-agent system is represented by the following JSON schema:
```
{workflow_schema}
```
If the workflow has an 'Example Agent' as the main agent, it means the user is yet to create the main agent. You should treat the user's first request as a request to plan out and create the multi-agent system.
## Section 12: Examples
## Section 11: Examples
### Example 1:
User: create a system to handle 2fa related customer support queries. The queries can be: 1. setting up 2fa : ask the users preferred methods 2. changing 2fa : chaing the 2fa method 3. troubleshooting : not getting 2fa codes etc.
User: create a system to handle 2fa related customer support queries for a banking app. The queries can be: 1. setting up 2fa : ask the users preferred methods 2. changing 2fa : chaing the 2fa method 3. troubleshooting : not getting 2fa codes etc.
Copilot output:
@ -297,6 +286,7 @@ Copilot output:
"config_changes": {
"name": "get_current_2fa_method",
"description": "Tool to fetch the user's current 2FA method.",
"mockInstructions": "Return a random 2FA method for a banking app.",
"parameters": {
"type": "object",
"properties": {
@ -327,7 +317,7 @@ Copilot output:
"name": "2FA Setup",
"type": "conversation",
"description": "Agent to guide users in setting up 2FA.",
"instructions": "## 🧑‍💼 Role:\nHelp users set up their 2FA preferences.\n\n---\n## ⚙️ Steps to Follow:\n1. Ask the user about their preferred 2FA method (e.g., SMS, Email).\n2. Confirm the setup method with the user.\n3. Guide them through the setup steps.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Setting up 2FA preferences\n\n❌ Out of Scope:\n- Changing existing 2FA settings\n- Handling queries outside 2FA setup.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Clearly explain setup options and steps.\n\n🚫 Don'ts:\n- Assume preferences without user confirmation.\n- Extend the conversation beyond 2FA setup.",
"instructions": "## 🧑‍💼 Role:\nHelp users set up their 2FA preferences.\n\n---\n## ⚙️ Steps to Follow:\n1. Ask the user about their preferred 2FA method (e.g., SMS, Email).\n2. Confirm the setup method with the user.\n3. Guide them through the setup steps.\n4. If the user request is out of scope, pass control to [@agent:2FA Hub](#mention)\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Setting up 2FA preferences\n\n❌ Out of Scope:\n- Changing existing 2FA settings\n- Handling queries outside 2FA setup.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Clearly explain setup options and steps.\n\n🚫 Don'ts:\n- Assume preferences without user confirmation.\n- Extend the conversation beyond 2FA setup.",
"examples": "- **User** : I'd like to set up 2FA for my account.\n - **Agent response**: Sure, can you tell me your preferred method for 2FA? Options include SMS, Email, or an Authenticator App.\n\n- **User** : I want to use SMS for 2FA.\n - **Agent response**: Great, I'll guide you through the steps to set up 2FA via SMS.\n\n- **User** : How about using an Authenticator App?\n - **Agent response**: Sure, let's set up 2FA with an Authenticator App. I'll walk you through the necessary steps.\n\n- **User** : Can you help me set up 2FA through Email?\n - **Agent response**: No problem, I'll explain how to set up 2FA via Email now.\n\n- **User** : I changed my mind, can we start over?\n - **Agent response**: Of course, let's begin again. Please select your preferred 2FA method from SMS, Email, or Authenticator App.",
"model": "gpt-4o",
"toggleAble": true,
@ -350,7 +340,7 @@ Copilot output:
"name": "2FA Change",
"type": "conversation",
"description": "Agent to assist users in changing their 2FA method.",
"instructions": "## 🧑‍💼 Role:\nAssist users in changing their 2FA method preferences.\n\n---\n## ⚙️ Steps to Follow:\n1. Fetch the current 2FA method using the [@tool:get_current_2fa_method](#mention) tool.\n2. Confirm with the user if they want to change the method.\n3. Guide them through the process of changing the method.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Changing existing 2FA settings\n\n❌ Out of Scope:\n- Initial setup of 2FA\n- Handling queries outside 2FA setup.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Ensure the user is aware of the current method before change.\n\n🚫 Don'ts:\n- Change methods without explicit user confirmation.\n- Extend the conversation beyond 2FA change.",
"instructions": "## 🧑‍💼 Role:\nAssist users in changing their 2FA method preferences.\n\n---\n## ⚙️ Steps to Follow:\n1. Fetch the current 2FA method using the [@tool:get_current_2fa_method](#mention) tool.\n2. Confirm with the user if they want to change the method.\n3. Guide them through the process of changing the method.\n4. If the user request is out of scope, pass control to [@agent:2FA Hub](#mention)\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Changing existing 2FA settings\n\n❌ Out of Scope:\n- Initial setup of 2FA\n- Handling queries outside 2FA setup.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Ensure the user is aware of the current method before change.\n\n🚫 Don'ts:\n- Change methods without explicit user confirmation.\n- Extend the conversation beyond 2FA change.",
"examples": "- **User** : I want to change my 2FA method from SMS to Email.\n - **Agent response**: I can help with that. Let me fetch your current 2FA setting first.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : Can I switch to using an Authenticator App instead of Email?\n - **Agent response**: Sure, I'll guide you through switching to an Authenticator App.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : I don't want to use 2FA via phone anymore, can you change it?\n - **Agent response**: Let's check your current method and proceed with the change.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : I'd like to update my 2FA to be more secure, what do you suggest?\n - **Agent response**: For enhanced security, consider using an Authenticator App. Let's fetch your current method and update it.\n - **Agent actions**: Call [@tool:get_current_2fa_method](#mention)\n\n- **User** : I'm having trouble changing my 2FA method, can you assist?\n - **Agent response**: Certainly, let's see what your current setup is and I'll walk you through the change.",
"model": "gpt-4o",
"toggleAble": true,
@ -373,7 +363,7 @@ Copilot output:
"name": "2FA Troubleshooting",
"type": "conversation",
"description": "Agent to troubleshoot issues related to not receiving 2FA codes.",
"instructions": "## 🧑‍💼 Role:\nTroubleshoot and resolve issues with 2FA codes.\n\n---\n## ⚙️ Steps to Follow:\n1. Confirm the contact details for 2FA are correct.\n2. Ask about the issue specifics (e.g., not receiving codes at all, delayed codes).\n3. Provide troubleshooting steps or escalate if unresolved.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Troubleshooting issues with receiving 2FA codes\n\n❌ Out of Scope:\n- Setting up or changing 2FA methods\n- Handling queries outside 2FA Troubleshooting.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Verify contact information for accuracy.\n\n🚫 Don'ts:\n- Extend the conversation beyond 2FA change.",
"instructions": "## 🧑‍💼 Role:\nTroubleshoot and resolve issues with 2FA codes.\n\n---\n## ⚙️ Steps to Follow:\n1. Confirm the contact details for 2FA are correct.\n2. Ask about the issue specifics (e.g., not receiving codes at all, delayed codes).\n3. Provide troubleshooting steps or escalate if unresolved.\n4. If the user request is out of scope, pass control to [@agent:2FA Hub](#mention)\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Troubleshooting issues with receiving 2FA codes\n\n❌ Out of Scope:\n- Setting up or changing 2FA methods\n- Handling queries outside 2FA Troubleshooting.\n- General knowledge queries.\n\n---\n## 📋 Guidelines:\n✔️ Dos:\n- Verify contact information for accuracy.\n\n🚫 Don'ts:\n- Extend the conversation beyond 2FA change.",
"examples": "- **User** : I haven't received my 2FA code.\n - **Agent response**: Let's ensure your contact details are correct. Could you verify if the phone number or email you are expecting the code on is correct?\n\n- **User** : My 2FA codes are always delayed.\n - **Agent response**: I apologize for the inconvenience. Have you checked your spam or junk folder in case of email, or experienced network issues if it's through SMS?\n\n- **User** : The codes aren't coming to my phone.\n - **Agent response**: Let's confirm the number on file for you. Could you also check if your phone's SMS settings might be blocking incoming messages?\n\n- **User** : I'm facing issues with receiving 2FA codes via email.\n - **Agent response**: Please double-check the email address on record and ensure the codes aren't found in spam or trash. I can assist further if needed.\n\n- **User** : My codes are coming to the wrong email address.\n - **Agent response**: I recommend updating your 2FA contact information. Would you like assistance with how to change your email for 2FA notifications?",
"model": "gpt-4o",
"toggleAble": true,
@ -417,6 +407,16 @@ Copilot output:
"response": "<new instructions with relevant changes>"
}
```
## Section 12: State of the Current Multi-Agent System
The design of the multi-agent system is represented by the following JSON schema:
```
{workflow_schema}
```
If the workflow has an 'Example Agent' as the main agent, it means the user is yet to create the main agent. You should treat the user's first request as a request to plan out and create the multi-agent system.
"""
copilot_instructions_edit_agent = """
@ -540,7 +540,6 @@ User: {last_message.content}
updated_msgs = [{"role": "system", "content": sys_prompt}] + [
message.model_dump() for message in messages
]
print(json.dumps(updated_msgs, indent=2))
response = openai_client.chat.completions.create(
model=MODEL_NAME,

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "rowboat"
version = "1.0.6"
version = "2.1.0"
authors = [
{ name = "Your Name", email = "your.email@example.com" },
]

View file

@ -38,7 +38,8 @@ class Client:
workflowId=workflow_id,
testProfileId=test_profile_id
)
response = requests.post(self.base_url, headers=self.headers, data=request.model_dump_json())
json_data = request.model_dump()
response = requests.post(self.base_url, headers=self.headers, json=json_data)
if not response.status_code == 200:
raise ValueError(f"Error: {response.status_code} - {response.text}")
@ -90,7 +91,7 @@ class Client:
) -> Tuple[List[ApiMessage], Optional[Dict[str, Any]]]:
"""Stateless chat method that handles a single conversation turn with multiple tool call rounds"""
current_messages = messages
current_messages = messages[:]
current_state = state
turns = 0

View file

@ -1,30 +1,28 @@
'use server';
import { convertFromAgenticAPIChatMessages } from "../lib/types/agents_api_types";
import { AgenticAPIInitStreamResponse, convertFromAgenticAPIChatMessages } from "../lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "../lib/types/agents_api_types";
import { WorkflowAgent } from "../lib/types/workflow_types";
import { EmbeddingRecord } from "../lib/types/datasource_types";
import { WebpageCrawlResponse } from "../lib/types/tool_types";
import { GetInformationToolResult } from "../lib/types/tool_types";
import { EmbeddingDoc } from "../lib/types/datasource_types";
import { generateObject, generateText, embed } from "ai";
import { dataSourceDocsCollection, dataSourcesCollection, embeddingsCollection, webpagesCollection } from "../lib/mongodb";
import { webpagesCollection } from "../lib/mongodb";
import { z } from 'zod';
import { openai } from "@ai-sdk/openai";
import FirecrawlApp, { ScrapeResponse } from '@mendable/firecrawl-js';
import { embeddingModel } from "../lib/embedding";
import { apiV1 } from "rowboat-shared";
import { Claims, getSession } from "@auth0/nextjs-auth0";
import { callClientToolWebhook, getAgenticApiResponse, mockToolResponse, runRAGToolCall } from "../lib/utils";
import { getAgenticApiResponse, getAgenticResponseStreamId } from "../lib/utils";
import { check_query_limit } from "../lib/rate_limiting";
import { QueryLimitError } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions";
import { qdrantClient } from "../lib/qdrant";
import { ObjectId } from "mongodb";
import { TestProfile } from "../lib/types/testing_types";
import { USE_AUTH } from "../lib/feature_flags";
const crawler = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY || '' });
export async function authCheck(): Promise<Claims> {
if (!USE_AUTH) {
return {
email: 'guestuser@rowboatlabs.com',
email_verified: true,
sub: 'guest_user',
};
}
const { user } = await getSession() || {};
if (!user) {
throw new Error('User not authenticated');
@ -76,17 +74,14 @@ export async function scrapeWebpage(url: string): Promise<z.infer<typeof Webpage
};
}
export async function getAssistantResponse(
projectId: string,
request: z.infer<typeof AgenticAPIChatRequest>,
): Promise<{
export async function getAssistantResponse(request: z.infer<typeof AgenticAPIChatRequest>): Promise<{
messages: z.infer<typeof apiV1.ChatMessage>[],
state: unknown,
rawRequest: unknown,
rawResponse: unknown,
}> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
await projectAuthCheck(request.projectId);
if (!await check_query_limit(request.projectId)) {
throw new QueryLimitError();
}
@ -99,95 +94,12 @@ export async function getAssistantResponse(
};
}
export async function suggestToolResponse(toolId: string, projectId: string, messages: z.infer<typeof apiV1.ChatMessage>[], mockInstructions: string): Promise<string> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
export async function getAssistantResponseStreamId(request: z.infer<typeof AgenticAPIChatRequest>): Promise<z.infer<typeof AgenticAPIInitStreamResponse>> {
await projectAuthCheck(request.projectId);
if (!await check_query_limit(request.projectId)) {
throw new QueryLimitError();
}
return await mockToolResponse(toolId, messages, mockInstructions);
}
export async function getInformationTool(
projectId: string,
query: string,
sourceIds: string[],
returnType: z.infer<typeof WorkflowAgent>['ragReturnType'],
k: number,
): Promise<z.infer<typeof GetInformationToolResult>> {
await projectAuthCheck(projectId);
return await runRAGToolCall(projectId, query, sourceIds, returnType, k);
}
export async function simulateUserResponse(
projectId: string,
messages: z.infer<typeof apiV1.ChatMessage>[],
scenario: string,
): Promise<string> {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
const scenarioPrompt = `
# Your Specific Task:
## Context:
Here is a scenario:
Scenario:
<START_SCENARIO>
{{scenario}}
<END_SCENARIO>
## Task definition:
Pretend to be a user reaching out to customer support. Chat with the
customer support assistant, assuming your issue is based on this scenario.
Ask follow-up questions and make it real-world like. Don't do dummy
conversations. Your conversation should be a maximum of 5 user turns.
As output, simply provide your (user) turn of conversation.
After you are done with the chat, keep replying with a single word EXIT
in all capitals.
`;
await projectAuthCheck(projectId);
// flip message assistant / user message
// roles from chat messages
// use only text response messages
const flippedMessages: { role: 'user' | 'assistant', content: string }[] = messages
.filter(m => m.role == 'assistant' || m.role == 'user')
.map(m => ({
role: m.role == 'assistant' ? 'user' : 'assistant',
content: m.content || '',
}));
// simulate user call
let prompt;
prompt = scenarioPrompt
.replace('{{scenario}}', scenario);
const { text } = await generateText({
model: openai("gpt-4o"),
system: prompt || '',
messages: flippedMessages,
});
return text.replace(/\. EXIT$/, '.');
}
export async function executeClientTool(
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number],
messages: z.infer<typeof apiV1.ChatMessage>[],
projectId: string,
): Promise<unknown> {
await projectAuthCheck(projectId);
const result = await callClientToolWebhook(toolCall, messages, projectId);
return result;
const response = await getAgenticResponseStreamId(request);
return response;
}

View file

@ -36,7 +36,7 @@ export async function getCopilotResponse(
current_workflow_config: JSON.stringify(convertToCopilotWorkflow(current_workflow_config)),
context: context ? convertToCopilotApiChatContext(context) : null,
};
console.log(`copilot request`, JSON.stringify(request, null, 2));
console.log(`sending copilot request`, JSON.stringify(request));
// call copilot api
const response = await fetch(process.env.COPILOT_API_URL + '/chat', {
@ -54,7 +54,7 @@ export async function getCopilotResponse(
// parse and return response
const json: z.infer<typeof CopilotAPIResponse> = await response.json();
console.log(`copilot response`, JSON.stringify(json, null, 2));
console.log(`received copilot response`, JSON.stringify(json));
if ('error' in json) {
throw new Error(`Failed to call copilot api: ${json.error}`);
}
@ -182,7 +182,7 @@ export async function getCopilotAgentInstructions(
agentName: agentName,
}
};
console.log(`copilot request`, JSON.stringify(request, null, 2));
console.log(`sending copilot agent instructions request`, JSON.stringify(request));
// call copilot api
const response = await fetch(process.env.COPILOT_API_URL + '/edit_agent_instructions', {
@ -201,7 +201,7 @@ export async function getCopilotAgentInstructions(
// parse and return response
const json = await response.json();
console.log(`copilot response`, JSON.stringify(json, null, 2));
console.log(`received copilot agent instructions response`, JSON.stringify(json));
let copilotResponse: z.infer<typeof CopilotAPIResponse>;
let agent_instructions: string;
try {

View file

@ -0,0 +1,83 @@
"use server";
import { z } from "zod";
import { WorkflowTool } from "../lib/types/workflow_types";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { projectAuthCheck } from "./project_actions";
import { projectsCollection } from "../lib/mongodb";
import { Project } from "../lib/types/project_types";
import { MCPServer } from "../lib/types/types";
export async function fetchMcpTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
const mcpServers = project?.mcpServers ?? [];
const tools: z.infer<typeof WorkflowTool>[] = [];
for (const mcpServer of mcpServers) {
try {
const transport = new SSEClientTransport(new URL(mcpServer.url));
const client = new Client(
{
name: "rowboat-client",
version: "1.0.0"
},
{
capabilities: {
prompts: {},
resources: {},
tools: {}
}
}
);
await client.connect(transport);
// List tools
const result = await client.listTools();
await client.close();
tools.push(...result.tools.map((mcpTool) => {
let props = mcpTool.inputSchema.properties as Record<string, { description: string; type: string }>;
const tool: z.infer<typeof WorkflowTool> = {
name: mcpTool.name,
description: mcpTool.description ?? "",
parameters: {
type: "object",
properties: props ?? {},
required: mcpTool.inputSchema.required as string[] ?? [],
},
isMcp: true,
mcpServerName: mcpServer.name,
}
return tool;
}));
} catch (e) {
console.error(`Error fetching MCP tools from ${mcpServer.name}: ${e}`);
}
}
return tools;
}
export async function updateMcpServers(projectId: string, mcpServers: z.infer<typeof Project>['mcpServers']): Promise<void> {
await projectAuthCheck(projectId);
await projectsCollection.updateOne({
_id: projectId,
}, { $set: { mcpServers } });
}
export async function listMcpServers(projectId: string): Promise<z.infer<typeof MCPServer>[]> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
return project?.mcpServers ?? [];
}

View file

@ -10,8 +10,12 @@ import { authCheck } from "./actions";
import { WithStringId } from "../lib/types/types";
import { ApiKey } from "../lib/types/project_types";
import { Project } from "../lib/types/project_types";
import { USE_AUTH } from "../lib/feature_flags";
export async function projectAuthCheck(projectId: string) {
if (!USE_AUTH) {
return;
}
const user = await authCheck();
const membership = await projectMembersCollection.findOne({
projectId,
@ -21,11 +25,9 @@ export async function projectAuthCheck(projectId: string) {
throw new Error('User not a member of project');
}
}
export async function createProject(formData: FormData) {
const user = await authCheck();
// ensure that projects created by this user is less than
// configured limit
async function createBaseProject(name: string, user: any) {
// Check project limits
const projectsLimit = Number(process.env.MAX_PROJECTS_PER_USER) || 0;
if (projectsLimit > 0) {
const count = await projectsCollection.countDocuments({
@ -36,16 +38,14 @@ export async function createProject(formData: FormData) {
}
}
const name = formData.get('name') as string;
const templateKey = formData.get('template') as string;
const projectId = crypto.randomUUID();
const chatClientId = crypto.randomBytes(16).toString('base64url');
const secret = crypto.randomBytes(32).toString('hex');
// create project
// Create project
await projectsCollection.insertOne({
_id: projectId,
name: name,
name,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
createdByUserId: user.sub,
@ -55,7 +55,28 @@ export async function createProject(formData: FormData) {
testRunCounter: 0,
});
// add first workflow version
// Add user to project
await projectMembersCollection.insertOne({
userId: user.sub,
projectId: projectId,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
});
// Add first api key
await createApiKey(projectId);
return projectId;
}
export async function createProject(formData: FormData) {
const user = await authCheck();
const name = formData.get('name') as string;
const templateKey = formData.get('template') as string;
const projectId = await createBaseProject(name, user);
// Add first workflow version with specified template
const { agents, prompts, tools, startAgent } = templates[templateKey];
await agentWorkflowsCollection.insertOne({
projectId,
@ -68,17 +89,6 @@ export async function createProject(formData: FormData) {
name: `Version 1`,
});
// add user to project
await projectMembersCollection.insertOne({
userId: user.sub,
projectId: projectId,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
});
// add first api key
await createApiKey(projectId);
redirect(`/projects/${projectId}/workflow`);
}
@ -212,3 +222,25 @@ export async function deleteProject(projectId: string) {
redirect('/projects');
}
export async function createProjectFromPrompt(formData: FormData) {
const user = await authCheck();
const name = formData.get('name') as string;
const projectId = await createBaseProject(name, user);
// Add first workflow version with default template
const { agents, prompts, tools, startAgent } = templates['default'];
await agentWorkflowsCollection.insertOne({
projectId,
agents,
prompts,
tools,
startAgent,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
name: `Version 1`,
});
return { id: projectId };
}

View file

@ -170,6 +170,7 @@ export async function createSimulation(
projectId: string,
data: {
name: string;
description?: string;
scenarioId: string;
profileId: string | null;
passCriteria: string;
@ -195,6 +196,7 @@ export async function updateSimulation(
simulationId: string,
updates: {
name?: string;
description?: string;
scenarioId?: string;
profileId?: string | null;
passCriteria?: string;
@ -268,7 +270,6 @@ export async function deleteProfile(projectId: string, profileId: string): Promi
await testProfilesCollection.deleteOne({
_id: new ObjectId(profileId),
projectId,
default: false,
});
}
@ -449,6 +450,15 @@ export async function updateRun(
);
}
export async function cancelRun(projectId: string, runId: string): Promise<void> {
await projectAuthCheck(projectId);
await testRunsCollection.updateOne(
{ _id: new ObjectId(runId), projectId },
{ $set: { status: 'cancelled' } }
);
}
export async function listResults(
projectId: string,
runId: string,
@ -510,6 +520,7 @@ export async function createResult(
simulationId: string;
result: 'pass' | 'fail';
details: string;
transcript: string;
}
): Promise<WithStringId<z.infer<typeof TestResult>>> {
await projectAuthCheck(projectId);
@ -544,4 +555,56 @@ export async function updateResult(
$set: updates,
}
);
}
export async function getSimulationResult(
projectId: string,
runId: string,
simulationId: string
): Promise<WithStringId<z.infer<typeof TestResult>> | null> {
await projectAuthCheck(projectId);
const result = await testResultsCollection.findOne({
projectId,
runId,
simulationId
});
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
_id: _id.toString(),
};
}
export async function listRunSimulations(
projectId: string,
simulationIds: string[]
): Promise<WithStringId<z.infer<typeof TestSimulation>>[]> {
await projectAuthCheck(projectId);
const simulations = await testSimulationsCollection
.find({
_id: { $in: simulationIds.map(id => new ObjectId(id)) },
projectId
})
.toArray();
// Fetch associated scenario and profile names
const enrichedSimulations = await Promise.all(simulations.map(async (simulation) => {
const scenario = simulation.scenarioId ? await testScenariosCollection.findOne({ _id: new ObjectId(simulation.scenarioId) }) : null;
const profile = simulation.profileId ? await testProfilesCollection.findOne({ _id: new ObjectId(simulation.profileId) }) : null;
return {
...simulation,
_id: simulation._id.toString(),
scenarioName: scenario?.name || 'Unknown',
profileName: profile?.name || 'None',
};
}));
return enrichedSimulations;
}

View file

@ -0,0 +1,280 @@
'use server';
import { TwilioConfigParams, TwilioConfigResponse, TwilioConfig, InboundConfigResponse } from "../lib/types/voice_types";
import { twilioConfigsCollection } from "../lib/mongodb";
import { ObjectId } from "mongodb";
import twilio from 'twilio';
import { Twilio } from 'twilio';
// Helper function to serialize MongoDB documents
function serializeConfig(config: any) {
return {
...config,
_id: config._id.toString(),
createdAt: config.createdAt.toISOString(),
};
}
// Real implementation for configuring Twilio number
export async function configureTwilioNumber(params: TwilioConfigParams): Promise<TwilioConfigResponse> {
console.log('configureTwilioNumber - Received params:', params);
try {
const client = twilio(params.account_sid, params.auth_token);
try {
// List all phone numbers and find the matching one
const numbers = await client.incomingPhoneNumbers.list();
console.log('Twilio numbers for this account:', numbers);
const phoneExists = numbers.some(
number => number.phoneNumber === params.phone_number
);
if (!phoneExists) {
throw new Error('Phone number not found in this account');
}
} catch (error) {
console.error('Error verifying phone number:', error);
throw new Error(
error instanceof Error
? error.message
: 'Invalid phone number or phone number does not belong to this account'
);
}
// Save to MongoDB after successful validation
const savedConfig = await saveTwilioConfig(params);
console.log('configureTwilioNumber - Saved config result:', savedConfig);
return { success: true };
} catch (error) {
console.error('Error in configureTwilioNumber:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to configure Twilio number'
};
}
}
// Save Twilio configuration to MongoDB
export async function saveTwilioConfig(params: TwilioConfigParams): Promise<TwilioConfig> {
console.log('saveTwilioConfig - Incoming params:', {
...params,
label: {
value: params.label,
type: typeof params.label,
length: params.label?.length,
isEmpty: params.label === ''
}
});
// First, list all configs to see what's in the database
const allConfigs = await twilioConfigsCollection
.find({ status: 'active' as const })
.toArray();
console.log('saveTwilioConfig - All active configs in DB:', allConfigs);
// Find existing config for this project
const existingConfig = await twilioConfigsCollection.findOne({
project_id: params.project_id,
status: 'active' as const
});
console.log('saveTwilioConfig - Existing config search by project:', {
searchCriteria: {
project_id: params.project_id,
status: 'active'
},
found: existingConfig
});
const configToSave = {
phone_number: params.phone_number,
account_sid: params.account_sid,
auth_token: params.auth_token,
label: params.label || '', // Use empty string instead of undefined
project_id: params.project_id,
workflow_id: params.workflow_id,
createdAt: existingConfig?.createdAt || new Date(),
status: 'active' as const
};
console.log('saveTwilioConfig - Config to save:', configToSave);
try {
// Configure inbound calls first
await configureInboundCall(
params.phone_number,
params.account_sid,
params.auth_token,
params.workflow_id
);
// Then save/update the config in database
if (existingConfig) {
console.log('saveTwilioConfig - Updating existing config:', existingConfig._id);
const result = await twilioConfigsCollection.updateOne(
{ _id: existingConfig._id },
{ $set: configToSave }
);
console.log('saveTwilioConfig - Update result:', result);
} else {
console.log('saveTwilioConfig - No existing config found, creating new');
const result = await twilioConfigsCollection.insertOne(configToSave);
console.log('saveTwilioConfig - Insert result:', result);
}
const savedConfig = await twilioConfigsCollection.findOne({
project_id: params.project_id,
status: 'active'
});
if (!savedConfig) {
throw new Error('Failed to save Twilio configuration');
}
console.log('configureTwilioNumber - Saved config result:', savedConfig);
return savedConfig;
} catch (error) {
console.error('Error saving Twilio config:', error);
throw error;
}
}
// Get Twilio configuration for a workflow
export async function getTwilioConfigs(projectId: string) {
console.log('getTwilioConfigs - Fetching for projectId:', projectId);
const configs = await twilioConfigsCollection
.find({
project_id: projectId,
status: 'active' as const
})
.sort({ createdAt: -1 })
.limit(1)
.toArray();
console.log('getTwilioConfigs - Raw configs:', configs);
const serializedConfigs = configs.map(serializeConfig);
console.log('getTwilioConfigs - Serialized configs:', serializedConfigs);
return serializedConfigs;
}
// Delete a Twilio configuration (soft delete)
export async function deleteTwilioConfig(projectId: string, configId: string) {
console.log('deleteTwilioConfig - Deleting config:', { projectId, configId });
const result = await twilioConfigsCollection.updateOne(
{
_id: new ObjectId(configId),
project_id: projectId
},
{
$set: { status: 'deleted' as const }
}
);
console.log('deleteTwilioConfig - Delete result:', result);
return result;
}
// Mock implementation for testing/development
export async function mockConfigureTwilioNumber(params: TwilioConfigParams): Promise<TwilioConfigResponse> {
await new Promise(resolve => setTimeout(resolve, 1000));
await saveTwilioConfig(params);
return { success: true };
}
export async function configureInboundCall(
phone_number: string,
account_sid: string,
auth_token: string,
workflow_id: string
): Promise<InboundConfigResponse> {
try {
// Normalize phone number format
if (!phone_number.startsWith('+')) {
phone_number = '+' + phone_number;
}
console.log('Configuring inbound call for:', {
phone_number,
workflow_id
});
// Initialize Twilio client
const client = new Twilio(account_sid, auth_token);
// Find the phone number in Twilio account
const incomingPhoneNumbers = await client.incomingPhoneNumbers.list({ phoneNumber: phone_number });
console.log('Found Twilio numbers:', incomingPhoneNumbers.map(n => ({
phoneNumber: n.phoneNumber,
currentVoiceUrl: n.voiceUrl,
currentStatusCallback: n.statusCallback,
sid: n.sid
})));
if (!incomingPhoneNumbers.length) {
throw new Error(`Phone number ${phone_number} not found in Twilio account`);
}
const phoneSid = incomingPhoneNumbers[0].sid;
const currentVoiceUrl = incomingPhoneNumbers[0].voiceUrl;
const wasPreviouslyConfigured = Boolean(currentVoiceUrl);
// Get base URL from environment - MUST be a public URL
const baseUrl = process.env.VOICE_API_URL;
if (!baseUrl) {
throw new Error('Voice service URL not configured. Please set VOICE_API_URL environment variable.');
}
// Validate URL is not localhost
if (baseUrl.includes('localhost')) {
throw new Error('Voice service must use a public URL, not localhost.');
}
const inboundUrl = `${baseUrl}/inbound?workflow_id=${workflow_id}`;
console.log('Setting up webhooks:', {
voiceUrl: inboundUrl,
statusCallback: `${baseUrl}/call-status`,
currentConfig: {
voiceUrl: currentVoiceUrl,
statusCallback: incomingPhoneNumbers[0].statusCallback
}
});
// Update the phone number configuration
const updatedNumber = await client.incomingPhoneNumbers(phoneSid).update({
voiceUrl: inboundUrl,
voiceMethod: 'POST',
statusCallback: `${baseUrl}/call-status`,
statusCallbackMethod: 'POST'
});
console.log('Webhook configuration complete:', {
phoneNumber: updatedNumber.phoneNumber,
newVoiceUrl: updatedNumber.voiceUrl,
newStatusCallback: updatedNumber.statusCallback,
success: updatedNumber.voiceUrl === inboundUrl
});
return {
status: wasPreviouslyConfigured ? 'reconfigured' : 'configured',
phone_number: phone_number,
workflow_id: workflow_id,
previous_webhook: wasPreviouslyConfigured ? currentVoiceUrl : undefined
};
} catch (err: unknown) {
console.error('Error configuring inbound call:', err);
// Type guard for error with message property
if (err instanceof Error) {
if (err.message.includes('localhost')) {
throw new Error('Voice service needs to be accessible from the internet. Please check your configuration.');
}
// Type guard for Twilio error
if ('code' in err && err.code === 21402) {
throw new Error('Invalid voice service URL. Please make sure it\'s a public, secure URL.');
}
}
// If we can't determine the specific error, throw a generic one
throw new Error('Failed to configure phone number. Please check your settings and try again.');
}
}

View file

@ -4,10 +4,9 @@ import { z } from "zod";
import { ObjectId } from "mongodb";
import { authCheck } from "../../utils";
import { ApiRequest, ApiResponse } from "../../../../lib/types/types";
import { AgenticAPIChatRequest, AgenticAPIChatMessage, convertFromAgenticApiToApiMessages, convertFromApiToAgenticApiMessages, convertWorkflowToAgenticAPI } from "../../../../lib/types/agents_api_types";
import { getAgenticApiResponse, callClientToolWebhook, runRAGToolCall, mockToolResponse } from "../../../../lib/utils";
import { AgenticAPIChatRequest, convertFromAgenticApiToApiMessages, convertFromApiToAgenticApiMessages, convertWorkflowToAgenticAPI } from "../../../../lib/types/agents_api_types";
import { getAgenticApiResponse } from "../../../../lib/utils";
import { check_query_limit } from "../../../../lib/rate_limiting";
import { apiV1 } from "rowboat-shared";
import { PrefixLogger } from "../../../../lib/utils";
import { TestProfile } from "@/app/lib/types/testing_types";
@ -70,7 +69,7 @@ export async function POST(
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
return Response.json({ error: "Workflow not found" }, { status: 404 });
}
// if test profile is provided in the request, use it
let testProfile: z.infer<typeof TestProfile> | null = null;
if (result.data.testProfileId) {
@ -84,134 +83,30 @@ export async function POST(
}
}
// if profile has a context available, overwrite the system message in the request (if there is one)
let currentMessages = reqMessages;
if (testProfile?.context) {
// if there is a system message, overwrite it
const systemMessageIndex = reqMessages.findIndex(m => m.role === "system");
if (systemMessageIndex !== -1) {
currentMessages[systemMessageIndex].content = testProfile.context;
} else {
// if there is no system message, add one
currentMessages.unshift({ role: "system", content: testProfile.context });
}
}
const MAX_TURNS = result.data.maxTurns ?? 3;
let currentState: unknown = reqState ?? { last_agent_name: workflow.agents[0].name };
let turns = 0;
let hasToolCalls = false;
do {
hasToolCalls = false;
// get assistant response
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
const request: z.infer<typeof AgenticAPIChatRequest> = {
messages: convertFromApiToAgenticApiMessages(currentMessages),
state: currentState,
agents,
tools,
prompts,
startAgent,
};
// get assistant response
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
const request: z.infer<typeof AgenticAPIChatRequest> = {
projectId,
messages: convertFromApiToAgenticApiMessages(reqMessages),
state: currentState,
agents,
tools,
prompts,
startAgent,
testProfile: testProfile ?? undefined,
mcpServers: project.mcpServers ?? undefined,
toolWebhookUrl: project.webhookUrl ?? undefined,
};
console.log(`turn ${turns}: sending agentic request from /chat api`, JSON.stringify(request, null, 2));
logger.log(`Processing turn ${turns} for conversation`);
const { messages: agenticMessages, state } = await getAgenticApiResponse(request);
const newMessages = convertFromAgenticApiToApiMessages(agenticMessages);
currentState = state;
// if tool calls are to be skipped, return immediately
if (result.data.skipToolCalls) {
logger.log('Skipping tool calls as requested');
const responseBody: z.infer<typeof ApiResponse> = {
messages: newMessages,
state: currentState,
};
return Response.json(responseBody);
}
// get last message to check for tool calls
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage?.role === "assistant" &&
'tool_calls' in lastMessage &&
lastMessage.tool_calls?.length > 0) {
hasToolCalls = true;
const toolCallResultMessages: z.infer<typeof apiV1.ToolMessage>[] = [];
// Process tool calls
for (const toolCall of lastMessage.tool_calls) {
let result: unknown;
if (toolCall.function.name === "getArticleInfo") {
logger.log(`Running RAG tool call for agent ${lastMessage.agenticSender}`);
// find the source ids attached to this agent in the workflow
const agent = workflow.agents.find(a => a.name === lastMessage.agenticSender);
if (!agent) {
return Response.json({ error: "Agent not found" }, { status: 404 });
}
const sourceIds = agent.ragDataSources;
if (!sourceIds) {
return Response.json({ error: "Agent has no data sources" }, { status: 404 });
}
try {
result = await runRAGToolCall(projectId, toolCall.function.arguments, sourceIds, agent.ragReturnType, agent.ragK);
logger.log(`RAG tool call completed for agent ${lastMessage.agenticSender}`);
} catch (e) {
logger.log(`Error running RAG tool call: ${e}`);
return Response.json({ error: "Error running RAG tool call" }, { status: 500 });
}
} else {
logger.log(`Running client tool webhook for tool ${toolCall.function.name}`);
try {
// if tool is supposed to be mocked, mock it
const workflowTool = workflow.tools.find(t => t.name === toolCall.function.name);
if (testProfile?.mockTools || workflowTool?.mockTool) {
logger.log(`Mocking tool call ${toolCall.function.name}`);
result = await mockToolResponse(toolCall.id, currentMessages, testProfile?.mockPrompt || workflowTool?.mockInstructions || '');
} else {
// else run the tool call by calling the client tool webhook
logger.log(`Running client tool webhook for tool ${toolCall.function.name}`);
result = await callClientToolWebhook(
toolCall,
currentMessages,
projectId,
);
}
} catch (e) {
logger.log(`Error in tool call ${toolCall.function.name}: ${e}`);
return Response.json({ error: `Error in tool call ${toolCall.function.name}` }, { status: 500 });
}
logger.log(`Tool call ${toolCall.function.name} completed`);
}
toolCallResultMessages.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify(result),
tool_name: toolCall.function.name,
});
}
// Add new messages to the conversation
currentMessages = [...currentMessages, ...newMessages, ...toolCallResultMessages];
} else {
// No tool calls, just add the new messages
currentMessages = [...currentMessages, ...newMessages];
}
turns++;
if (turns >= MAX_TURNS && hasToolCalls) {
logger.log(`Max turns (${MAX_TURNS}) reached for conversation`);
return Response.json({ error: "Max turns reached" }, { status: 429 });
}
} while (hasToolCalls);
const { messages: agenticMessages, state } = await getAgenticApiResponse(request);
const newMessages = convertFromAgenticApiToApiMessages(agenticMessages);
const newState = state;
const responseBody: z.infer<typeof ApiResponse> = {
messages: currentMessages.slice(reqMessages.length),
state: currentState,
messages: newMessages,
state: newState,
};
return Response.json(responseBody);
});

View file

@ -0,0 +1,45 @@
export async function GET(request: Request, { params }: { params: { streamId: string } }) {
// Replace with your actual upstream SSE endpoint.
const upstreamUrl = `${process.env.AGENTS_API_URL}/chat_stream/${params.streamId}`;
console.log('upstreamUrl', upstreamUrl);
// Fetch the upstream SSE stream.
const upstreamResponse = await fetch(upstreamUrl, {
headers: {
'Authorization': `Bearer ${process.env.AGENTS_API_KEY || 'test'}`,
},
cache: 'no-store',
});
// If the upstream request fails, return a 502 Bad Gateway.
if (!upstreamResponse.ok || !upstreamResponse.body) {
return new Response("Error connecting to upstream SSE stream", { status: 502 });
}
const reader = upstreamResponse.body.getReader();
const stream = new ReadableStream({
async start(controller) {
try {
// Read from the upstream stream continuously.
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Immediately enqueue each received chunk.
controller.enqueue(value);
}
controller.close();
} catch (error) {
controller.error(error);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}

View file

@ -8,13 +8,10 @@ import { convertFromAgenticAPIChatMessages } from "../../../../../../lib/types/a
import { convertToAgenticAPIChatMessages } from "../../../../../../lib/types/agents_api_types";
import { convertWorkflowToAgenticAPI } from "../../../../../../lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "../../../../../../lib/types/agents_api_types";
import { callClientToolWebhook, getAgenticApiResponse, runRAGToolCall, mockToolResponse } from "../../../../../../lib/utils";
import { getAgenticApiResponse } from "../../../../../../lib/utils";
import { check_query_limit } from "../../../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../../../lib/utils";
// Add max turns constant at the top with other constants
const MAX_TURNS = 3;
// get next turn / agent response
export async function POST(
req: NextRequest,
@ -23,7 +20,7 @@ export async function POST(
return await authCheck(req, async (session) => {
const { chatId } = await params;
const logger = new PrefixLogger(`widget-chat:${chatId}`);
logger.log(`Processing turn request for chat ${chatId}`);
// check query limit
@ -95,101 +92,33 @@ export async function POST(
// get assistant response
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
const unsavedMessages: z.infer<typeof apiV1.ChatMessage>[] = [userMessage];
let resolvingToolCalls = true;
let state: unknown = chat.agenticState ?? {last_agent_name: startAgent};
let turns = 0; // Add turns counter
let state: unknown = chat.agenticState ?? { last_agent_name: startAgent };
while (resolvingToolCalls) {
if (turns >= MAX_TURNS) {
logger.log(`Max turns (${MAX_TURNS}) reached for chat ${chatId}`);
throw new Error("Max turns reached");
}
turns++;
const request: z.infer<typeof AgenticAPIChatRequest> = {
messages: convertToAgenticAPIChatMessages([systemMessage, ...messages, ...unsavedMessages]),
state,
agents,
tools,
prompts,
startAgent,
};
logger.log(`Turn ${turns}: sending agentic request`);
const response = await getAgenticApiResponse(request);
state = response.state;
if (response.messages.length === 0) {
throw new Error("No messages returned from assistant");
}
const convertedMessages = convertFromAgenticAPIChatMessages(response.messages);
unsavedMessages.push(...convertedMessages.map(m => ({
...m,
version: 'v1' as const,
chatId,
createdAt: new Date().toISOString(),
})));
// if the last messages is tool call, execute them
const lastMessage = convertedMessages[convertedMessages.length - 1];
if (lastMessage.role === 'assistant' && 'tool_calls' in lastMessage) {
logger.log(`Processing ${lastMessage.tool_calls.length} tool calls`);
const toolCallResults = await Promise.all(lastMessage.tool_calls.map(async toolCall => {
logger.log(`Executing tool call: ${toolCall.function.name}`);
try {
if (toolCall.function.name === "getArticleInfo") {
logger.log(`Processing RAG tool call for agent ${lastMessage.agenticSender}`);
const agent = workflow.agents.find(a => a.name === lastMessage.agenticSender);
if (!agent || !agent.ragDataSources) {
throw new Error("Agent not found or has no data sources");
}
return await runRAGToolCall(
session.projectId,
toolCall.function.arguments,
agent.ragDataSources,
agent.ragReturnType,
agent.ragK
);
}
const workflowTool = workflow.tools.find(t => t.name === toolCall.function.name);
if (workflowTool?.mockTool) {
logger.log(`Using mock response for tool: ${toolCall.function.name}`);
return await mockToolResponse(
toolCall.id,
[...messages, ...unsavedMessages],
workflowTool.mockInstructions || ''
);
}
logger.log(`Calling webhook for tool: ${toolCall.function.name}`);
return await callClientToolWebhook(
toolCall,
[...messages, ...unsavedMessages],
session.projectId,
);
} catch (error) {
logger.log(`Error executing tool call ${toolCall.id}: ${error}`);
return { error: "Tool execution failed" };
}
}));
unsavedMessages.push(...toolCallResults.map((result, index) => ({
version: 'v1' as const,
chatId,
createdAt: new Date().toISOString(),
role: 'tool' as const,
tool_call_id: lastMessage.tool_calls[index].id,
tool_name: lastMessage.tool_calls[index].function.name,
content: JSON.stringify(result),
})));
} else {
// ensure that the last message is from an assistant
// and is of an external type
if (lastMessage.role !== 'assistant' || lastMessage.agenticResponseType !== 'external') {
throw new Error("Last message is not from an assistant and is not of an external type");
}
resolvingToolCalls = false;
break;
}
const request: z.infer<typeof AgenticAPIChatRequest> = {
projectId: session.projectId,
messages: convertToAgenticAPIChatMessages([systemMessage, ...messages, ...unsavedMessages]),
state,
agents,
tools,
prompts,
startAgent,
mcpServers: projectSettings.mcpServers ?? undefined,
toolWebhookUrl: projectSettings.webhookUrl ?? undefined,
testProfile: undefined,
};
logger.log(`Sending agentic request`);
const response = await getAgenticApiResponse(request);
state = response.state;
if (response.messages.length === 0) {
throw new Error("No messages returned from assistant");
}
const convertedMessages = convertFromAgenticAPIChatMessages(response.messages);
unsavedMessages.push(...convertedMessages.map(m => ({
...m,
version: 'v1' as const,
chatId,
createdAt: new Date().toISOString(),
})));
logger.log(`Saving ${unsavedMessages.length} new messages and updating chat state`);
await chatMessagesCollection.insertMany(unsavedMessages);

View file

@ -1,10 +1,10 @@
import { FileIcon, FilesIcon, GlobeIcon } from "lucide-react";
import { FileIcon, FilesIcon, FileTextIcon, GlobeIcon } from "lucide-react";
export function DataSourceIcon({
type = undefined,
size = "sm",
}: {
type?: "crawl" | "urls" | "files" | undefined;
type?: "crawl" | "urls" | "files" | "text" | undefined;
size?: "sm" | "md";
}) {
const sizeClass = size === "sm" ? "w-4 h-4" : "w-6 h-6";
@ -13,5 +13,6 @@ export function DataSourceIcon({
{type == "crawl" && <GlobeIcon className={sizeClass} />}
{type == "urls" && <GlobeIcon className={sizeClass} />}
{type == "files" && <FilesIcon className={sizeClass} />}
{type == "text" && <FileTextIcon className={sizeClass} />}
</>;
}

View file

@ -0,0 +1,204 @@
import { Button, Input, Textarea } from "@heroui/react";
import { useEffect, useRef, useState } from "react";
import { useClickAway } from "../../../hooks/use-click-away";
import MarkdownContent from "./markdown-content";
import clsx from "clsx";
import { Label } from "./label";
import { SparklesIcon } from "lucide-react";
interface EditableFieldProps {
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
markdown?: boolean;
multiline?: boolean;
locked?: boolean;
className?: string;
validate?: (value: string) => { valid: boolean; errorMessage?: string };
light?: boolean;
error?: string | null;
inline?: boolean;
showGenerateButton?: {
show: boolean;
setShow: (show: boolean) => void;
};
disabled?: boolean;
type?: string;
}
export function EditableField({
value,
onChange,
label,
placeholder = "Click to edit...",
markdown = false,
multiline = false,
locked = false,
className = "flex flex-col gap-1 w-full",
validate,
light = false,
error,
inline = false,
showGenerateButton,
disabled = false,
type = "text",
}: EditableFieldProps) {
const [isEditing, setIsEditing] = useState(false);
const [localValue, setLocalValue] = useState(value);
const ref = useRef<HTMLDivElement>(null);
const validationResult = validate?.(localValue);
const isValid = !validate || validationResult?.valid;
useEffect(() => {
setLocalValue(value);
}, [value]);
useClickAway(ref, () => {
if (isEditing) {
if (isValid && localValue !== value) {
onChange(localValue);
} else {
setLocalValue(value);
}
setIsEditing(false);
}
});
const onValueChange = (newValue: string) => {
setLocalValue(newValue);
onChange(newValue); // Always save immediately
};
const commonProps = {
autoFocus: true,
value: localValue,
onValueChange: onValueChange,
variant: "bordered" as const,
labelPlacement: "outside" as const,
placeholder: markdown ? '' : placeholder,
classNames: {
input: "rounded-md",
inputWrapper: "rounded-md border-medium"
},
radius: "md" as const,
isInvalid: !isValid,
errorMessage: validationResult?.errorMessage,
onKeyDown: (e: React.KeyboardEvent) => {
if (!multiline && e.key === "Enter") {
e.preventDefault();
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
}
if (e.key === "Escape") {
setLocalValue(value);
setIsEditing(false);
}
},
};
if (isEditing) {
return (
<div ref={ref} className={clsx("flex flex-col gap-1 w-full", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
<div className="flex gap-2 items-center">
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
</div>
</div>
)}
{multiline && <Textarea
{...commonProps}
minRows={3}
maxRows={20}
className="w-full"
classNames={{
...commonProps.classNames,
input: "rounded-md py-2",
inputWrapper: "rounded-md border-medium py-1"
}}
/>}
{!multiline && <Input
{...commonProps}
type={type}
className="w-full"
classNames={{
...commonProps.classNames,
input: "rounded-md py-2",
inputWrapper: "rounded-md border-medium py-1"
}}
/>}
</div>
);
}
return (
<div ref={ref} className={clsx("cursor-text", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
</div>
)}
<div
className={clsx(
{
"border border-gray-300 dark:border-gray-600 rounded px-3 py-3": !inline,
"bg-transparent focus:outline-none focus:ring-0 border-0 rounded-none text-gray-900 dark:text-gray-100": inline,
}
)}
style={inline ? {
border: 'none',
borderRadius: '0',
padding: '0'
} : undefined}
onClick={() => !locked && setIsEditing(true)}
>
{value ? (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto">
<MarkdownContent content={value} />
</div>}
{!markdown && <div className={`${multiline ? 'whitespace-pre-wrap max-h-[420px] overflow-y-auto' : 'flex items-center'}`}>
{value}
</div>}
</>
) : (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
<MarkdownContent content={placeholder} />
</div>}
{!markdown && <span className="text-gray-400">{placeholder}</span>}
</>
)}
{error && (
<div className="text-xs text-red-500 mt-1">
{error}
</div>
)}
</div>
</div>
);
}

View file

@ -1,36 +1,33 @@
import clsx from "clsx";
import { ActionButton } from "./structured-panel";
export function SectionHeader({ title, onAdd }: { title: string; onAdd: () => void }) {
export function SectionHeader({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between px-2 py-1 mt-4 first:mt-0 border-b border-gray-200 dark:border-gray-600">
<div className="text-xs font-semibold text-gray-400 dark:text-gray-300 uppercase">{title}</div>
<ActionButton
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
</svg>}
onClick={onAdd}
>
Add
</ActionButton>
<div className="flex items-center gap-3">
{children}
</div>
</div>
);
}
export function ListItem({
name,
isSelected,
onClick,
export function ListItem({
name,
isSelected,
onClick,
disabled,
rightElement,
selectedRef
}: {
selectedRef,
icon
}: {
name: string;
isSelected: boolean;
onClick: () => void;
disabled?: boolean;
rightElement?: React.ReactNode;
selectedRef?: React.RefObject<HTMLButtonElement>;
icon?: React.ReactNode;
}) {
return (
<button
@ -41,9 +38,12 @@ export function ListItem({
"hover:bg-gray-50 dark:hover:bg-gray-800": !isSelected,
})}
>
<div className={clsx("truncate text-sm dark:text-gray-200", {
"text-gray-400 dark:text-gray-500": disabled,
})}>{name}</div>
<div className="flex items-center gap-1">
{icon && <div className="w-4 shrink-0">{icon}</div>}
<div className={clsx("truncate text-sm dark:text-gray-200", {
"text-gray-400 dark:text-gray-500": disabled,
})}>{name}</div>
</div>
{rightElement}
</button>
);

View file

@ -1,4 +1,5 @@
export const USE_RAG = process.env.USE_RAG === 'true';
export const USE_RAG_UPLOADS = process.env.USE_RAG_UPLOADS === 'true';
export const USE_RAG_SCRAPING = process.env.USE_RAG_SCRAPING === 'true';
export const USE_CHAT_WIDGET = process.env.USE_CHAT_WIDGET === 'true';
export const USE_CHAT_WIDGET = process.env.USE_CHAT_WIDGET === 'true';
export const USE_AUTH = process.env.USE_AUTH === 'true';

View file

@ -8,6 +8,7 @@ import { EmbeddingDoc } from "./types/datasource_types";
import { DataSourceDoc } from "./types/datasource_types";
import { DataSource } from "./types/datasource_types";
import { TestScenario, TestResult, TestRun, TestProfile, TestSimulation } from "./types/testing_types";
import { TwilioConfig } from "./types/voice_types";
import { z } from 'zod';
import { apiV1 } from "rowboat-shared";
@ -28,4 +29,16 @@ export const testSimulationsCollection = db.collection<z.infer<typeof TestSimula
export const testRunsCollection = db.collection<z.infer<typeof TestRun>>("test_runs");
export const testResultsCollection = db.collection<z.infer<typeof TestResult>>("test_results");
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
// Create indexes
twilioConfigsCollection.createIndexes([
{
key: { workflow_id: 1, status: 1 },
name: "workflow_status_idx",
// This ensures only one active config per workflow
unique: true,
partialFilterExpression: { status: "active" }
}
]);

View file

@ -37,7 +37,7 @@ You are an helpful customer support assistant
Don'ts:
- don't ask user any other detail than email`,
model: "gpt-4o-mini",
model: "gpt-4o",
toggleAble: true,
ragReturnType: "chunks",
ragK: 3,
@ -48,7 +48,7 @@ You are an helpful customer support assistant
type: "post_process",
description: "",
instructions: "Ensure that the agent response is terse and to the point.",
model: "gpt-4o-mini",
model: "gpt-4o",
toggleAble: true,
locked: true,
global: true,
@ -61,7 +61,7 @@ You are an helpful customer support assistant
type: "escalation",
description: "",
instructions: "Get the user's contact information and let them know that their request has been escalated.",
model: "gpt-4o-mini",
model: "gpt-4o",
locked: true,
toggleAble: false,
ragReturnType: "chunks",
@ -75,6 +75,11 @@ You are an helpful customer support assistant
type: "style_prompt",
prompt: "You should be empathetic and helpful.",
},
{
name: "Greeting",
type: "greeting",
prompt: "Hello! How can I help you?"
}
],
tools: [],
},
@ -131,6 +136,11 @@ You are an helpful customer support assistant
"name": "Style prompt",
"type": "style_prompt",
"prompt": "You should be empathetic and helpful."
},
{
"name": "Greeting",
"type": "greeting",
"prompt": "Hello! How can I help you?"
}
],
"tools": [
@ -259,6 +269,11 @@ You are an helpful customer support assistant
"type": "style_prompt",
"prompt": "---\n\nmake this more friendly. Keep it to 5-7 sentences. Use these as example references:\n\n---"
},
{
"name": "Greeting",
"type": "greeting",
"prompt": "Hello! How can I help you?"
},
{
"name": "structured_output",
"type": "base_prompt",
@ -332,4 +347,12 @@ You are an helpful customer support assistant
}
],
}
}
export const starting_copilot_prompts: { [key: string]: string } = {
"Credit Card Assistant": "Create a credit card assistant that helps users with credit card related queries like card recommendations, benefits, rewards, application process, and general credit card advice. Provide accurate and helpful information while maintaining a professional and friendly tone.",
"Scheduling Assistant": "Create an appointment scheduling assistant that helps users schedule, modify, and manage their appointments efficiently. Help with finding available time slots, sending reminders, rescheduling appointments, and answering questions about scheduling policies and procedures. Maintain a professional and organized approach.",
"Banking Assistant": "Create a banking assistant focused on helping customers with their banking needs. Help with account inquiries, banking products and services, transaction information, and general banking guidance. Prioritize accuracy and security while providing clear and helpful responses to banking-related questions."
}

View file

@ -1,7 +1,9 @@
import { z } from "zod";
import { ConnectedEntity, sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPrompt, WorkflowTool } from "./workflow_types";
import { sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPrompt, WorkflowTool } from "./workflow_types";
import { apiV1 } from "rowboat-shared";
import { ApiMessage } from "./types";
import { TestProfile } from "./testing_types";
import { MCPServer } from "./types";
export const AgenticAPIChatMessage = z.object({
role: z.union([z.literal('user'), z.literal('assistant'), z.literal('tool'), z.literal('system')]),
@ -30,12 +32,8 @@ export const AgenticAPIAgent = WorkflowAgent
locked: true,
toggleAble: true,
global: true,
ragDataSources: true,
ragReturnType: true,
ragK: true,
})
.extend({
hasRagSources: z.boolean().default(false).optional(),
tools: z.array(z.string()),
prompts: z.array(z.string()),
connectedAgents: z.array(z.string()),
@ -43,18 +41,22 @@ export const AgenticAPIAgent = WorkflowAgent
export const AgenticAPIPrompt = WorkflowPrompt;
export const AgenticAPITool = WorkflowTool.omit({
mockTool: true,
autoSubmitMockedResponse: true,
});
export const AgenticAPITool = WorkflowTool
.omit({
autoSubmitMockedResponse: true,
})
export const AgenticAPIChatRequest = z.object({
projectId: z.string(),
messages: z.array(AgenticAPIChatMessage),
state: z.unknown(),
agents: z.array(AgenticAPIAgent),
tools: z.array(AgenticAPITool),
prompts: z.array(WorkflowPrompt),
startAgent: z.string(),
testProfile: TestProfile.optional(),
mcpServers: z.array(MCPServer).optional(),
toolWebhookUrl: z.string().optional(),
});
export const AgenticAPIChatResponse = z.object({
@ -62,6 +64,10 @@ export const AgenticAPIChatResponse = z.object({
state: z.unknown(),
});
export const AgenticAPIInitStreamResponse = z.object({
streamId: z.string(),
});
export function convertWorkflowToAgenticAPI(workflow: z.infer<typeof Workflow>): {
agents: z.infer<typeof AgenticAPIAgent>[];
tools: z.infer<typeof AgenticAPITool>[];
@ -82,8 +88,10 @@ export function convertWorkflowToAgenticAPI(workflow: z.infer<typeof Workflow>):
description: agent.description,
instructions: sanitized,
model: agent.model,
hasRagSources: agent.ragDataSources ? agent.ragDataSources.length > 0 : false,
controlType: agent.controlType,
ragDataSources: agent.ragDataSources,
ragK: agent.ragK,
ragReturnType: agent.ragReturnType,
tools: entities.filter(e => e.type == 'tool').map(e => e.name),
prompts: entities.filter(e => e.type == 'prompt').map(e => e.name),
connectedAgents: entities.filter(e => e.type === 'agent').map(e => e.name),
@ -91,10 +99,8 @@ export function convertWorkflowToAgenticAPI(workflow: z.infer<typeof Workflow>):
return agenticAgent;
}),
tools: workflow.tools.map(tool => {
const { mockTool, autoSubmitMockedResponse, ...rest } = tool;
return {
...rest,
};
const { autoSubmitMockedResponse, ...rest } = tool;
return rest;
}),
prompts: workflow.prompts
.map(p => {

View file

@ -1,4 +1,5 @@
import { z } from "zod";
export const DataSource = z.object({
name: z.string(),
projectId: z.string(),
@ -23,8 +24,13 @@ export const DataSource = z.object({
z.object({
type: z.literal('files'),
}),
z.object({
type: z.literal('text'),
})
]),
});export const DataSourceDoc = z.object({
});
export const DataSourceDoc = z.object({
sourceId: z.string(),
name: z.string(),
version: z.number(),
@ -50,8 +56,13 @@ export const DataSource = z.object({
mimeType: z.string(),
s3Key: z.string(),
}),
z.object({
type: z.literal('text'),
content: z.string(),
}),
]),
});
export const EmbeddingDoc = z.object({
content: z.string(),
sourceId: z.string(),
@ -74,5 +85,4 @@ export const EmbeddingRecord = z.object({
title: z.string(),
name: z.string(),
}),
});
});

View file

@ -1,4 +1,6 @@
import { z } from "zod";
import { MCPServer } from "./types";
export const Project = z.object({
_id: z.string().uuid(),
name: z.string(),
@ -11,16 +13,19 @@ export const Project = z.object({
publishedWorkflowId: z.string().optional(),
nextWorkflowNumber: z.number().optional(),
testRunCounter: z.number().default(0),
});export const ProjectMember = z.object({
mcpServers: z.array(MCPServer).optional(),
});
export const ProjectMember = z.object({
userId: z.string(),
projectId: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const ApiKey = z.object({
projectId: z.string(),
key: z.string(),
createdAt: z.string().datetime(),
lastUsedAt: z.string().datetime().optional(),
});
});

View file

@ -20,12 +20,13 @@ export const TestProfile = z.object({
export const TestSimulation = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
name: z.string(),
description: z.string().optional().nullable(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
scenarioId: z.string(),
profileId: z.string().nullable(),
passCriteria: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const TestRun = z.object({
@ -48,5 +49,6 @@ export const TestResult = z.object({
runId: z.string(),
simulationId: z.string(),
result: z.union([z.literal('pass'), z.literal('fail')]),
details: z.string()
details: z.string(),
transcript: z.string()
});

View file

@ -2,6 +2,11 @@ import { CoreMessage, ToolCallPart } from "ai";
import { z } from "zod";
import { apiV1 } from "rowboat-shared";
export const MCPServer = z.object({
name: z.string(),
url: z.string(),
});
export const PlaygroundChat = z.object({
createdAt: z.string().datetime(),
projectId: z.string(),

View file

@ -0,0 +1,32 @@
import { z } from 'zod';
import { WithId } from 'mongodb';
export const TwilioConfigParams = z.object({
phone_number: z.string(),
account_sid: z.string(),
auth_token: z.string(),
label: z.string(),
project_id: z.string(),
workflow_id: z.string(),
});
export const TwilioConfig = TwilioConfigParams.extend({
createdAt: z.date(),
status: z.enum(['active', 'deleted']),
});
export type TwilioConfigParams = z.infer<typeof TwilioConfigParams>;
export type TwilioConfig = WithId<z.infer<typeof TwilioConfig>>;
export interface TwilioConfigResponse {
success: boolean;
error?: string;
}
export interface InboundConfigResponse {
status: 'configured' | 'reconfigured';
phone_number: string;
workflow_id: string;
previous_webhook?: string;
error?: string;
}

View file

@ -27,6 +27,7 @@ export const WorkflowPrompt = z.object({
type: z.union([
z.literal('base_prompt'),
z.literal('style_prompt'),
z.literal('greeting'),
]),
prompt: z.string(),
});
@ -44,6 +45,8 @@ export const WorkflowTool = z.object({
})),
required: z.array(z.string()).optional(),
}),
isMcp: z.boolean().default(false).optional(),
mcpServerName: z.string().optional(),
});
export const Workflow = z.object({
name: z.string().optional(),

View file

@ -1,89 +1,8 @@
import { convertFromAgenticAPIChatMessages } from "./types/agents_api_types";
import { ClientToolCallRequest } from "./types/tool_types";
import { ClientToolCallJwt, GetInformationToolResult } from "./types/tool_types";
import { ClientToolCallRequestBody } from "./types/tool_types";
import { AgenticAPIChatResponse } from "./types/agents_api_types";
import { AgenticAPIChatRequest } from "./types/agents_api_types";
import { Workflow, WorkflowAgent } from "./types/workflow_types";
import { AgenticAPIChatMessage } from "./types/agents_api_types";
import { AgenticAPIChatResponse, AgenticAPIChatRequest, AgenticAPIChatMessage, AgenticAPIInitStreamResponse } from "./types/agents_api_types";
import { z } from "zod";
import { dataSourceDocsCollection, dataSourcesCollection, projectsCollection } from "./mongodb";
import { apiV1 } from "rowboat-shared";
import { SignJWT } from "jose";
import crypto from "crypto";
import { ObjectId } from "mongodb";
import { embeddingModel } from "./embedding";
import { embed, generateObject } from "ai";
import { qdrantClient } from "./qdrant";
import { EmbeddingRecord } from "./types/datasource_types";
import { generateObject } from "ai";
import { ApiMessage } from "./types/types";
import { openai } from "@ai-sdk/openai";
import { TestProfile } from "./types/testing_types";
export async function callClientToolWebhook(
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number],
messages: z.infer<typeof ApiMessage>[],
projectId: string,
): Promise<unknown> {
const project = await projectsCollection.findOne({
"_id": projectId,
});
if (!project) {
throw new Error('Project not found');
}
if (!project.webhookUrl) {
throw new Error('Webhook URL not found');
}
// prepare request body
const content = JSON.stringify({
toolCall,
messages,
} as z.infer<typeof ClientToolCallRequestBody>);
const requestId = crypto.randomUUID();
const bodyHash = crypto
.createHash('sha256')
.update(content, 'utf8')
.digest('hex');
// sign request
const jwt = await new SignJWT({
requestId,
projectId,
bodyHash,
} as z.infer<typeof ClientToolCallJwt>)
.setProtectedHeader({
alg: 'HS256',
typ: 'JWT',
})
.setIssuer('rowboat')
.setAudience(project.webhookUrl)
.setSubject(`tool-call-${toolCall.id}`)
.setJti(requestId)
.setIssuedAt()
.setExpirationTime("5 minutes")
.sign(new TextEncoder().encode(project.secret));
// make request
const request: z.infer<typeof ClientToolCallRequest> = {
requestId,
content,
};
const response = await fetch(project.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-signature-jwt': jwt,
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Failed to call webhook: ${response.status}: ${response.statusText}`);
}
const responseBody = await response.json();
return responseBody;
}
export async function getAgenticApiResponse(
request: z.infer<typeof AgenticAPIChatRequest>,
@ -93,7 +12,7 @@ export async function getAgenticApiResponse(
rawAPIResponse: unknown,
}> {
// call agentic api
console.log(`agentic request`, JSON.stringify(request, null, 2));
console.log(`sending agentic api request`, JSON.stringify(request));
const response = await fetch(process.env.AGENTS_API_URL + '/chat', {
method: 'POST',
body: JSON.stringify(request),
@ -107,6 +26,7 @@ export async function getAgenticApiResponse(
throw new Error(`Failed to call agentic api: ${response.statusText}`);
}
const responseJson = await response.json();
console.log(`received agentic api response`, JSON.stringify(responseJson));
const result: z.infer<typeof AgenticAPIChatResponse> = responseJson;
return {
messages: result.messages,
@ -115,85 +35,29 @@ export async function getAgenticApiResponse(
};
}
export async function runRAGToolCall(
projectId: string,
query: string,
sourceIds: string[],
returnType: z.infer<typeof WorkflowAgent>['ragReturnType'],
k: number,
): Promise<z.infer<typeof GetInformationToolResult>> {
// create embedding for question
const embedResult = await embed({
model: embeddingModel,
value: query,
});
// fetch all data sources for this project
const sources = await dataSourcesCollection.find({
projectId: projectId,
active: true,
}).toArray();
const validSourceIds = sources
.filter(s => sourceIds.includes(s._id.toString())) // id should be in sourceIds
.filter(s => s.active) // should be active
.map(s => s._id.toString());
// if no sources found, return empty response
if (validSourceIds.length === 0) {
return {
results: [],
};
}
// perform qdrant vector search
const qdrantResults = await qdrantClient.query("embeddings", {
query: embedResult.embedding,
filter: {
must: [
{ key: "projectId", match: { value: projectId } },
{ key: "sourceId", match: { any: validSourceIds } },
],
export async function getAgenticResponseStreamId(
request: z.infer<typeof AgenticAPIChatRequest>,
): Promise<z.infer<typeof AgenticAPIInitStreamResponse>> {
// call agentic api
console.log(`sending agentic api init stream request`, JSON.stringify(request));
const response = await fetch(process.env.AGENTS_API_URL + '/chat_stream_init', {
method: 'POST',
body: JSON.stringify(request),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.AGENTS_API_KEY || 'test'}`,
},
limit: k,
with_payload: true,
});
// if return type is chunks, return the chunks
let results = qdrantResults.points.map((point) => {
const { title, name, content, docId, sourceId } = point.payload as z.infer<typeof EmbeddingRecord>['payload'];
return {
title,
name,
content,
docId,
sourceId,
};
});
if (returnType === 'chunks') {
return {
results,
};
if (!response.ok) {
console.error('Failed to call agentic init stream api', response);
throw new Error(`Failed to call agentic init stream api: ${response.statusText}`);
}
// otherwise, fetch the doc contents from mongodb
const docs = await dataSourceDocsCollection.find({
_id: { $in: results.map(r => new ObjectId(r.docId)) },
}).toArray();
// map the results to the docs
results = results.map(r => {
const doc = docs.find(d => d._id.toString() === r.docId);
return {
...r,
content: doc?.content || '',
};
});
return {
results,
};
const responseJson = await response.json();
console.log(`received agentic api init stream response`, JSON.stringify(responseJson));
const result: z.infer<typeof AgenticAPIInitStreamResponse> = responseJson;
return result;
}
// create a PrefixLogger class that wraps console.log with a prefix
// and allows chaining with a parent logger
export class PrefixLogger {

View file

@ -1,5 +1,12 @@
import { App } from "./app";
import { redirect } from "next/navigation";
import { USE_AUTH } from "./lib/feature_flags";
export const dynamic = 'force-dynamic';
export default function Home() {
if (!USE_AUTH) {
redirect("/projects");
}
return <App />
}

View file

@ -2,16 +2,26 @@
import { Metadata } from "next";
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider } from "@heroui/react";
import { ReactNode, useEffect, useState, useCallback } from "react";
import { ReactNode, useEffect, useState, useCallback, useMemo } from "react";
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
import { updateMcpServers } from "../../../actions/mcp_actions";
import { CopyButton } from "../../../lib/components/copy-button";
import { EditableField } from "../../../lib/components/editable-field";
import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon } from "lucide-react";
import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon, CheckCircleIcon, XCircleIcon } from "lucide-react";
import { WithStringId } from "../../../lib/types/types";
import { ApiKey } from "../../../lib/types/project_types";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
import { Label } from "../../../lib/components/label";
import { ListItem } from "../../../lib/components/structured-list";
import { FormSection } from "../../../lib/components/form-section";
import { StructuredPanel } from "../../../lib/components/structured-panel";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "../../../../components/ui/resizable"
import { VoiceSection } from './voice';
export const metadata: Metadata = {
title: "Project config",
@ -78,38 +88,250 @@ export function BasicSettingsSection({
}
return <Section title="Basic settings">
<SectionRow>
<LeftLabel label="Project name" />
<RightContent>
<div className="flex flex-row gap-2 items-center">
{loading && <Spinner size="sm" />}
{!loading && <EditableField
value={projectName || ''}
onChange={updateName}
className="w-full"
/>}
</div>
</RightContent>
</SectionRow>
<FormSection label="Project name">
{loading && <Spinner size="sm" />}
{!loading && <EditableField
value={projectName || ''}
onChange={updateName}
className="w-full"
/>}
</FormSection>
<Divider />
<SectionRow>
<LeftLabel label="Project ID" />
<RightContent>
<div className="flex flex-row gap-2 items-center">
<div className="text-gray-600 text-sm font-mono">{projectId}</div>
<CopyButton
onCopy={() => {
navigator.clipboard.writeText(projectId);
}}
label="Copy"
successLabel="Copied"
/>
</div>
</RightContent>
</SectionRow>
<FormSection label="Project ID">
<div className="flex flex-row gap-2 items-center">
<div className="text-gray-600 text-sm font-mono">{projectId}</div>
<CopyButton
onCopy={() => {
navigator.clipboard.writeText(projectId);
}}
label="Copy"
successLabel="Copied"
/>
</div>
</FormSection>
</Section>;
}
function McpServersSection({
projectId,
}: {
projectId: string;
}) {
const [servers, setServers] = useState<Array<{ name: string; url: string }>>([]);
const [originalServers, setOriginalServers] = useState<Array<{ name: string; url: string }>>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const [newServer, setNewServer] = useState({ name: '', url: '' });
const [validationErrors, setValidationErrors] = useState<{
name?: string;
url?: string;
}>({});
// Load initial servers
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
const initialServers = project.mcpServers || [];
setServers(JSON.parse(JSON.stringify(initialServers))); // Deep copy
setOriginalServers(JSON.parse(JSON.stringify(initialServers))); // Deep copy
setLoading(false);
});
}, [projectId]);
// Check if there are unsaved changes by comparing the arrays
const hasChanges = useMemo(() => {
if (servers.length !== originalServers.length) return true;
return servers.some((server, index) => {
return server.name !== originalServers[index]?.name ||
server.url !== originalServers[index]?.url;
});
}, [servers, originalServers]);
const handleAddServer = () => {
setNewServer({ name: '', url: '' });
setValidationErrors({});
onOpen();
};
const handleRemoveServer = (index: number) => {
setServers(servers.filter((_, i) => i !== index));
};
const handleCreateServer = () => {
// Clear previous validation errors
setValidationErrors({});
const errors: typeof validationErrors = {};
// Validate name uniqueness
if (!newServer.name.trim()) {
errors.name = 'Server name is required';
} else if (servers.some(s => s.name === newServer.name)) {
errors.name = 'Server name must be unique';
}
// Validate URL
if (!newServer.url.trim()) {
errors.url = 'Server URL is required';
} else {
try {
new URL(newServer.url);
} catch {
errors.url = 'Invalid URL format';
}
}
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
return;
}
setServers([...servers, newServer]);
onClose();
};
const handleSave = async () => {
setSaving(true);
try {
await updateMcpServers(projectId, servers);
setOriginalServers(JSON.parse(JSON.stringify(servers))); // Update original servers after successful save
setMessage({ type: 'success', text: 'Servers updated successfully' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Failed to update servers' });
}
setSaving(false);
};
return <Section title="MCP servers">
<div className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
MCP servers are used to execute MCP tools.
</p>
<Button
size="sm"
variant="flat"
startContent={<PlusIcon className="w-4 h-4" />}
onPress={handleAddServer}
>
Add Server
</Button>
</div>
{loading ? (
<Spinner size="sm" />
) : (
<>
<div className="space-y-3">
{servers.map((server, index) => (
<div key={index} className="flex gap-3 items-center p-3 border border-border rounded-md">
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">{server.url}</div>
</div>
<Button
size="sm"
color="danger"
variant="light"
onPress={() => handleRemoveServer(index)}
>
Remove
</Button>
</div>
))}
{servers.length === 0 && (
<div className="text-center text-muted-foreground p-4">
No servers configured
</div>
)}
</div>
{hasChanges && (
<div className="flex justify-end">
<Button
size="sm"
color="primary"
onPress={handleSave}
isLoading={saving}
>
Save Changes
</Button>
</div>
)}
{message && (
<div className={`text-sm p-2 rounded-md ${
message.type === 'success' ? 'bg-green-50 text-green-500' : 'bg-red-50 text-red-500'
}`}>
{message.text}
</div>
)}
</>
)}
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader>Add MCP Server</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
<Input
label="Server Name"
placeholder="Enter server name"
value={newServer.name}
onChange={(e) => {
setNewServer({ ...newServer, name: e.target.value });
// Clear name error when user types
if (validationErrors.name) {
setValidationErrors(prev => ({
...prev,
name: undefined
}));
}
}}
errorMessage={validationErrors.name}
isInvalid={!!validationErrors.name}
isRequired
/>
<Input
label="SSE URL"
placeholder="https://localhost:8000/sse"
value={newServer.url}
onChange={(e) => {
setNewServer({ ...newServer, url: e.target.value });
// Clear URL error when user types
if (validationErrors.url) {
setValidationErrors(prev => ({
...prev,
url: undefined
}));
}
}}
errorMessage={validationErrors.url}
isInvalid={!!validationErrors.url}
isRequired
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button
color="primary"
onPress={handleCreateServer}
isDisabled={!newServer.name || !newServer.url}
>
Add Server
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</Section>;
}
@ -408,20 +630,15 @@ export function WebhookUrlSection({
In workflow editor, tool calls will be posted to this URL, unless they are mocked.
</p>
<Divider />
<SectionRow>
<LeftLabel label="Webhook URL" />
<RightContent>
<div className="flex flex-row gap-2 items-center">
{loading && <Spinner size="sm" />}
{!loading && <EditableField
value={webhookUrl || ''}
onChange={update}
validate={validate}
className="w-full"
/>}
</div>
</RightContent>
</SectionRow>
<FormSection label="Webhook URL">
{loading && <Spinner size="sm" />}
{!loading && <EditableField
value={webhookUrl || ''}
onChange={update}
validate={validate}
className="w-full"
/>}
</FormSection>
</Section>;
}
@ -567,7 +784,30 @@ export function DeleteProjectSection({
);
}
export default function App({
function NavigationMenu({
selected,
onSelect
}: {
selected: string;
onSelect: (page: string) => void;
}) {
const items = ['Project', 'Tools', 'Voice'];
return (
<StructuredPanel title="SETTINGS">
{items.map((item) => (
<ListItem
key={item}
name={item}
isSelected={selected === item}
onClick={() => onSelect(item)}
/>
))}
</StructuredPanel>
);
}
export function ConfigApp({
projectId,
useChatWidget,
chatWidgetHost,
@ -576,21 +816,54 @@ export default function App({
useChatWidget: boolean;
chatWidgetHost: string;
}) {
return <div className="flex flex-col h-full">
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-border">
<div className="flex flex-col">
<h1 className="text-lg">Project config</h1>
</div>
</div>
<div className="grow overflow-auto py-4">
<div className="max-w-[768px] mx-auto flex flex-col gap-4">
<BasicSettingsSection projectId={projectId} />
<SecretSection projectId={projectId} />
<ApiKeysSection projectId={projectId} />
<WebhookUrlSection projectId={projectId} />
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
<DeleteProjectSection projectId={projectId} />
</div>
</div>
</div>;
}
const [selectedPage, setSelectedPage] = useState('Project');
const renderContent = () => {
switch (selectedPage) {
case 'Project':
return (
<div className="h-full overflow-auto p-6 space-y-6">
<BasicSettingsSection projectId={projectId} />
<SecretSection projectId={projectId} />
<McpServersSection projectId={projectId} />
<WebhookUrlSection projectId={projectId} />
<ApiKeysSection projectId={projectId} />
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
<DeleteProjectSection projectId={projectId} />
</div>
);
case 'Tools':
return (
<div className="h-full overflow-auto p-6">
<WebhookUrlSection projectId={projectId} />
</div>
);
case 'Voice':
return (
<div className="h-full overflow-auto p-6">
<VoiceSection projectId={projectId} />
</div>
);
default:
return null;
}
};
return (
<ResizablePanelGroup direction="horizontal" className="h-screen gap-1">
<ResizablePanel minSize={10} defaultSize={15}>
<NavigationMenu
selected={selectedPage}
onSelect={setSelectedPage}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={20} defaultSize={85}>
{renderContent()}
</ResizablePanel>
</ResizablePanelGroup>
);
}
// Add default export
export default ConfigApp;

View file

@ -0,0 +1,229 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from "@heroui/react";
import { configureTwilioNumber, mockConfigureTwilioNumber, getTwilioConfigs, deleteTwilioConfig } from "../../../actions/voice_actions";
import { FormSection } from "../../../lib/components/form-section";
import { EditableField } from "../../../lib/components/editable-field-with-immediate-save";
import { StructuredPanel } from "../../../lib/components/structured-panel";
import { TwilioConfig } from "../../../lib/types/voice_types";
import { CheckCircleIcon, XCircleIcon, InfoIcon } from "lucide-react";
export function VoiceSection({
projectId,
}: {
projectId: string;
}) {
const [formState, setFormState] = useState({
phone: '',
accountSid: '',
authToken: '',
label: ''
});
const [existingConfig, setExistingConfig] = useState<TwilioConfig | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [configurationValid, setConfigurationValid] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const loadConfig = useCallback(async () => {
try {
const configs = await getTwilioConfigs(projectId);
if (configs.length > 0) {
const config = configs[0];
setExistingConfig(config);
setFormState({
phone: config.phone_number,
accountSid: config.account_sid,
authToken: config.auth_token,
label: config.label || ''
});
setConfigurationValid(true);
setIsDirty(false);
}
} catch (err) {
console.error('Error loading config:', err);
}
}, [projectId]);
useEffect(() => {
loadConfig();
}, [loadConfig]);
const handleFieldChange = (field: string, value: string) => {
setFormState(prev => ({
...prev,
[field]: value
}));
setIsDirty(true);
setError(null);
};
const handleConfigureTwilio = async () => {
if (!formState.phone || !formState.accountSid || !formState.authToken) {
setError('Please fill in all required fields');
setConfigurationValid(false);
return;
}
const workflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
if (!workflowId) {
setError('No workflow selected. Please select a workflow first.');
setConfigurationValid(false);
return;
}
setLoading(true);
setError(null);
const configParams = {
phone_number: formState.phone,
account_sid: formState.accountSid,
auth_token: formState.authToken,
label: formState.label,
project_id: projectId,
workflow_id: workflowId,
};
const result = await configureTwilioNumber(configParams);
if (result.success) {
await loadConfig();
setSuccess(true);
setConfigurationValid(true);
setIsDirty(false);
setTimeout(() => setSuccess(false), 3000);
} else {
setError(result.error || 'Failed to validate Twilio credentials or phone number');
setConfigurationValid(false);
}
setLoading(false);
};
const handleDeleteConfig = async () => {
if (!existingConfig) return;
if (confirm('Are you sure you want to delete this phone number configuration?')) {
await deleteTwilioConfig(projectId, existingConfig._id.toString());
setExistingConfig(null);
setFormState({
phone: '',
accountSid: '',
authToken: '',
label: ''
});
setConfigurationValid(false);
setIsDirty(false);
}
};
return (
<div className="flex flex-col gap-4">
<StructuredPanel title="CONFIGURE TWILIO PHONE NUMBER">
<div className="flex flex-col gap-4 p-6">
{success && (
<div className="bg-green-50 text-green-700 p-4 rounded-md flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5" />
<span>
{existingConfig
? 'Twilio number validated and updated successfully!'
: 'Twilio number validated and configured successfully!'}
</span>
</div>
)}
{error && (
<div className="bg-red-50 text-red-700 p-4 rounded-md flex items-center gap-2">
<XCircleIcon className="w-5 h-5" />
<span>{error}</span>
</div>
)}
{existingConfig && configurationValid && !error && (
<div className="bg-blue-50 text-blue-700 p-4 rounded-md flex items-center gap-2">
<InfoIcon className="w-5 h-5" />
<span>This is your currently assigned phone number for this project</span>
</div>
)}
<FormSection label="TWILIO PHONE NUMBER">
<EditableField
value={formState.phone}
onChange={(value) => handleFieldChange('phone', value)}
placeholder="+14156021922"
disabled={loading}
/>
</FormSection>
<FormSection label="TWILIO ACCOUNT SID">
<EditableField
value={formState.accountSid}
onChange={(value) => handleFieldChange('accountSid', value)}
placeholder="AC5588686d3ec65df89615274..."
disabled={loading}
/>
</FormSection>
<FormSection label="TWILIO AUTH TOKEN">
<EditableField
value={formState.authToken}
onChange={(value) => handleFieldChange('authToken', value)}
placeholder="b74e48f9098764ef834cf6bd..."
type="password"
disabled={loading}
/>
</FormSection>
<FormSection label="LABEL">
<EditableField
value={formState.label}
onChange={(value) => handleFieldChange('label', value)}
placeholder="Enter a label for this number..."
disabled={loading}
/>
</FormSection>
<div className="flex gap-2 mt-4">
<Button
color="primary"
onClick={handleConfigureTwilio}
isLoading={loading}
disabled={loading || !isDirty}
>
{existingConfig ? 'Update Twilio Config' : 'Import from Twilio'}
</Button>
{existingConfig ? (
<Button
color="danger"
variant="flat"
onClick={handleDeleteConfig}
disabled={loading}
>
Delete Configuration
</Button>
) : (
<Button
variant="flat"
onClick={() => {
setFormState({
phone: '',
accountSid: '',
authToken: '',
label: ''
});
setError(null);
setIsDirty(false);
}}
disabled={loading}
>
Cancel
</Button>
)}
</div>
</div>
</StructuredPanel>
</div>
);
}

View file

@ -1,7 +1,7 @@
'use client';
import { useState } from "react";
import { z } from "zod";
import { PlaygroundChat } from "../../../lib/types/types";
import { MCPServer, PlaygroundChat } from "../../../lib/types/types";
import { Workflow } from "../../../lib/types/workflow_types";
import { Chat } from "./chat";
import { ActionButton, Pane } from "../workflow/pane";
@ -17,14 +17,19 @@ export function App({
projectId,
workflow,
messageSubscriber,
mcpServerUrls,
toolWebhookUrl,
}: {
hidden?: boolean;
projectId: string;
workflow: z.infer<typeof Workflow>;
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
mcpServerUrls: Array<z.infer<typeof MCPServer>>;
toolWebhookUrl: string;
}) {
const [counter, setCounter] = useState<number>(0);
const [testProfile, setTestProfile] = useState<z.infer<typeof TestProfile> | null>(null);
const [systemMessage, setSystemMessage] = useState<string>(defaultSystemMessage);
const [chat, setChat] = useState<z.infer<typeof PlaygroundChat>>({
projectId,
createdAt: new Date().toISOString(),
@ -33,42 +38,14 @@ export function App({
systemMessage: defaultSystemMessage,
});
function handleTestProfileChange(profile: WithStringId<z.infer<typeof TestProfile>> | null) {
setTestProfile(profile);
function handleSystemMessageChange(message: string) {
setSystemMessage(message);
setCounter(counter + 1);
}
// const beginSimulation = useCallback((scenario: string) => {
// setExistingChatId(null);
// setLoadingChat(true);
// setCounter(counter + 1);
// setChat({
// projectId,
// createdAt: new Date().toISOString(),
// messages: [],
// simulated: true,
// simulationScenario: scenario,
// systemMessage: '',
// });
// }, [counter, projectId]);
// useEffect(() => {
// const scenarioId = localStorage.getItem('pendingScenarioId');
// if (scenarioId && projectId) {
// console.log('Scenario Effect triggered:', { scenarioId, projectId });
// getScenario(projectId, scenarioId).then((scenario) => {
// console.log('Scenario data received:', scenario);
// beginSimulation(scenario.description);
// localStorage.removeItem('pendingScenarioId');
// }).catch(error => {
// console.error('Error fetching scenario:', error);
// localStorage.removeItem('pendingScenarioId');
// });
// }
// }, [projectId, beginSimulation]);
if (hidden) {
return <></>;
function handleTestProfileChange(profile: WithStringId<z.infer<typeof TestProfile>> | null) {
setTestProfile(profile);
setCounter(counter + 1);
}
function handleNewChatButtonClick() {
@ -82,6 +59,10 @@ export function App({
});
}
if (hidden) {
return <></>;
}
return (
<Pane
title="PLAYGROUND"
@ -105,6 +86,10 @@ export function App({
testProfile={testProfile}
messageSubscriber={messageSubscriber}
onTestProfileChange={handleTestProfileChange}
systemMessage={systemMessage}
onSystemMessageChange={handleSystemMessageChange}
mcpServerUrls={mcpServerUrls}
toolWebhookUrl={toolWebhookUrl}
/>
</div>
</Pane>

View file

@ -1,10 +1,10 @@
'use client';
import { getAssistantResponse, simulateUserResponse } from "../../../actions/actions";
import { useEffect, useState } from "react";
import { getAssistantResponseStreamId } from "../../../actions/actions";
import { useEffect, useOptimistic, useState } from "react";
import { Messages } from "./messages";
import z from "zod";
import { PlaygroundChat } from "../../../lib/types/types";
import { convertToAgenticAPIChatMessages } from "../../../lib/types/agents_api_types";
import { MCPServer, PlaygroundChat } from "../../../lib/types/types";
import { AgenticAPIChatMessage, convertFromAgenticAPIChatMessages, convertToAgenticAPIChatMessages } from "../../../lib/types/agents_api_types";
import { convertWorkflowToAgenticAPI } from "../../../lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "../../../lib/types/agents_api_types";
import { Workflow } from "../../../lib/types/workflow_types";
@ -13,17 +13,21 @@ import { Button, Spinner, Tooltip } from "@heroui/react";
import { apiV1 } from "rowboat-shared";
import { CopyAsJsonButton } from "./copy-as-json-button";
import { TestProfile } from "@/app/lib/types/testing_types";
import { ProfileSelector } from "@/app/lib/components/selectors/profile-selector";
import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/profile-selector";
import { WithStringId } from "@/app/lib/types/types";
import { XCircleIcon, XIcon } from "lucide-react";
import { XIcon } from "lucide-react";
export function Chat({
chat,
projectId,
workflow,
messageSubscriber,
testProfile=null,
testProfile = null,
onTestProfileChange,
systemMessage,
onSystemMessageChange,
mcpServerUrls,
toolWebhookUrl,
}: {
chat: z.infer<typeof PlaygroundChat>;
projectId: string;
@ -31,19 +35,26 @@ export function Chat({
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
testProfile?: z.infer<typeof TestProfile> | null;
onTestProfileChange: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
systemMessage: string;
onSystemMessageChange: (message: string) => void;
mcpServerUrls: Array<z.infer<typeof MCPServer>>;
toolWebhookUrl: string;
}) {
const [messages, setMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
const [loadingAssistantResponse, setLoadingAssistantResponse] = useState<boolean>(false);
const [loadingUserResponse, setLoadingUserResponse] = useState<boolean>(false);
const [simulationComplete, setSimulationComplete] = useState<boolean>(chat.simulationComplete || false);
const [agenticState, setAgenticState] = useState<unknown>(chat.agenticState || {
last_agent_name: workflow.startAgent,
});
const [fetchResponseError, setFetchResponseError] = useState<string | null>(null);
const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null);
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
const [systemMessage, setSystemMessage] = useState<string | undefined>(testProfile?.context);
const [isProfileSelectorOpen, setIsProfileSelectorOpen] = useState(false);
const [optimisticMessages, setOptimisticMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
// reset optimistic messages when messages change
useEffect(() => {
setOptimisticMessages(messages);
}, [messages]);
// collect published tool call results
const toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>> = {};
@ -52,6 +63,7 @@ export function Chat({
.forEach((message) => {
toolCallResults[message.tool_call_id] = message;
});
console.log('toolCallResults', toolCallResults);
function handleUserMessage(prompt: string) {
const updatedMessages: z.infer<typeof apiV1.ChatMessage>[] = [...messages, {
@ -65,15 +77,6 @@ export function Chat({
setFetchResponseError(null);
}
function handleToolCallResults(results: z.infer<typeof apiV1.ToolMessage>[]) {
setMessages([...messages, ...results.map((result) => ({
...result,
version: 'v1' as const,
chatId: '',
createdAt: new Date().toISOString(),
}))]);
}
// reset state when workflow changes
useEffect(() => {
setMessages([]);
@ -89,15 +92,19 @@ export function Chat({
}
}, [messages, messageSubscriber]);
// get agent response
// get assistant response
useEffect(() => {
console.log('stream useEffect called');
let ignore = false;
let eventSource: EventSource | null = null;
let msgs: z.infer<typeof apiV1.ChatMessage>[] = [];
async function process() {
setLoadingAssistantResponse(true);
setFetchResponseError(null);
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
const request: z.infer<typeof AgenticAPIChatRequest> = {
projectId,
messages: convertToAgenticAPIChatMessages([{
role: 'system',
content: systemMessage || '',
@ -110,119 +117,89 @@ export function Chat({
tools,
prompts,
startAgent,
mcpServers: mcpServerUrls,
toolWebhookUrl: toolWebhookUrl,
testProfile: testProfile ?? undefined,
};
setLastAgenticRequest(null);
setLastAgenticResponse(null);
let streamId: string | null = null;
try {
const response = await getAssistantResponse(projectId, request);
const response = await getAssistantResponseStreamId(request);
if (ignore) {
return;
}
if (simulationComplete) {
return;
}
setLastAgenticRequest(response.rawRequest);
setLastAgenticResponse(response.rawResponse);
setMessages([...messages, ...response.messages.map((message) => ({
...message,
version: 'v1' as const,
chatId: '',
createdAt: new Date().toISOString(),
}))]);
setAgenticState(response.state);
streamId = response.streamId;
} catch (err) {
if (!ignore) {
setFetchResponseError(`Failed to get assistant response: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
} finally {
if (!ignore) {
setLoadingAssistantResponse(false);
}
}
}
// if no messages, return
if (messages.length === 0) {
return;
}
// if last message is not from role user
// or tool, return
const last = messages[messages.length - 1];
if (fetchResponseError) {
return;
}
if (last.role !== 'user' && last.role !== 'tool') {
return;
}
process();
return () => {
ignore = true;
};
}, [chat.simulated, messages, projectId, agenticState, workflow, fetchResponseError, systemMessage, simulationComplete]);
// simulate user turn
useEffect(() => {
let ignore = false;
async function process() {
if (chat.simulationScenario === undefined) {
if (ignore || !streamId) {
console.log('almost there', ignore, streamId);
return;
}
// fetch next user prompt
setLoadingUserResponse(true);
try {
// log the stream id
console.log('🔄 got assistant response', streamId);
const response = await simulateUserResponse(projectId, messages, chat.simulationScenario)
// read from SSE stream
eventSource = new EventSource(`/api/v1/stream-response/${streamId}`);
eventSource.addEventListener("message", (event) => {
if (ignore) {
return;
}
if (simulationComplete) {
return;
try {
const data = JSON.parse(event.data);
const msg = AgenticAPIChatMessage.parse(data);
const parsedMsg = convertFromAgenticAPIChatMessages([msg])[0];
console.log('🔄 got assistant response chunk', parsedMsg);
msgs.push(parsedMsg);
setOptimisticMessages(prev => [...prev, parsedMsg]);
} catch (err) {
console.error('Failed to parse SSE message:', err);
setFetchResponseError(`Failed to parse SSE message: ${err instanceof Error ? err.message : 'Unknown error'}`);
setOptimisticMessages(messages);
}
if (response.trim() === 'EXIT') {
setSimulationComplete(true);
return;
});
eventSource.addEventListener('done', (event) => {
if (eventSource) {
eventSource.close();
}
setMessages([...messages, {
role: 'user',
content: response,
version: 'v1' as const,
chatId: '',
createdAt: new Date().toISOString(),
}]);
setFetchResponseError(null);
} catch (err) {
setFetchResponseError(`Failed to simulate user response: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setLoadingUserResponse(false);
console.log('🔄 got assistant response done', event.data);
const parsed: {state: unknown} = JSON.parse(event.data);
setAgenticState(parsed.state);
setMessages([...messages, ...msgs]);
setLoadingAssistantResponse(false);
});
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
if (!ignore) {
setLoadingAssistantResponse(false);
setFetchResponseError('Stream connection failed');
setOptimisticMessages(messages);
}
};
}
// if last message is not a user message, return
if (messages.length > 0) {
const last = messages[messages.length - 1];
if (last.role !== 'user') {
return;
}
}
// proceed only if chat is simulated
if (!chat.simulated) {
return;
}
// dont proceed if simulation is complete
if (chat.simulated && simulationComplete) {
return;
}
// check if there are no messages yet OR
// check if the last message is an assistant
// message containing a text response. If so,
// call the simulate user turn api to fetch
// user response
let last = messages[messages.length - 1];
if (last && last.role !== 'assistant') {
return;
}
if (last && 'tool_calls' in last) {
// if there is an error, return
if (fetchResponseError) {
return;
}
@ -230,37 +207,22 @@ export function Chat({
return () => {
ignore = true;
console.log('stream useEffect cleanup called');
if (eventSource) {
eventSource.close();
}
};
}, [chat.simulated, messages, projectId, simulationComplete, chat.simulationScenario]);
// save chat on every assistant message
// useEffect(() => {
// let ignore = false;
// function process() {
// savePlaygroundChat(projectId, {
// ...chat,
// messages,
// simulationComplete,
// agenticState,
// }, chatId)
// .then((insertedChatId) => {
// if (!chatId) {
// setChatId(insertedChatId);
// }
// });
// }
// if (messages.length === 0) {
// return;
// }
// const lastMessage = messages[messages.length - 1];
// if (lastMessage && lastMessage.role !== 'assistant') {
// return;
// }
// process();
// }, [chatId, chat, messages, projectId, simulationComplete, agenticState]);
}, [
messages,
projectId,
agenticState,
workflow,
systemMessage,
mcpServerUrls,
toolWebhookUrl,
testProfile,
fetchResponseError,
]);
const handleCopyChat = () => {
const jsonString = JSON.stringify({
@ -274,10 +236,6 @@ export function Chat({
navigator.clipboard.writeText(jsonString);
}
function handleSystemMessageChange(message: string) {
setSystemMessage(message);
}
return <div className="relative h-full flex flex-col gap-8 pt-8 overflow-auto">
<CopyAsJsonButton onCopy={handleCopyChat} />
<div className="absolute top-0 left-0 flex items-center gap-1">
@ -303,15 +261,13 @@ export function Chat({
/>
<Messages
projectId={projectId}
messages={messages}
systemMessage={systemMessage}
messages={optimisticMessages}
toolCallResults={toolCallResults}
handleToolCallResults={handleToolCallResults}
loadingAssistantResponse={loadingAssistantResponse}
loadingUserResponse={loadingUserResponse}
workflow={workflow}
testProfile={testProfile}
onSystemMessageChange={handleSystemMessageChange}
systemMessage={systemMessage}
onSystemMessageChange={onSystemMessageChange}
/>
<div className="shrink-0">
{fetchResponseError && (
@ -328,26 +284,12 @@ export function Chat({
</Button>
</div>
)}
{!chat.simulated && <div className="max-w-[768px] mx-auto">
<div className="max-w-[768px] mx-auto">
<ComposeBox
handleUserMessage={handleUserMessage}
messages={messages}
/>
</div>}
{chat.simulated && !simulationComplete && <div className="p-2 bg-gray-50 border border-gray-200 flex items-center justify-center gap-2">
<Spinner size="sm" />
<div className="text-sm text-gray-500 animate-pulse">Simulating...</div>
<Button
size="sm"
color="danger"
onPress={() => {
setSimulationComplete(true);
}}
>
Stop
</Button>
</div>}
{chat.simulated && simulationComplete && <p className="text-center text-sm">Simulation complete.</p>}
</div>
</div>
</div>;
}

View file

@ -1,17 +1,13 @@
'use client';
import { Button, Spinner, Textarea } from "@heroui/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Spinner } from "@heroui/react";
import { useEffect, useMemo, useRef, useState } from "react";
import z from "zod";
import { Workflow } from "../../../lib/types/workflow_types";
import { WorkflowTool } from "../../../lib/types/workflow_types";
import { WebpageCrawlResponse } from "../../../lib/types/tool_types";
import { GetInformationToolResult } from "../../../lib/types/tool_types";
import { executeClientTool, getInformationTool, scrapeWebpage, suggestToolResponse } from "../../../actions/actions";
import MarkdownContent from "../../../lib/components/markdown-content";
import Link from "next/link";
import { apiV1 } from "rowboat-shared";
import { EditableField } from "../../../lib/components/editable-field";
import { MessageSquareIcon, EllipsisIcon, CircleCheckIcon, ChevronsDownIcon, ChevronsRightIcon, ChevronRightIcon, ChevronDownIcon, ExternalLinkIcon, XIcon } from "lucide-react";
import { MessageSquareIcon, EllipsisIcon, CircleCheckIcon, ChevronRightIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { TestProfile } from "@/app/lib/types/testing_types";
function UserMessage({ content }: { content: string }) {
@ -56,9 +52,9 @@ function AssistantMessage({ content, sender, latency }: { content: string, sende
<div className="text-gray-500 dark:text-gray-400 text-xs pl-3">
{sender ?? 'Assistant'}
</div>
<div className="text-gray-400 dark:text-gray-500 text-xs pr-3">
{latency > 0 && <div className="text-gray-400 dark:text-gray-500 text-xs pr-3">
{Math.round(latency / 1000)}s
</div>
</div>}
</div>
<div className="bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-lg rounded-bl-none text-sm text-gray-900 dark:text-gray-100">
<MarkdownContent content={content} />
@ -75,31 +71,18 @@ function AssistantMessageLoading() {
</div>;
}
function UserMessageLoading() {
return <div className="self-end ml-[30%] flex flex-col">
<div className="text-right text-gray-500 dark:text-gray-400 text-sm mr-3">
User
</div>
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg rounded-br-none animate-pulse w-20 text-gray-800 dark:text-gray-200">
<Spinner size="sm" />
</div>
</div>;
}
function ToolCalls({
toolCalls,
results,
handleResults,
projectId,
messages,
sender,
workflow,
testProfile=null,
testProfile = null,
systemMessage,
}: {
toolCalls: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'];
results: Record<string, z.infer<typeof apiV1.ToolMessage>>;
handleResults: (results: z.infer<typeof apiV1.ToolMessage>[]) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
@ -107,29 +90,14 @@ function ToolCalls({
testProfile: z.infer<typeof TestProfile> | null;
systemMessage: string | undefined;
}) {
const resultsMap: Record<string, z.infer<typeof apiV1.ToolMessage>> = {};
function handleToolCallResult(result: z.infer<typeof apiV1.ToolMessage>) {
resultsMap[result.tool_call_id] = result;
if (Object.keys(resultsMap).length === toolCalls.length) {
const results = Object.values(resultsMap);
handleResults(results);
}
}
return <div className="flex flex-col gap-4">
{toolCalls.map(toolCall => {
return <ToolCall
key={toolCall.id}
toolCall={toolCall}
result={results[toolCall.id]}
handleResult={handleToolCallResult}
projectId={projectId}
messages={messages}
sender={sender}
workflow={workflow}
testProfile={testProfile}
systemMessage={systemMessage}
/>
})}
</div>;
@ -138,23 +106,13 @@ function ToolCalls({
function ToolCall({
toolCall,
result,
handleResult,
projectId,
messages,
sender,
workflow,
testProfile=null,
systemMessage,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile> | null;
systemMessage: string | undefined;
}) {
let matchingWorkflowTool: z.infer<typeof WorkflowTool> | undefined;
for (const tool of workflow.tools) {
@ -164,171 +122,24 @@ function ToolCall({
}
}
switch (toolCall.function.name) {
case 'getArticleInfo':
return <GetInformationToolCall
toolCall={toolCall}
result={result}
handleResult={handleResult}
projectId={projectId}
messages={messages}
sender={sender}
workflow={workflow}
/>;
default:
if (toolCall.function.name.startsWith('transfer_to_')) {
return <TransferToAgentToolCall
toolCall={toolCall}
result={result}
handleResult={handleResult}
projectId={projectId}
messages={messages}
sender={sender}
/>;
}
if (!matchingWorkflowTool ||
matchingWorkflowTool.mockTool ||
(testProfile && testProfile.mockTools)) {
return <MockToolCall
toolCall={toolCall}
result={result}
handleResult={handleResult}
projectId={projectId}
messages={messages}
sender={sender}
testProfile={testProfile}
workflowTool={matchingWorkflowTool}
systemMessage={systemMessage}
/>;
}
return <ClientToolCall
toolCall={toolCall}
result={result}
handleResult={handleResult}
projectId={projectId}
messages={messages}
sender={sender}
/>;
if (toolCall.function.name.startsWith('transfer_to_')) {
return <TransferToAgentToolCall
result={result}
sender={sender}
/>;
}
}
function ToolCallHeader({
toolCall,
result,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
}) {
return <div className="flex flex-col gap-1">
<div className='shrink-0 flex gap-2 items-center'>
{!result && <Spinner size="sm" />}
{result && <CircleCheckIcon size={16} />}
<div className='font-semibold text-sm'>
Function Call: <code className='bg-gray-100 dark:bg-neutral-800 px-2 py-0.5 rounded font-mono'>{toolCall.function.name}</code>
</div>
</div>
</div>;
}
function GetInformationToolCall({
toolCall,
result: availableResult,
handleResult,
projectId,
messages,
sender,
workflow,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
}) {
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
const args = JSON.parse(toolCall.function.arguments) as { question: string };
let typedResult: z.infer<typeof GetInformationToolResult> | undefined;
if (result) {
typedResult = JSON.parse(result.content) as z.infer<typeof GetInformationToolResult>;
}
useEffect(() => {
if (result) {
return;
}
let ignore = false;
async function process() {
const result: z.infer<typeof apiV1.ToolMessage> = {
role: 'tool',
tool_call_id: toolCall.id,
tool_name: toolCall.function.name,
content: '',
};
// find target agent
const agent = workflow.agents.find(agent => agent.name == sender);
if (!agent || !agent.ragDataSources) {
result.content = JSON.stringify({
results: [],
});
} else {
const matches = await getInformationTool(projectId, args.question, agent.ragDataSources, agent.ragReturnType, agent.ragK);
if (ignore) {
return;
}
result.content = JSON.stringify(matches);
}
setResult(result);
handleResult(result);
}
process();
return () => {
ignore = true;
};
}, [result, toolCall.id, toolCall.function.name, projectId, args.question, workflow.agents, sender, handleResult]);
return <div className="flex flex-col gap-1">
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
<div className='border border-gray-300 p-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
<ToolCallHeader toolCall={toolCall} result={result} />
<div className='mt-1'>
{result ? 'Fetched' : 'Fetch'} information for question: <span className='font-mono font-semibold'>{args['question']}</span>
{result && <div className='flex flex-col gap-2 mt-2 pt-2 border-t border-t-gray-200'>
{typedResult && typedResult.results.length === 0 && <div>No matches found.</div>}
{typedResult && typedResult.results.length > 0 && <ul className="list-disc ml-6">
{typedResult.results.map((result, index) => {
return <li key={'' + index} className="mb-2">
<ExpandableContent
label={result.title || result.name}
content={result.content}
expanded={false}
/>
</li>
})}
</ul>}
</div>}
</div>
</div>
</div>;
return <ClientToolCall
toolCall={toolCall}
result={result}
sender={sender}
/>;
}
function TransferToAgentToolCall({
toolCall,
result: availableResult,
handleResult,
projectId,
messages,
sender,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
}) {
const typedResult = availableResult ? JSON.parse(availableResult.content) as { assistant: string } : undefined;
@ -348,203 +159,29 @@ function TransferToAgentToolCall({
function ClientToolCall({
toolCall,
result: availableResult,
handleResult,
projectId,
messages,
sender,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
}) {
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
useEffect(() => {
if (result) {
return;
}
let ignore = false;
async function process() {
let response;
try {
response = await executeClientTool(
toolCall,
messages,
projectId,
);
} catch (e) {
response = {
error: (e as Error).message,
};
}
if (ignore) {
return;
}
const result: z.infer<typeof apiV1.ToolMessage> = {
role: 'tool',
tool_call_id: toolCall.id,
tool_name: toolCall.function.name,
content: JSON.stringify(response),
};
setResult(result);
handleResult(result);
}
process();
return () => {
ignore = true;
};
}, [result, toolCall, projectId, messages, handleResult]);
return <div className="flex flex-col gap-1">
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
<div className='border border-gray-300 p-2 pt-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
<ToolCallHeader toolCall={toolCall} result={result} />
<div className='flex flex-col gap-2'>
<ExpandableContent label='Params' content={toolCall.function.arguments} expanded={Boolean(!result)} />
{result && <ExpandableContent label='Result' content={result.content} expanded={true} />}
</div>
</div>
</div>;
}
function MockToolCall({
toolCall,
result: availableResult,
handleResult,
projectId,
messages,
sender,
testProfile=null,
workflowTool,
systemMessage,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
testProfile: z.infer<typeof TestProfile> | null;
workflowTool: z.infer<typeof WorkflowTool> | undefined;
systemMessage: string | undefined;
}) {
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
const [response, setResponse] = useState('');
const [generatingResponse, setGeneratingResponse] = useState(false);
const handleSubmit = useCallback(() => {
let parsed;
try {
parsed = JSON.parse(response);
} catch (e) {
alert('Invalid JSON');
return;
}
const result: z.infer<typeof apiV1.ToolMessage> = {
role: 'tool',
tool_call_id: toolCall.id,
tool_name: toolCall.function.name,
content: JSON.stringify(parsed),
};
setResult(result);
handleResult(result);
}, [toolCall.id, toolCall.function.name, handleResult, response]);
useEffect(() => {
if (result) {
return;
}
if (response) {
return;
}
let ignore = false;
async function process() {
setGeneratingResponse(true);
const response = await suggestToolResponse(
toolCall.id,
projectId,
[{
role: 'system',
content: systemMessage || '',
createdAt: new Date().toISOString(),
version: 'v1',
chatId: '',
}, ...messages],
testProfile?.mockPrompt || workflowTool?.mockInstructions || '',
);
if (ignore) {
return;
}
setResponse(response);
setGeneratingResponse(false);
}
process();
return () => {
ignore = true;
};
}, [result, response, toolCall.id, projectId, messages, testProfile, systemMessage, workflowTool?.mockInstructions]);
// auto submit if autoSubmitMockedResponse is true
useEffect(() => {
if (!workflowTool?.autoSubmitMockedResponse) {
return;
}
if (result) {
return;
}
if (response) {
handleSubmit();
}
}, [workflowTool?.autoSubmitMockedResponse, response, handleSubmit, result]);
return <div className="flex flex-col gap-1">
{sender && <div className='text-gray-500 dark:text-gray-400 text-xs ml-3'>{sender}</div>}
<div className='border border-gray-300 dark:border-gray-700 p-2 pt-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%] bg-white dark:bg-gray-900'>
<div className="flex items-center gap-2">
{!result && <Spinner size="sm" />}
{result && <CircleCheckIcon size={16} className="text-gray-500 dark:text-gray-400" />}
<span className="text-sm text-gray-700 dark:text-gray-300">
Function Call: <code className='bg-gray-100 dark:bg-neutral-800 px-2 py-0.5 rounded font-mono'>{toolCall.function.name}</code>
</span>
<div className="flex flex-col gap-1">
<div className='shrink-0 flex gap-2 items-center'>
{!availableResult && <Spinner size="sm" />}
{availableResult && <CircleCheckIcon size={16} />}
<div className='font-semibold text-sm'>
Function Call: <code className='bg-gray-100 dark:bg-neutral-800 px-2 py-0.5 rounded font-mono'>{toolCall.function.name}</code>
</div>
</div>
</div>
<div className='flex flex-col gap-2'>
<ExpandableContent label='Params' content={toolCall.function.arguments} expanded={false} />
{result && <ExpandableContent label='Result' content={result.content} expanded={false} />}
{availableResult && <ExpandableContent label='Result' content={availableResult.content} expanded={false} />}
</div>
{!result && !workflowTool?.autoSubmitMockedResponse && <div className='flex flex-col gap-2 mt-2'>
<div>Response:</div>
<Textarea
maxRows={10}
placeholder='{}'
variant="bordered"
value={response}
disabled={generatingResponse}
onValueChange={(value) => setResponse(value)}
className='font-mono'
size="sm"
>
</Textarea>
<Button
onPress={handleSubmit}
disabled={generatingResponse}
isLoading={generatingResponse}
size="sm"
>
Submit result
</Button>
</div>}
</div>
</div>;
}
@ -594,65 +231,61 @@ function ExpandableContent({
function SystemMessage({
content,
onChange,
locked
locked = false,
}: {
content: string,
onChange: (content: string) => void,
locked: boolean
locked?: boolean,
}) {
return (
<div className="border border-gray-300 dark:border-gray-700 p-2 rounded-lg flex flex-col gap-2 bg-white dark:bg-gray-900">
<div className="text-sm text-gray-500 dark:text-gray-400 font-medium">CONTEXT</div>
<EditableField
light
value={content}
onChange={onChange}
multiline
markdown
locked={locked}
placeholder={`Provide context about the user (e.g. user ID, user name) to the assistant at the start of chat, for testing purposes.`}
/>
</div>
);
return <div className="text-sm">
<EditableField
label="Context"
value={content}
onChange={onChange}
locked={locked}
multiline
markdown
placeholder={`Provide context about the user (e.g. user ID, user name) to the assistant at the start of chat, for testing purposes.`}
showSaveButton={true}
showDiscardButton={true}
/>
</div>;
}
export function Messages({
projectId,
systemMessage,
messages,
toolCallResults,
handleToolCallResults,
loadingAssistantResponse,
loadingUserResponse,
workflow,
testProfile=null,
testProfile = null,
systemMessage,
onSystemMessageChange,
}: {
projectId: string;
systemMessage: string | undefined;
messages: z.infer<typeof apiV1.ChatMessage>[];
toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>>;
handleToolCallResults: (results: z.infer<typeof apiV1.ToolMessage>[]) => void;
loadingAssistantResponse: boolean;
loadingUserResponse: boolean;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile> | null;
systemMessage: string | undefined;
onSystemMessageChange: (message: string) => void;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
let lastUserMessageTimestamp = 0;
let userMessageSeen = false;
// scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages, loadingAssistantResponse, loadingUserResponse]);
}, [messages, loadingAssistantResponse]);
return <div className="grow pt-4 overflow-auto">
<div className="max-w-[768px] mx-auto flex flex-col gap-8">
<SystemMessage
content={testProfile?.context || systemMessage || ''}
onChange={onSystemMessageChange}
locked={testProfile !== null || messages.length > 0}
locked={testProfile !== null}
/>
{messages.map((message, index) => {
if (message.role === 'assistant') {
@ -661,7 +294,6 @@ export function Messages({
key={index}
toolCalls={message.tool_calls}
results={toolCallResults}
handleResults={handleToolCallResults}
projectId={projectId}
messages={messages}
sender={message.agenticSender}
@ -671,7 +303,11 @@ export function Messages({
/>;
} else {
// the assistant message createdAt is an ISO string timestamp
const latency = new Date(message.createdAt).getTime() - lastUserMessageTimestamp;
let latency = new Date(message.createdAt).getTime() - lastUserMessageTimestamp;
// if this is the first message, set the latency to 0
if (!userMessageSeen) {
latency = 0;
}
if (message.agenticResponseType === 'internal') {
return (
<InternalAssistantMessage
@ -695,12 +331,12 @@ export function Messages({
}
if (message.role === 'user' && typeof message.content === 'string') {
lastUserMessageTimestamp = new Date(message.createdAt).getTime();
userMessageSeen = true;
return <UserMessage key={index} content={message.content} />;
}
return <></>;
})}
{loadingAssistantResponse && <AssistantMessageLoading key="assistant-loading" />}
{loadingUserResponse && <UserMessageLoading key="user-loading" />}
<div ref={messagesEndRef} />
</div>
</div>;

View file

@ -13,6 +13,7 @@ import { TableLabel, TableValue } from "./shared";
import { ScrapeSource } from "./scrape-source";
import { FilesSource } from "./files-source";
import { getDataSource } from "../../../../actions/datasource_actions";
import { TextSource } from "./text-source";
export function SourcePage({
sourceId,
@ -118,6 +119,10 @@ export function SourcePage({
<DataSourceIcon type="files" />
<div>File upload</div>
</div>}
{source.data.type === 'text' && <div className="flex gap-1 items-center">
<DataSourceIcon type="text" />
<div>Text</div>
</div>}
</TableValue>
</tr>
<tr>
@ -131,6 +136,7 @@ export function SourcePage({
</PageSection>
{source.data.type === 'urls' && <ScrapeSource projectId={projectId} dataSource={source} handleReload={handleReload} />}
{source.data.type === 'files' && <FilesSource projectId={projectId} dataSource={source} handleReload={handleReload} />}
{source.data.type === 'text' && <TextSource projectId={projectId} dataSource={source} handleReload={handleReload} />}
<PageSection title="Danger zone">
<div className="flex flex-col gap-2 items-start">

View file

@ -0,0 +1,128 @@
"use client";
import { PageSection } from "../../../../lib/components/page-section";
import { WithStringId } from "../../../../lib/types/types";
import { DataSource } from "../../../../lib/types/datasource_types";
import { z } from "zod";
import { useState, useEffect } from "react";
import { Textarea } from "@heroui/react";
import { FormStatusButton } from "../../../../lib/components/form-status-button";
import { Spinner } from "@heroui/react";
import { addDocsToDataSource, deleteDocsFromDataSource, listDocsInDataSource } from "../../../../actions/datasource_actions";
export function TextSource({
projectId,
dataSource,
handleReload,
}: {
projectId: string,
dataSource: WithStringId<z.infer<typeof DataSource>>,
handleReload: () => void;
}) {
const [content, setContent] = useState("");
const [docId, setDocId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
let ignore = false;
async function fetchContent() {
setIsLoading(true);
try {
const { files } = await listDocsInDataSource({
projectId,
sourceId: dataSource._id,
limit: 1,
});
console.log('got data', files);
if (!ignore && files.length > 0) {
const doc = files[0];
if (doc.data.type === 'text') {
setContent(doc.data.content);
setDocId(doc._id);
}
}
} catch (error) {
console.error('Error fetching content:', error);
} finally {
setIsLoading(false);
}
}
fetchContent();
return () => {
ignore = true;
};
}, [projectId, dataSource._id]);
async function handleSubmit(formData: FormData) {
setIsSaving(true);
try {
const newContent = formData.get('content') as string;
// Delete existing doc if it exists
if (docId) {
await deleteDocsFromDataSource({
projectId,
sourceId: dataSource._id,
docIds: [docId],
});
}
// Add new doc
await addDocsToDataSource({
projectId,
sourceId: dataSource._id,
docData: [{
name: 'text',
data: {
type: 'text',
content: newContent,
},
}],
});
handleReload();
} finally {
setIsSaving(false);
}
}
if (isLoading) {
return (
<PageSection title="Content">
<div className="flex items-center justify-center gap-2">
<Spinner size="sm" />
<p>Loading content...</p>
</div>
</PageSection>
);
}
return (
<PageSection title="Content">
<form action={handleSubmit} className="flex flex-col gap-4">
<Textarea
name="content"
label="Text content"
labelPlacement="outside"
value={content}
onValueChange={setContent}
minRows={10}
maxRows={20}
variant="bordered"
/>
<FormStatusButton
props={{
type: "submit",
children: "Update content",
className: "self-start",
isLoading: isSaving,
}}
/>
</form>
</PageSection>
);
}

View file

@ -60,6 +60,32 @@ export function Form({
router.push(`/projects/${projectId}/sources/${source._id}`);
}
async function createTextDataSource(formData: FormData) {
const source = await createDataSource({
projectId,
name: formData.get('name') as string,
data: {
type: 'text',
},
status: 'pending',
});
const content = formData.get('content') as string;
await addDocsToDataSource({
projectId,
sourceId: source._id,
docData: [{
name: 'text',
data: {
type: 'text',
content,
},
}],
});
router.push(`/projects/${projectId}/sources/${source._id}`);
}
function handleSourceTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
setSourceType(event.target.value);
}
@ -75,6 +101,12 @@ export function Form({
...(useRagScraping ? [] : ['urls']),
]}
>
<SelectItem
key="text"
startContent={<DataSourceIcon type="text" />}
>
Text
</SelectItem>
<SelectItem
key="urls"
startContent={<DataSourceIcon type="urls" />}
@ -87,7 +119,7 @@ export function Form({
>
Upload files
</SelectItem>
</Select>
</Select>
{sourceType === "urls" && <form
action={createUrlsDataSource}
@ -159,6 +191,39 @@ export function Form({
}}
/>
</form>}
{sourceType === "text" && <form
action={createTextDataSource}
className="flex flex-col gap-4"
>
<Textarea
required
type="text"
name="content"
label="Text content"
labelPlacement="outside"
minRows={10}
maxRows={30}
/>
<div className="self-start">
<Input
required
type="text"
name="name"
labelPlacement="outside"
placeholder="e.g. Product documentation"
variant="bordered"
/>
</div>
<FormStatusButton
props={{
type: "submit",
children: "Add data source",
className: "self-start",
startContent: <PlusIcon className="w-[24px] h-[24px]" />
}}
/>
</form>}
</div>
</div>;
}

View file

@ -86,6 +86,14 @@ export function SourcesList({
<DataSourceIcon type="urls" />
<div>List URLs</div>
</div>}
{source.data.type == 'text' && <div className="flex gap-1 items-center">
<DataSourceIcon type="text" />
<div>Text</div>
</div>}
{source.data.type == 'files' && <div className="flex gap-1 items-center">
<DataSourceIcon type="files" />
<div>Files</div>
</div>}
</td>
<td className="py-4">
<SelfUpdatingSourceStatus sourceId={source._id} projectId={projectId} initialStatus={source.status} compact={true} />

View file

@ -6,6 +6,8 @@ import { ProfilesApp } from "./profiles_app";
import { SimulationsApp } from "./simulations_app";
import { usePathname } from "next/navigation";
import { RunsApp } from "./runs_app";
import { StructuredPanel } from "../../../../lib/components/structured-panel";
import { ListItem } from "../../../../lib/components/structured-list";
export function App({
projectId,
@ -43,18 +45,19 @@ export function App({
];
return <div className="flex h-full">
<div className="w-40 shrink-0 p-2">
<ul>
<StructuredPanel title="TEST" tooltip="Browse and manage your test scenarios and runs">
<div className="overflow-auto flex flex-col gap-1 justify-start">
{menuItems.map((item) => (
<li key={item.label}>
<Link
className={`block p-2 rounded-md text-sm ${pathname.startsWith(item.href) ? "bg-gray-100" : "hover:bg-gray-100"}`}
href={item.href}>{item.label}</Link>
</li>
<ListItem
key={item.label}
name={item.label}
isSelected={pathname.startsWith(item.href)}
onClick={() => router.push(item.href)}
/>
))}
</ul>
</div>
<div className="grow border-l border-gray-200 p-2">
</div>
</StructuredPanel>
<div className="grow border-l border-gray-200 dark:border-neutral-800 p-2">
{selection === "scenarios" && <ScenariosApp projectId={projectId} slug={innerSlug} />}
{selection === "profiles" && <ProfilesApp projectId={projectId} slug={innerSlug} />}
{selection === "simulations" && <SimulationsApp projectId={projectId} slug={innerSlug} />}

View file

@ -0,0 +1,38 @@
// First, let's create a reusable component for item views
export function ItemView({
items,
actions
}: {
items: { label: string; value: string | React.ReactNode }[];
actions: React.ReactNode;
}) {
return (
<div className="max-w-3xl">
{/* Content */}
<div className="bg-white dark:bg-neutral-950 rounded-lg border border-gray-200 dark:border-neutral-800 overflow-hidden">
<div className="divide-y divide-gray-100 dark:divide-neutral-800">
{items.map((item, index) => (
<div
key={index}
className="px-6 py-4 flex flex-col gap-1"
>
<dt className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-neutral-400">
{item.label}
</dt>
<dd className="text-sm text-gray-900 dark:text-white">
{item.value || "—"}
</dd>
</div>
))}
</div>
{/* Actions */}
<div className="px-6 py-4 bg-gray-50 dark:bg-neutral-900 border-t border-gray-200 dark:border-neutral-800">
<div className="flex gap-2">
{actions}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,90 @@
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { Button, Input, Textarea, Switch } from "@heroui/react"
import { useRef, useState } from "react";
interface ProfileFormProps {
defaultValues?: {
name?: string;
context?: string;
mockTools?: boolean;
mockPrompt?: string;
};
formRef: React.RefObject<HTMLFormElement>;
handleSubmit: (formData: FormData) => Promise<void>;
onCancel: () => void;
submitButtonText: string;
}
export function ProfileForm({
defaultValues = {},
formRef,
handleSubmit,
onCancel,
submitButtonText,
}: ProfileFormProps) {
const [mockTools, setMockTools] = useState(Boolean(defaultValues.mockTools));
const [showMockPrompt, setShowMockPrompt] = useState(Boolean(defaultValues.mockTools));
return (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
<Input
label="Name"
name="name"
placeholder="Provide a name to describe the user's profile to simulate, e.g. &quot;Frequent buyer&quot;"
defaultValue={defaultValues.name}
isRequired
/>
<Textarea
label="Context"
name="context"
placeholder="Provide user info and other info to simulate, e.g. &quot;User's name: John Smith. Buying frequency: 10 orders a month. Location: US. Latest order: Pair of Jeans - XL.&quot;"
defaultValue={defaultValues.context}
isRequired
/>
<Switch
isSelected={mockTools}
onValueChange={(checked) => {
setMockTools(checked);
setShowMockPrompt(checked);
}}
name="mockTools"
value="on"
>
Mock Tools
</Switch>
{showMockPrompt && (
<div className="rounded-lg border border-gray-200 dark:border-neutral-800 p-4">
<div className="text-sm font-medium mb-2">Mock Prompt (Optional)</div>
<Textarea
name="mockPrompt"
placeholder="Enter a mock prompt"
defaultValue={defaultValues.mockPrompt}
/>
</div>
)}
<div className="flex gap-3">
<FormStatusButton
props={{
children: submitButtonText,
size: "md",
color: "primary",
type: "submit",
className: "font-medium"
}}
/>
<Button
size="md"
variant="flat"
onPress={onCancel}
className="font-medium"
>
Cancel
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,68 @@
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { Button, Input, Textarea } from "@heroui/react";
interface ScenarioFormProps {
formRef: React.RefObject<HTMLFormElement>;
handleSubmit: (formData: FormData) => Promise<void>;
onCancel: () => void;
submitButtonText: string;
defaultValues?: {
name?: string;
description?: string;
};
}
export function ScenarioForm({
formRef,
handleSubmit,
onCancel,
submitButtonText,
defaultValues = {},
}: ScenarioFormProps) {
return (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
<Input
type="text"
name="name"
label="Name"
placeholder="Provide a name for this scenario, e.g. &quot;Order cancellation&quot;"
defaultValue={defaultValues.name}
isRequired
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
<Textarea
name="description"
label="Description"
placeholder="Describe the scenario that should be simulated, e.g. &quot;Role play a user who wants to cancel their recently ordered pair of jeans.&quot;"
defaultValue={defaultValues.description}
isRequired
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
<div className="flex gap-3">
<FormStatusButton
props={{
children: submitButtonText,
size: "md",
color: "primary",
type: "submit",
className: "font-medium"
}}
/>
<Button
size="md"
variant="flat"
onPress={onCancel}
className="font-medium"
>
Cancel
</Button>
</div>
</form>
);
}

View file

@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { listProfiles } from "@/app/actions/testing_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { z } from "zod";
import { useRouter } from "next/navigation";
interface ProfileSelectorProps {
projectId: string;
@ -19,6 +20,7 @@ export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect }: P
const [profiles, setProfiles] = useState<WithStringId<z.infer<typeof TestProfile>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const pageSize = 10;
const router = useRouter();
const fetchProfiles = useCallback(async (page: number) => {
setLoading(true);
@ -58,10 +60,10 @@ export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect }: P
{!loading && !error && <>
{profiles.length === 0 && <div className="text-gray-600 text-center">No profiles found</div>}
{profiles.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-6 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-2 px-4">Name</div>
<div className="col-span-3 px-4">Context</div>
<div className="col-span-1 px-4">Mock Tools</div>
<div className="grid grid-cols-6 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-sm">
<div className="col-span-2 px-4 text-gray-900 dark:text-gray-100">Name</div>
<div className="col-span-3 px-4 text-gray-900 dark:text-gray-100">Context</div>
<div className="col-span-1 px-4 text-gray-900 dark:text-gray-100">Mock Tools</div>
</div>
{profiles.map((p) => (
@ -88,9 +90,18 @@ export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect }: P
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<div className="flex gap-2">
<Button
size="sm"
color="primary"
onPress={() => router.push(`/projects/${projectId}/test/profiles`)}
>
Manage Profiles
</Button>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
</div>
</ModalFooter>
</>
)}

View file

@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { listScenarios } from "@/app/actions/testing_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { z } from "zod";
import { useRouter } from "next/navigation";
interface ScenarioSelectorProps {
projectId: string;
@ -13,6 +14,7 @@ interface ScenarioSelectorProps {
}
export function ScenarioSelector({ projectId, isOpen, onOpenChange, onSelect }: ScenarioSelectorProps) {
const router = useRouter();
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -58,9 +60,9 @@ export function ScenarioSelector({ projectId, isOpen, onOpenChange, onSelect }:
{!loading && !error && <>
{scenarios.length === 0 && <div className="text-gray-600 text-center">No scenarios found</div>}
{scenarios.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-5 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-2 px-4">Name</div>
<div className="col-span-3 px-4">Description</div>
<div className="grid grid-cols-5 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-sm">
<div className="col-span-2 px-4 text-gray-900 dark:text-gray-100">Name</div>
<div className="col-span-3 px-4 text-gray-900 dark:text-gray-100">Description</div>
</div>
{scenarios.map((s) => (
@ -86,9 +88,18 @@ export function ScenarioSelector({ projectId, isOpen, onOpenChange, onSelect }:
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<div className="flex gap-2">
<Button
size="sm"
color="primary"
onPress={() => router.push(`/projects/${projectId}/test/scenarios`)}
>
Manage Scenarios
</Button>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
</div>
</ModalFooter>
</>
)}

View file

@ -5,7 +5,7 @@ import { listWorkflows } from "@/app/actions/workflow_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
import { WorkflowIcon } from "../icons";
import { WorkflowIcon } from "../../../../../../lib/components/icons";
import { PublishedBadge } from "@/app/projects/[projectId]/workflow/published_badge";
interface WorkflowSelectorProps {

View file

@ -0,0 +1,190 @@
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { Button, Input, Textarea } from "@heroui/react";
import { TestProfile, TestScenario } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";
import { ScenarioSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/scenario-selector";
import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/profile-selector";
import { z } from "zod";
interface SimulationFormProps {
formRef: React.RefObject<HTMLFormElement>;
handleSubmit: (formData: FormData) => Promise<void>;
scenario: WithStringId<z.infer<typeof TestScenario>> | null;
setScenario: (scenario: WithStringId<z.infer<typeof TestScenario>> | null) => void;
profile: WithStringId<z.infer<typeof TestProfile>> | null;
setProfile: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
isScenarioModalOpen: boolean;
setIsScenarioModalOpen: (isOpen: boolean) => void;
isProfileModalOpen: boolean;
setIsProfileModalOpen: (isOpen: boolean) => void;
projectId: string;
submitButtonText: string;
defaultValues?: {
name?: string;
description?: string;
passCriteria?: string;
};
onCancel: () => void;
}
export function SimulationForm({
formRef,
handleSubmit,
scenario,
setScenario,
profile,
setProfile,
isScenarioModalOpen,
setIsScenarioModalOpen,
isProfileModalOpen,
setIsProfileModalOpen,
projectId,
submitButtonText,
defaultValues = {},
onCancel,
}: SimulationFormProps) {
return (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
{/* Basic Information */}
<div className="flex flex-col gap-4 p-4 bg-white dark:bg-neutral-900 rounded-lg border border-gray-200 dark:border-neutral-800">
<h2 className="text-sm font-medium">Basic Information</h2>
<Input
type="text"
name="name"
label={<span>Name</span>}
placeholder="Enter a name for the simulation, e.g. &quot;Frequent buyer cancelling order&quot;"
defaultValue={defaultValues.name}
isRequired
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
<Textarea
name="description"
label={<span>Description</span>}
placeholder="Enter an optional description for the simulation, just to help you remember what it's for"
defaultValue={defaultValues.description}
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
</div>
{/* Test Configuration */}
<div className="flex flex-col gap-6 p-6 bg-white dark:bg-neutral-900 rounded-lg border border-gray-200 dark:border-neutral-800">
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Test Configuration</h2>
<div className="flex flex-col gap-6">
{/* Scenario Selection */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Scenario <span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-1.5 min-h-[2rem]">
<div className="flex-1 text-sm text-gray-600 dark:text-neutral-400">
{scenario ? (
<span className="text-blue-600 dark:text-blue-400">{scenario.name}</span>
) : (
<span className="text-red-500">No scenario selected</span>
)}
</div>
<Button
size="sm"
onPress={() => setIsScenarioModalOpen(true)}
type="button"
>
{scenario ? "Change" : "Select"} Scenario
</Button>
</div>
</div>
{/* Profile Selection */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Profile <span className="text-gray-500 dark:text-neutral-400">(optional)</span>
</label>
<div className="flex items-center gap-1.5 min-h-[2rem]">
<div className="flex-1 text-sm text-gray-600 dark:text-neutral-400">
{profile ? (
<span className="text-blue-600 dark:text-blue-400">{profile.name}</span>
) : (
"No profile selected"
)}
</div>
<div className="flex gap-2">
{profile && (
<Button size="sm" variant="bordered" onClick={() => setProfile(null)}>
Remove
</Button>
)}
<Button
size="sm"
onPress={() => setIsProfileModalOpen(true)}
type="button"
>
{profile ? "Change" : "Select"} Profile
</Button>
</div>
</div>
</div>
{/* Pass Criteria */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Pass Criteria <span className="text-red-500">*</span>
</label>
<Textarea
name="passCriteria"
placeholder="Define the criteria for this test to pass, e.g. &quot;The assistant should successfully cancel the user's order and provide next steps for the user to confirm the cancellation&quot;"
defaultValue={defaultValues.passCriteria}
isRequired
minRows={3}
classNames={{
base: "w-full",
input: "bg-white dark:bg-neutral-900 resize-none",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 hover:border-gray-300 dark:hover:border-neutral-700 transition-colors"
}}
/>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-3">
<FormStatusButton
props={{
children: submitButtonText,
size: "md",
color: "primary",
type: "submit",
isDisabled: !scenario,
className: "font-medium"
}}
/>
<Button
size="md"
variant="flat"
onPress={onCancel}
className="font-medium"
>
Cancel
</Button>
</div>
<ScenarioSelector
projectId={projectId}
isOpen={isScenarioModalOpen}
onOpenChange={setIsScenarioModalOpen}
onSelect={setScenario}
/>
<ProfileSelector
projectId={projectId}
isOpen={isProfileModalOpen}
onOpenChange={setIsProfileModalOpen}
onSelect={setProfile}
/>
</form>
);
}

View file

@ -0,0 +1,321 @@
import { Table, TableHeader, TableBody, TableColumn, TableRow, TableCell, Selection } from "@heroui/react";
import { Button } from "@heroui/react";
import { PencilIcon, TrashIcon, EyeIcon, DownloadIcon } from "lucide-react";
import Link from "next/link";
import { ReactNode, useState } from "react";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
// Helper function to safely parse dates
const isValidDate = (date: any): boolean => {
const parsed = new Date(date);
return parsed instanceof Date && !isNaN(parsed.getTime());
};
interface Column {
key: string;
label: string;
render?: (item: any) => ReactNode;
}
interface DataTableProps {
items: any[];
columns: Column[];
selectedKeys?: Selection;
onSelectionChange?: (keys: Selection) => void;
projectId: string;
onDelete?: (id: string) => Promise<void>;
onEdit?: (id: string) => void;
onView?: (id: string) => void;
onDownload?: (id: string) => void;
selectionMode?: "multiple" | "none";
}
export function DataTable({
items,
columns,
selectedKeys,
onSelectionChange,
projectId,
onDelete,
onEdit,
onView,
onDownload,
selectionMode = "multiple",
}: DataTableProps) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const [isDeleteAllModalOpen, setIsDeleteAllModalOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDeleteClick = (id: string) => {
setItemToDelete(id);
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (!itemToDelete || !onDelete) return;
try {
await onDelete(itemToDelete);
setIsDeleteModalOpen(false);
setItemToDelete(null);
} catch (error) {
setDeleteError(`Failed to delete: ${error}`);
}
};
const handleDeleteAll = async () => {
if (!onDelete) return;
try {
// Delete all items sequentially
for (const item of items) {
await onDelete(item._id);
}
setIsDeleteAllModalOpen(false);
// Selection will be cleared automatically when items refresh
} catch (error) {
setDeleteError(`Failed to delete items: ${error}`);
}
};
const isAllSelected = selectedKeys === "all";
const renderCells = (item: any) => {
const cells = columns.map(column => (
<TableCell key={column.key}>
{column.render ? column.render(item) :
// Handle date fields specially
(column.key.toLowerCase().includes('date') ||
column.key === 'createdAt' ||
column.key === 'lastUpdatedAt') && isValidDate(item[column.key]) ?
new Date(item[column.key]).toLocaleString() :
item[column.key]
}
</TableCell>
));
// Only add actions column if there are any actions
const hasActions = onDelete || onEdit || onView || onDownload;
if (hasActions) {
cells.push(
<TableCell key="actions">
<div className="flex items-center gap-0.5">
{onView && (
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => onView(item._id)}
aria-label="View item"
>
<EyeIcon size={16} />
</Button>
)}
{onEdit && (
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => onEdit(item._id)}
aria-label="Edit item"
>
<PencilIcon size={16} />
</Button>
)}
{onDownload && (
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => onDownload(item._id)}
aria-label="Download results"
>
<DownloadIcon size={16} />
</Button>
)}
{onDelete && (
<Button
isIconOnly
size="sm"
variant="light"
color="danger"
onPress={() => handleDeleteClick(item._id)}
aria-label="Delete item"
>
<TrashIcon size={16} />
</Button>
)}
</div>
</TableCell>
);
}
return cells;
};
return (
<>
<div className="flex flex-col gap-4">
{/* Only show Delete All button when selection is enabled and items are selected */}
{selectionMode === "multiple" && selectedKeys === "all" && items.length > 0 && (
<div className="flex justify-start">
<Button
size="sm"
color="danger"
variant="flat"
onPress={() => setIsDeleteAllModalOpen(true)}
startContent={<TrashIcon size={16} />}
>
Delete All ({items.length})
</Button>
</div>
)}
<Table
selectedKeys={selectionMode === "multiple" ? selectedKeys : undefined}
onSelectionChange={selectionMode === "multiple" ? onSelectionChange : undefined}
aria-label="Data table"
classNames={{
base: "max-h-[400px] overflow-auto",
table: "min-w-full",
}}
selectionMode={selectionMode}
>
<TableHeader columns={[
...columns.map(column => ({
key: column.key,
label: column.label
})),
...((onDelete || onEdit || onView || onDownload) ? [{
key: 'actions',
label: 'ACTIONS',
render: (item: any) => (
<div className="flex items-center gap-0.5">
<Button
isIconOnly
size="sm"
variant="light"
>
<PencilIcon size={16} />
</Button>
<Button
isIconOnly
size="sm"
variant="light"
color="danger"
>
<TrashIcon size={16} />
</Button>
</div>
),
}] : [])
]}>
{(column) => (
<TableColumn key={column.key}>{column.label}</TableColumn>
)}
</TableHeader>
<TableBody items={items}>
{(item) => (
<TableRow key={item._id}>
{renderCells(item)}
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Single Delete Confirmation Modal */}
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={(open) => {
setIsDeleteModalOpen(open);
if (!open) setItemToDelete(null);
}}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Deletion</ModalHeader>
<ModalBody>
Are you sure you want to delete this item?
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDeleteConfirm();
onClose();
}}
>
Delete
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* Delete All Confirmation Modal */}
<Modal
isOpen={isDeleteAllModalOpen}
onOpenChange={setIsDeleteAllModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Delete All</ModalHeader>
<ModalBody>
Are you sure you want to delete all {items.length} items? This action cannot be undone.
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDeleteAll();
onClose();
}}
>
Delete All
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* Error Modal */}
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Error</ModalHeader>
<ModalBody>
{deleteError}
</ModalBody>
<ModalFooter>
<Button size="sm" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
}

View file

@ -1,8 +1,43 @@
import { App } from "./app";
'use client';
export default function Page({ params }: { params: { projectId: string, slug?: string[] } }) {
return <App
projectId={params.projectId}
slug={params.slug}
/>;
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { ScenariosApp } from "./scenarios_app";
import { SimulationsApp } from "./simulations_app";
import { ProfilesApp } from "./profiles_app";
import { RunsApp } from "./runs_app";
import { TestingMenu } from "./testing_menu";
export default function TestPage({ params }: { params: { projectId: string; slug?: string[] } }) {
const { projectId, slug = [] } = params;
let app: "scenarios" | "simulations" | "profiles" | "runs" = "runs";
if (slug[0] === "scenarios") {
app = "scenarios";
} else if (slug[0] === "simulations") {
app = "simulations";
} else if (slug[0] === "profiles") {
app = "profiles";
} else if (slug[0] === "runs") {
app = "runs";
}
return (
<div className="h-full flex flex-col">
<ResizablePanelGroup direction="horizontal" className="h-full">
<ResizablePanel defaultSize={15} minSize={10}>
<div className="h-full border-r border-gray-200 dark:border-neutral-800">
<TestingMenu projectId={projectId} app={app} />
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={85}>
{app === "scenarios" && <ScenariosApp projectId={projectId} slug={slug.slice(1)} />}
{app === "simulations" && <SimulationsApp projectId={projectId} slug={slug.slice(1)} />}
{app === "profiles" && <ProfilesApp projectId={projectId} slug={slug.slice(1)} />}
{app === "runs" && <RunsApp projectId={projectId} slug={slug.slice(1)} />}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View file

@ -1,15 +1,19 @@
"use client";
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestProfile } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createProfile, getProfile, listProfiles, updateProfile, deleteProfile } from "@/app/actions/testing_actions";
import { Button, Input, Pagination, Spinner, Switch, Textarea, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Tooltip } from "@heroui/react";
import { Button, Spinner, Selection } from "@heroui/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { PlusIcon, ArrowLeftIcon, StarIcon } from "lucide-react";
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { PlusIcon } from "lucide-react";
import { RelativeTime } from "@primer/react"
import { getProjectConfig } from "@/app/actions/project_actions";
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel";
import { DataTable } from "./components/table";
import { isValidDate } from './utils/date';
import { ProfileForm } from "./components/profile-form";
function EditProfile({
projectId,
@ -46,324 +50,96 @@ function EditProfile({
try {
const name = formData.get("name") as string;
const context = formData.get("context") as string;
const mockTools = formData.get("mockTools") === "on";
const mockPrompt = formData.get("mockPrompt") as string;
await updateProfile(projectId, profileId, {
name,
context,
await updateProfile(projectId, profileId, {
name,
context,
mockTools,
mockPrompt: mockPrompt || undefined
mockPrompt: mockTools && mockPrompt ? mockPrompt : undefined
});
router.push(`/projects/${projectId}/test/profiles/${profileId}`);
router.push(`/projects/${projectId}/test/profiles`);
} catch (error) {
setError(`Unable to update profile: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">Edit Profile</h1>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => formRef.current?.requestSubmit()}>Retry</Button>
</div>}
{!loading && profile && (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the profile"
defaultValue={profile.name}
required
/>
<Textarea
name="context"
label="Context"
placeholder="Enter the context for this profile"
defaultValue={profile.context}
required
/>
<Switch
name="mockTools"
isSelected={mockTools}
onValueChange={(value) => {
setMockTools(value);
return <StructuredPanel
title="EDIT PROFILE"
tooltip="Edit an existing test profile"
>
<div className="flex flex-col gap-6 max-w-2xl">
{loading && (
<div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading profile...
</div>
)}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{!loading && profile && (
<ProfileForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/profiles`)}
submitButtonText="Update Profile"
defaultValues={{
name: profile.name,
context: profile.context,
mockTools: Boolean(profile.mockTools),
mockPrompt: profile.mockPrompt || ""
}}
className="self-start"
>
Mock Tools
</Switch>
{mockTools && <Textarea
name="mockPrompt"
label="Mock Prompt (Optional)"
placeholder="Enter a mock prompt"
defaultValue={profile.mockPrompt}
/>}
<div className="flex gap-2 items-center">
<FormStatusButton
props={{
className: "self-start",
children: "Update",
size: "sm",
type: "submit",
}}
/>
<Button
size="sm"
variant="flat"
as={Link}
href={`/projects/${projectId}/test/profiles/${profileId}`}
>
Cancel
</Button>
</div>
</form>
)}
</div>;
/>
)}
</div>
</StructuredPanel>;
}
function ViewProfile({
projectId,
profileId,
}: {
projectId: string,
profileId: string,
}) {
const router = useRouter();
const [profile, setProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
const [loading, setLoading] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
async function fetchProfile() {
const profile = await getProfile(projectId, profileId);
setProfile(profile);
setLoading(false);
}
fetchProfile();
}, [projectId, profileId]);
async function handleDelete() {
try {
await deleteProfile(projectId, profileId);
router.push(`/projects/${projectId}/test/profiles`);
} catch (error) {
setDeleteError(`Failed to delete profile: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">View Profile</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/profiles`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Profiles
</Button>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{!loading && !profile && <div className="text-gray-600 text-center">Profile not found</div>}
{!loading && profile && (
<>
<div className="flex flex-col gap-1 text-sm">
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Name</div>
<div className="flex-[2]">
<div className="flex-[2] whitespace-pre-wrap">{profile.name}</div>
</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Context</div>
<div className="flex-[2] whitespace-pre-wrap">{profile.context}</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Mock Tools</div>
<div className="flex-[2]">{profile.mockTools ? "Yes" : "No"}</div>
</div>
{profile.mockPrompt && <div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Mock Prompt</div>
<div className="flex-[2] whitespace-pre-wrap">{profile.mockPrompt}</div>
</div>}
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Created</div>
<div className="flex-[2]"><RelativeTime date={new Date(profile.createdAt)} /></div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Last Updated</div>
<div className="flex-[2]"><RelativeTime date={new Date(profile.lastUpdatedAt)} /></div>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
size="sm"
as={Link}
href={`/projects/${projectId}/test/profiles/${profileId}/edit`}
>
Edit
</Button>
<Button
size="sm"
color="danger"
variant="flat"
onPress={() => setIsDeleteModalOpen(true)}
>
Delete
</Button>
</div>
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Deletion</ModalHeader>
<ModalBody>
Are you sure you want to delete this profile?
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDelete();
onClose();
}}
>
Delete
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Error</ModalHeader>
<ModalBody>
{deleteError}
</ModalBody>
<ModalFooter>
<Button
size="sm"
color="primary"
onPress={onClose}
>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
)}
</div>;
}
function NewProfile({
projectId,
}: {
projectId: string,
}) {
function NewProfile({ projectId }: { projectId: string }) {
const formRef = useRef<HTMLFormElement>(null);
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [mockTools, setMockTools] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const context = formData.get("context") as string;
const mockPrompt = formData.get("mockPrompt") as string;
const profile = await createProfile(projectId, {
name,
context,
const mockTools = formData.get("mockTools") === "on";
const mockPrompt = mockTools ? (formData.get("mockPrompt") as string) : undefined;
await createProfile(projectId, {
name,
context,
mockTools,
mockPrompt: mockPrompt || undefined
mockPrompt // This will be undefined if mockTools is false
});
router.push(`/projects/${projectId}/test/profiles/${profile._id}`);
router.push(`/projects/${projectId}/test/profiles`);
} catch (error) {
setError(`Unable to create profile: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Profile</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/profiles`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Profiles
</Button>
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => formRef.current?.requestSubmit()}>Retry</Button>
</div>}
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the profile"
required
return <StructuredPanel
title="NEW PROFILE"
tooltip="Create a new test profile"
>
<div className="flex flex-col gap-6 max-w-2xl">
<ProfileForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/profiles`)}
submitButtonText="Create Profile"
/>
<Textarea
name="context"
label="Context"
placeholder="Enter the context for this profile"
required
/>
<Switch
name="mockTools"
isSelected={mockTools}
onValueChange={(value) => {
setMockTools(value);
}}
className="self-start"
>
Mock Tools
</Switch>
{mockTools && <Textarea
name="mockPrompt"
label="Mock Prompt (Optional)"
placeholder="Enter a mock prompt"
/>}
<FormStatusButton
props={{
className: "self-start",
children: "Create",
size: "sm",
type: "submit",
}}
/>
</form>
</div>;
</div>
</StructuredPanel>;
}
function ProfileList({
@ -379,6 +155,8 @@ function ProfileList({
const [error, setError] = useState<string | null>(null);
const [profiles, setProfiles] = useState<WithStringId<z.infer<typeof TestProfile>>[]>([]);
const [total, setTotal] = useState(0);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
useEffect(() => {
let ignore = false;
@ -412,95 +190,146 @@ function ProfileList({
};
}, [page, pageSize, error, projectId]);
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">Profiles</h1>
<Button
size="sm"
onPress={() => router.push(`/projects/${projectId}/test/profiles/new`)}
className="self-end"
startContent={<PlusIcon className="w-4 h-4" />}
>
New Profile
</Button>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>}
{!loading && !error && <>
{profiles.length === 0 && <div className="text-gray-600 text-center">No profiles found</div>}
{profiles.length > 0 && <div className="flex flex-col w-full">
{/* Header */}
<div className="grid grid-cols-8 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-2 px-4">Name</div>
<div className="col-span-3 px-4">Context</div>
<div className="col-span-1 px-4">Mock Tools</div>
<div className="col-span-1 px-4">Created</div>
<div className="col-span-1 px-4">Updated</div>
</div>
const handleSelectionChange = (selection: Selection) => {
if (selection === "all" &&
selectedKeys !== "all" &&
(selectedKeys as Set<string>).size > 0) {
setSelectedKeys(new Set());
setSelectedProfiles([]);
} else {
setSelectedKeys(selection);
if (selection === "all") {
setSelectedProfiles(profiles.map(profile => profile._id));
} else {
setSelectedProfiles(Array.from(selection as Set<string>));
}
}
};
{/* Rows */}
{profiles.map((profile) => (
<div key={profile._id} className="grid grid-cols-8 py-2 border-b hover:bg-gray-50 text-sm">
<div className="col-span-2 px-4 truncate">
<Link
href={`/projects/${projectId}/test/profiles/${profile._id}`}
className="text-blue-600 hover:underline"
>
{profile.name}
</Link>
</div>
<div className="col-span-3 px-4 truncate">{profile.context}</div>
<div className="col-span-1 px-4">{profile.mockTools ? "Yes" : "No"}</div>
<div className="col-span-1 px-4 text-gray-600 truncate">
<RelativeTime date={new Date(profile.createdAt)} />
</div>
<div className="col-span-1 px-4 text-gray-600 truncate">
<RelativeTime date={new Date(profile.lastUpdatedAt)} />
</div>
</div>
))}
</div>}
{total > 1 && <Pagination
total={total}
page={page}
onChange={(page) => {
router.push(`/projects/${projectId}/test/profiles?page=${page}`);
}}
className="self-center"
/>}
</>}
</div>;
const handleDelete = async (profileId: string) => {
try {
await deleteProfile(projectId, profileId);
// Refresh the profiles list after deletion
const result = await listProfiles(projectId, page, pageSize);
setProfiles(result.profiles);
setTotal(result.total);
} catch (err) {
setError(`Failed to delete profile: ${err}`);
}
};
const columns = [
{
key: 'name',
label: 'NAME',
render: (profile: any) => profile.name
},
{
key: 'context',
label: 'CONTEXT'
},
{
key: 'mockTools',
label: 'MOCK TOOLS',
render: (profile: any) => profile.mockTools ? "Yes" : "No"
},
{
key: 'createdAt',
label: 'CREATED',
render: (profile: any) => profile?.createdAt && isValidDate(profile.createdAt) ?
<RelativeTime date={new Date(profile.createdAt)} /> :
'Invalid date'
},
{
key: 'lastUpdatedAt',
label: 'LAST UPDATED',
render: (profile: any) => profile?.lastUpdatedAt && isValidDate(profile.lastUpdatedAt) ?
<RelativeTime date={new Date(profile.lastUpdatedAt)} /> :
'Invalid date'
}
];
return <StructuredPanel
title="PROFILES"
tooltip="View and manage your test profiles"
>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Profiles</h1>
<p className="text-sm text-gray-600 dark:text-neutral-400">
Create and manage test profiles for your simulations
</p>
</div>
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/profiles/new`)}
>
New Profile
</Button>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{/* Profiles Table */}
{loading ? (
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading profiles...
</div>
) : profiles.length === 0 ? (
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
<p className="text-gray-600 dark:text-neutral-400">No profiles created yet</p>
</div>
) : (
<DataTable
items={profiles}
columns={columns}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
onDelete={handleDelete}
onEdit={(id) => router.push(`/projects/${projectId}/test/profiles/${id}/edit`)}
projectId={projectId}
/>
)}
</div>
</StructuredPanel>;
}
export function ProfilesApp({
projectId,
slug
}: {
projectId: string,
slug: string[]
}) {
let selection: "list" | "view" | "new" | "edit" = "list";
let profileId: string | null = null;
if (slug.length > 0) {
export function ProfilesApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
let selection: "list" | "new" | "edit" = "list";
let profileId: string | undefined;
if (slug && slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else if (slug[slug.length - 1] === "edit") {
} else if (slug[1] === "edit") {
selection = "edit";
profileId = slug[0];
} else {
selection = "view";
selection = "list";
profileId = slug[0];
}
}
return <>
{selection === "list" && <ProfileList projectId={projectId} />}
{selection === "new" && <NewProfile projectId={projectId} />}
{selection === "view" && profileId && <ViewProfile projectId={projectId} profileId={profileId} />}
{selection === "edit" && profileId && <EditProfile projectId={projectId} profileId={profileId} />}
</>;
}
return (
<div className="h-full">
{selection === "list" && <ProfileList projectId={projectId} />}
{selection === "new" && <NewProfile projectId={projectId} />}
{selection === "edit" && profileId && (
<EditProfile projectId={projectId} profileId={profileId} />
)}
</div>
);
}
export { NewProfile, EditProfile };

View file

@ -1,149 +1,20 @@
"use client";
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestSimulation, TestRun } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createRun, getRun, getSimulation, listRuns } from "@/app/actions/testing_actions";
import { Button, Input, Pagination, Spinner, Chip } from "@heroui/react";
import { useEffect, useState } from "react";
import { getRun, getSimulation, listRuns, cancelRun, deleteRun, getSimulationResult, listRunSimulations } from "@/app/actions/testing_actions";
import { Button, Spinner, Selection } from "@heroui/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { ArrowLeftIcon, PlusIcon, WorkflowIcon } from "lucide-react";
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { ArrowLeftIcon, PlusIcon, DownloadIcon } from "lucide-react";
import { RelativeTime } from "@primer/react"
import { SimulationSelector } from "@/app/lib/components/selectors/simulation-selector";
import { WorkflowSelector } from "@/app/lib/components/selectors/workflow-selector";
import { Workflow } from "@/app/lib/types/workflow_types";
import { fetchWorkflow } from "@/app/actions/workflow_actions";
function NewRun({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const [selectedSimulations, setSelectedSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
const [isSimulationSelectorOpen, setIsSimulationSelectorOpen] = useState(false);
const [selectedWorkflow, setSelectedWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
const [isWorkflowSelectorOpen, setIsWorkflowSelectorOpen] = useState(false);
async function handleSubmit(formData: FormData) {
setError(null);
const simulationIds = selectedSimulations.map(sim => sim._id);
if (!selectedWorkflow) {
setError("Please select a workflow");
return;
}
if (simulationIds.length === 0) {
setError("Please select at least one simulation");
return;
}
try {
const run = await createRun(projectId, {
workflowId: selectedWorkflow._id,
simulationIds
});
router.push(`/projects/${projectId}/test/runs/${run._id}`);
} catch (error) {
setError(`Unable to create run: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Run</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/runs`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Runs
</Button>
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button
size="sm"
color="danger"
onPress={() => {
formRef.current?.requestSubmit();
}}
>
Retry
</Button>
</div>}
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Workflow</label>
<div className="flex items-center gap-2">
{selectedWorkflow ? (
<div className="text-sm text-blue-600">{selectedWorkflow.name}</div>
) : (
<div className="text-sm text-gray-500">No workflow selected</div>
)}
<Button
size="sm"
onPress={() => setIsWorkflowSelectorOpen(true)}
type="button"
>
{selectedWorkflow ? "Change" : "Select"} Workflow
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
onPress={() => setIsSimulationSelectorOpen(true)}
type="button"
className="self-start"
>
Select Simulations
</Button>
{selectedSimulations.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedSimulations.map((sim) => (
<Chip
key={sim._id}
onClose={() => setSelectedSimulations(prev => prev.filter(s => s._id !== sim._id))}
variant="flat"
className="py-1"
>
{sim.name}
</Chip>
))}
</div>
)}
</div>
<FormStatusButton
props={{
className: "self-start",
children: "Create Run",
size: "sm",
type: "submit",
isDisabled: !selectedWorkflow || selectedSimulations.length === 0,
}}
/>
</form>
<SimulationSelector
projectId={projectId}
isOpen={isSimulationSelectorOpen}
onOpenChange={setIsSimulationSelectorOpen}
onSelect={setSelectedSimulations}
initialSelected={selectedSimulations}
/>
<WorkflowSelector
projectId={projectId}
isOpen={isWorkflowSelectorOpen}
onOpenChange={setIsWorkflowSelectorOpen}
onSelect={setSelectedWorkflow}
/>
</div>;
}
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel"
import { DataTable } from "./components/table"
import { isValidDate } from './utils/date';
function ViewRun({
projectId,
@ -152,49 +23,124 @@ function ViewRun({
projectId: string,
runId: string,
}) {
const router = useRouter();
const [run, setRun] = useState<WithStringId<z.infer<typeof TestRun>> | null>(null);
const [loading, setLoading] = useState(true);
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
const [simulations, setSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
useEffect(() => {
async function fetchRun() {
const run = await getRun(projectId, runId);
setRun(run);
if (run) {
async function fetchData() {
try {
const run = await getRun(projectId, runId);
if (!run) {
setError("Run not found");
return;
}
setRun(run);
const enrichedSimulations = await listRunSimulations(projectId, run.simulationIds);
setSimulations(enrichedSimulations);
// Fetch workflow and simulations in parallel
const [workflowResult, simulationsResult] = await Promise.all([
fetchWorkflow(projectId, run.workflowId),
Promise.all(run.simulationIds.map(id => getSimulation(projectId, id)))
]);
setWorkflow(workflowResult);
setSimulations(simulationsResult.filter(s => s !== null));
} catch (error) {
setError(`Error fetching run: ${error}`);
} finally {
setLoading(false);
}
setLoading(false);
}
fetchRun();
}, [runId, projectId]);
fetchData();
}, [projectId, runId]);
return <div className="h-full flex flex-col gap-4">
<div className="flex items-center justify-between">
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/runs`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
const columns = [
{
key: 'name',
label: 'SIMULATION',
render: (simulation: any) => simulation.name
},
{
key: 'scenarioId',
label: 'SCENARIO',
render: (simulation: any) => simulation.scenarioName
},
{
key: 'profileId',
label: 'PROFILE',
render: (simulation: any) => simulation.profileName
}
];
const handleDownload = async (simulationId: string) => {
try {
const result = await getSimulationResult(projectId, runId, simulationId);
if (!result) {
console.error("No result found for simulation");
return;
}
// Get simulation name from simulations array
const simulation = simulations.find(s => s._id === simulationId);
if (!simulation) {
console.error("Simulation not found");
return;
}
// Create a safe filename
const safeName = `${run?.name}_${simulation.name}`
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
// Create the JSON content
const content = {
run: run?.name,
simulation: simulation.name,
result: result.result,
details: result.details,
transcript: result.transcript
};
// Create and trigger download
const blob = new Blob([JSON.stringify(content, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${safeName}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error("Failed to download result:", error);
}
};
return <StructuredPanel
title="VIEW RUN"
tooltip="View details of this test run"
actions={[
<ActionButton
key="back"
icon={<ArrowLeftIcon size={16} />}
onClick={() => router.push(`/projects/${projectId}/test/runs`)}
>
All Runs
</Button>
</div>
</ActionButton>
]}
>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{!loading && !run && <div className="text-gray-600 text-center">Run not found</div>}
{!loading && run && (
<>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Workflow and timing information in a grid */}
<div className="grid grid-cols-3 gap-4">
{workflow && (
@ -236,31 +182,22 @@ function ViewRun({
</div>
{/* Simulations List */}
<div className="mt-4">
<div>
<h2 className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-2">Simulations</h2>
<div className="space-y-2">
{simulations.map(sim => (
<div key={sim._id} className="border dark:border-neutral-800 rounded-lg p-3">
<Link
href={`/projects/${projectId}/test/simulations/${sim._id}`}
className="text-blue-600 hover:underline"
>
{sim.name}
</Link>
</div>
))}
</div>
<DataTable
items={simulations}
columns={columns}
projectId={projectId}
onDownload={handleDownload}
selectionMode="none"
/>
</div>
</>
</div>
)}
</div>;
</StructuredPanel>
}
function RunList({
projectId,
}: {
projectId: string,
}) {
function RunsList({ projectId }: { projectId: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
@ -270,6 +207,54 @@ function RunList({
const [runs, setRuns] = useState<WithStringId<z.infer<typeof TestRun>>[]>([]);
const [workflowMap, setWorkflowMap] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({});
const [total, setTotal] = useState(0);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
const [selectedRuns, setSelectedRuns] = useState<string[]>([]);
const handleSelectionChange = (selection: Selection) => {
if (selection === "all" &&
selectedKeys !== "all" &&
(selectedKeys as Set<string>).size > 0) {
setSelectedKeys(new Set());
setSelectedRuns([]);
} else {
setSelectedKeys(selection);
if (selection === "all") {
setSelectedRuns(runs.map(run => run._id));
} else {
setSelectedRuns(Array.from(selection as Set<string>));
}
}
};
const handleCancel = async (runId: string) => {
try {
await cancelRun(projectId, runId);
// Update the run status locally after successful cancellation
setRuns(runs.map(run => {
if (run._id === runId) {
return {
...run,
status: 'cancelled'
};
}
return run;
}));
} catch (err) {
setError(`Failed to cancel run: ${err}`);
}
};
const handleDelete = async (runId: string) => {
try {
await deleteRun(projectId, runId);
// Refresh the runs list after deletion
const updatedRuns = await listRuns(projectId, page, pageSize);
setRuns(updatedRuns.runs);
setTotal(updatedRuns.total);
} catch (err) {
setError(`Failed to delete run: ${err}`);
}
};
useEffect(() => {
let ignore = false;
@ -333,101 +318,122 @@ function RunList({
};
}, [runs, error, projectId]);
return <div className="h-full flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-800 dark:text-neutral-200">Test Runs</h1>
<Button
size="sm"
onPress={() => router.push(`/projects/${projectId}/test/runs/new`)}
startContent={<PlusIcon className="w-4 h-4" />}
>
New Run
</Button>
</div>
const columns = [
{
key: 'name',
label: 'NAME',
render: (run: any) => run.name
},
{
key: 'status',
label: 'STATUS',
render: (run: any) => (
<div className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${getStatusStyles(run.status)}`}>
<div className={`w-1.5 h-1.5 rounded-full ${getStatusDotStyles(run.status)}`} />
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
</div>
)
},
{
key: 'results',
label: 'RESULTS',
render: (run: any) => (
<div className="flex items-center gap-2">
<span className="text-green-600 dark:text-green-400">{run.passCount || 0} passed</span>
<span className="text-red-600 dark:text-red-400">{run.failCount || 0} failed</span>
</div>
)
},
{
key: 'createdAt',
label: 'STARTED',
render: (run: any) => isValidDate(run.startedAt) ?
<RelativeTime date={new Date(run.startedAt)} /> :
'Invalid date'
}
];
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>}
{!loading && !error && <>
{runs.length === 0 && <div className="text-gray-600 text-center">No test runs found</div>}
{runs.length > 0 && <div className="space-y-4">
{runs.map((run) => (
<div key={run._id} className="border dark:border-neutral-800 rounded-lg shadow-sm">
<div className="p-4 flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800">
<div className="flex items-center space-x-4">
<Link
href={`/projects/${projectId}/test/runs/${run._id}`}
className="text-blue-600 hover:underline"
>
{run.name}
</Link>
{workflowMap[run.workflowId] && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-neutral-400">
<WorkflowIcon className="w-4 h-4 shrink-0" />
{workflowMap[run.workflowId].name}
</div>
)}
</div>
<div className="flex items-center gap-4">
<span className={getStatusClass(run.status)}>
{run.status}
</span>
<div className="text-sm text-gray-600 dark:text-neutral-400">
<RelativeTime date={new Date(run.startedAt)} />
</div>
</div>
</div>
{run.aggregateResults && (
<div className="border-t dark:border-neutral-800 px-4 py-2 bg-gray-50 dark:bg-neutral-900/50">
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="text-gray-600 dark:text-neutral-400">
Total: {run.aggregateResults.total}
</div>
<div className="text-green-600 dark:text-green-400">
Passed: {run.aggregateResults.passCount}
</div>
<div className="text-red-600 dark:text-red-400">
Failed: {run.aggregateResults.failCount}
</div>
</div>
</div>
)}
return (
<StructuredPanel
title="TEST RUNS"
tooltip="View and manage your test runs"
>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Test Runs</h1>
<p className="text-sm text-gray-600 dark:text-neutral-400">
View and monitor your workflow test runs
</p>
</div>
))}
</div>}
{total > 1 && <Pagination
total={total}
page={page}
onChange={(page) => {
router.push(`/projects/${projectId}/test/runs?page=${page}`);
}}
className="self-center"
/>}
</>}
</div>;
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/simulations`)}
>
New Run
</Button>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{/* Runs Table */}
{loading ? (
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading test runs...
</div>
) : runs.length === 0 ? (
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
<p className="text-gray-600 dark:text-neutral-400">No test runs created yet</p>
</div>
) : (
<DataTable
items={runs}
columns={columns}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
onDelete={handleDelete}
onView={(id) => router.push(`/projects/${projectId}/test/runs/${id}`)}
projectId={projectId}
/>
)}
</div>
</StructuredPanel>
);
}
// Helper function for status styling
function getStatusClass(status: string) {
const baseClass = "px-2 py-1 rounded text-xs uppercase font-medium";
switch (status) {
case 'completed':
return `${baseClass} bg-green-100 text-green-800`;
case 'failed':
case 'error':
return `${baseClass} bg-red-100 text-red-800`;
case 'cancelled':
return `${baseClass} bg-gray-100 text-gray-800`;
case 'running':
case 'pending':
default:
return `${baseClass} bg-yellow-100 text-yellow-800`;
}
// Helper functions for status styling
function getStatusStyles(status: string): string {
const styles = {
pending: "bg-gray-100 text-gray-700 dark:bg-neutral-800 dark:text-neutral-300",
running: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
completed: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
cancelled: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
failed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
error: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
};
return styles[status as keyof typeof styles] || styles.pending;
}
function getStatusDotStyles(status: string): string {
const styles = {
pending: "bg-gray-500 dark:bg-neutral-400",
running: "bg-blue-500 dark:bg-blue-400",
completed: "bg-green-500 dark:bg-green-400",
cancelled: "bg-yellow-500 dark:bg-yellow-400",
failed: "bg-red-500 dark:bg-red-400",
error: "bg-red-500 dark:bg-red-400"
};
return styles[status as keyof typeof styles] || styles.pending;
}
export function RunsApp({
@ -437,20 +443,15 @@ export function RunsApp({
projectId: string,
slug: string[]
}) {
let selection: "list" | "view" | "new" = "list";
let selection: "list" | "view" = "list";
let runId: string | null = null;
if (slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else {
selection = "view";
runId = slug[0];
}
selection = "view";
runId = slug[0];
}
return <>
{selection === "list" && <RunList projectId={projectId} />}
{selection === "new" && <NewRun projectId={projectId} />}
{selection === "list" && <RunsList projectId={projectId} />}
{selection === "view" && runId && <ViewRun projectId={projectId} runId={runId} />}
</>;
}

View file

@ -1,14 +1,20 @@
"use client";
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestScenario } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createScenario, getScenario, listScenarios, updateScenario, deleteScenario } from "@/app/actions/testing_actions";
import { Button, Input, Pagination, Spinner, Textarea, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { Button, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Selection } from "@heroui/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { ArrowLeftIcon, PlusIcon } from "lucide-react";
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { ArrowLeftIcon, PlusIcon, } from "lucide-react";
import { RelativeTime } from "@primer/react"
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel";
import { DataTable } from "./components/table";
import { isValidDate } from './utils/date';
import { ItemView } from "./components/item-view"
import { ScenarioForm } from "./components/scenario-form";
function EditScenario({
projectId,
@ -44,68 +50,45 @@ function EditScenario({
const name = formData.get("name") as string;
const description = formData.get("description") as string;
await updateScenario(projectId, scenarioId, { name, description });
router.push(`/projects/${projectId}/test/scenarios/${scenarioId}`);
router.push(`/projects/${projectId}/test/scenarios`);
} catch (error) {
setError(`Unable to update scenario: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">Edit Scenario</h1>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button
size="sm"
color="danger"
onPress={() => {
formRef.current?.requestSubmit();
}}
>
Retry
</Button>
</div>}
{!loading && scenario && (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the scenario"
defaultValue={scenario.name}
required
/>
<Textarea
name="description"
label="Description"
placeholder="Enter a description for the scenario"
defaultValue={scenario.description}
required
/>
<div className="flex gap-2 items-center">
<FormStatusButton
props={{
className: "self-start",
children: "Update",
size: "sm",
type: "submit",
}}
/>
<Button
size="sm"
variant="flat"
as={Link}
href={`/projects/${projectId}/test/scenarios/${scenarioId}`}
>
Cancel
</Button>
return <StructuredPanel
title="EDIT SCENARIO"
tooltip="Edit an existing test scenario"
>
<div className="flex flex-col gap-6 max-w-2xl">
{loading && (
<div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading scenario...
</div>
</form>
)}
</div>;
)}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{!loading && scenario && (
<ScenarioForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/scenarios`)}
submitButtonText="Update Scenario"
defaultValues={{
name: scenario.name,
description: scenario.description
}}
/>
)}
</div>
</StructuredPanel>;
}
function ViewScenario({
@ -139,120 +122,98 @@ function ViewScenario({
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">View Scenario</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/scenarios`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
return (
<StructuredPanel
title="VIEW SCENARIO"
tooltip="View scenario details"
actions={[
<ActionButton
key="back"
icon={<ArrowLeftIcon size={16} />}
onClick={() => router.push(`/projects/${projectId}/test/scenarios`)}
>
All Scenarios
</ActionButton>
]}
>
All Scenarios
</Button>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{!loading && !scenario && <div className="text-gray-600 text-center">Scenario not found</div>}
{!loading && scenario && (
<>
<div className="flex flex-col gap-1 text-sm">
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Name</div>
<div className="flex-[2]">{scenario.name}</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Description</div>
<div className="flex-[2]">{scenario.description}</div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Created</div>
<div className="flex-[2]"><RelativeTime date={new Date(scenario.createdAt)} /></div>
</div>
<div className="flex border-b py-2">
<div className="flex-[1] font-medium text-gray-600">Last Updated</div>
<div className="flex-[2]"><RelativeTime date={new Date(scenario.lastUpdatedAt)} /></div>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
size="sm"
as={Link}
href={`/projects/${projectId}/test/scenarios/${scenarioId}/edit`}
>
Edit
</Button>
<Button
size="sm"
color="danger"
variant="flat"
onPress={() => setIsDeleteModalOpen(true)}
>
Delete
</Button>
</div>
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Deletion</ModalHeader>
<ModalBody>
Are you sure you want to delete this scenario?
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDelete();
onClose();
}}
>
Delete
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Error</ModalHeader>
<ModalBody>
{deleteError}
</ModalBody>
<ModalFooter>
<Button
size="sm"
color="primary"
onPress={onClose}
>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
)}
</div>;
<ItemView
items={[
{ label: "Name", value: scenario?.name },
{ label: "Description", value: scenario?.description },
{
label: "Created",
value: scenario?.createdAt && isValidDate(scenario.createdAt)
? <RelativeTime date={new Date(scenario.createdAt)} />
: 'Invalid date'
},
{
label: "Last Updated",
value: scenario?.lastUpdatedAt && isValidDate(scenario.lastUpdatedAt)
? <RelativeTime date={new Date(scenario.lastUpdatedAt)} />
: 'Invalid date'
}
]}
actions={
<>
<Button size="sm" variant="flat" onPress={() => router.push(`/projects/${projectId}/test/scenarios/${scenarioId}/edit`)}>Edit</Button>
<Button size="sm" color="danger" variant="flat" onPress={() => setIsDeleteModalOpen(true)}>Delete</Button>
</>
}
/>
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Deletion</ModalHeader>
<ModalBody>
Are you sure you want to delete this scenario?
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDelete();
onClose();
}}
>
Delete
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Error</ModalHeader>
<ModalBody>
{deleteError}
</ModalBody>
<ModalFooter>
<Button size="sm" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</StructuredPanel>
);
}
function NewScenario({
@ -266,63 +227,36 @@ function NewScenario({
async function handleSubmit(formData: FormData) {
setError(null);
const name = formData.get("name") as string;
const description = formData.get("description") as string;
try {
const scenario = await createScenario(projectId, { name, description });
router.push(`/projects/${projectId}/test/scenarios/${scenario._id}`);
const name = formData.get("name") as string;
const description = formData.get("description") as string;
await createScenario(projectId, { name, description });
router.push(`/projects/${projectId}/test/scenarios`);
} catch (error) {
setError(`Unable to create scenario: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Scenario</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/scenarios`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Scenarios
</Button>
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button
size="sm"
color="danger"
onPress={() => {
formRef.current?.requestSubmit();
}}
>
Retry
</Button>
</div>}
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the scenario"
required
return <StructuredPanel
title="NEW SCENARIO"
tooltip="Create a new test scenario"
>
<div className="flex flex-col gap-6 max-w-2xl">
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
<ScenarioForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/scenarios`)}
submitButtonText="Create Scenario"
/>
<Textarea
name="description"
label="Description"
placeholder="Enter a description for the scenario"
required
/>
<FormStatusButton
props={{
className: "self-start",
children: "Create",
size: "sm",
type: "submit",
}}
/>
</form>
</div>;
</div>
</StructuredPanel>;
}
function ScenarioList({
@ -338,6 +272,8 @@ function ScenarioList({
const [error, setError] = useState<string | null>(null);
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof TestScenario>>[]>([]);
const [total, setTotal] = useState(0);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
const [selectedScenarios, setSelectedScenarios] = useState<string[]>([]);
useEffect(() => {
let ignore = false;
@ -371,93 +307,132 @@ function ScenarioList({
};
}, [page, pageSize, error, projectId]);
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">Scenarios</h1>
<Button
size="sm"
onPress={() => router.push(`/projects/${projectId}/test/scenarios/new`)}
className="self-end"
startContent={<PlusIcon className="w-4 h-4" />}
>
New Scenario
</Button>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>}
{!loading && !error && <>
{scenarios.length === 0 && <div className="text-gray-600 text-center">No scenarios found</div>}
{scenarios.length > 0 && <div className="flex flex-col w-full">
{/* Header */}
<div className="grid grid-cols-7 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-2 px-4">Name</div>
<div className="col-span-3 px-4">Description</div>
<div className="col-span-1 px-4">Created</div>
<div className="col-span-1 px-4">Updated</div>
</div>
const handleSelectionChange = (selection: Selection) => {
if (selection === "all" &&
selectedKeys !== "all" &&
(selectedKeys as Set<string>).size > 0) {
setSelectedKeys(new Set());
setSelectedScenarios([]);
} else {
setSelectedKeys(selection);
if (selection === "all") {
setSelectedScenarios(scenarios.map(scenario => scenario._id));
} else {
setSelectedScenarios(Array.from(selection as Set<string>));
}
}
};
{/* Rows */}
{scenarios.map((scenario) => (
<div key={scenario._id} className="grid grid-cols-7 py-2 border-b hover:bg-gray-50 text-sm">
<div className="col-span-2 px-4 truncate">
<Link
href={`/projects/${projectId}/test/scenarios/${scenario._id}`}
className="text-blue-600 hover:underline"
>
{scenario.name}
</Link>
</div>
<div className="col-span-3 px-4 truncate">{scenario.description}</div>
<div className="col-span-1 px-4 text-gray-600 truncate">
<RelativeTime date={new Date(scenario.createdAt)} />
</div>
<div className="col-span-1 px-4 text-gray-600 truncate">
<RelativeTime date={new Date(scenario.lastUpdatedAt)} />
</div>
</div>
))}
</div>}
{total > 1 && <Pagination
total={total}
page={page}
onChange={(page) => {
router.push(`/projects/${projectId}/test/scenarios?page=${page}`);
}}
className="self-center"
/>}
</>}
</div>;
const handleDelete = async (scenarioId: string) => {
try {
await deleteScenario(projectId, scenarioId);
// Refresh the scenarios list after deletion
const result = await listScenarios(projectId, page, pageSize);
setScenarios(result.scenarios);
setTotal(result.total);
} catch (err) {
setError(`Failed to delete scenario: ${err}`);
}
};
const columns = [
{
key: 'name',
label: 'NAME',
render: (scenario: any) => scenario.name
},
{
key: 'description',
label: 'DESCRIPTION'
},
{
key: 'createdAt',
label: 'CREATED',
render: (scenario: any) => isValidDate(scenario.createdAt) ?
<RelativeTime date={new Date(scenario.createdAt)} /> :
'Invalid date'
}
];
return <StructuredPanel
title="SCENARIOS"
tooltip="View and manage your test scenarios"
>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Scenarios</h1>
<p className="text-sm text-gray-600 dark:text-neutral-400">
Create and manage test scenarios for your simulations
</p>
</div>
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/scenarios/new`)}
>
New Scenario
</Button>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{/* Scenarios Table */}
{loading ? (
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading scenarios...
</div>
) : scenarios.length === 0 ? (
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
<p className="text-gray-600 dark:text-neutral-400">No scenarios created yet</p>
</div>
) : (
<DataTable
items={scenarios}
columns={columns}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
onDelete={handleDelete}
onEdit={(id) => router.push(`/projects/${projectId}/test/scenarios/${id}/edit`)}
projectId={projectId}
/>
)}
</div>
</StructuredPanel>;
}
export function ScenariosApp({
projectId,
slug
}: {
projectId: string,
slug: string[]
}) {
let selection: "list" | "view" | "new" | "edit" = "list";
let scenarioId: string | null = null;
if (slug.length > 0) {
export function ScenariosApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
let selection: "list" | "new" | "edit" = "list";
let scenarioId: string | undefined;
if (slug && slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else if (slug[slug.length - 1] === "edit") {
} else if (slug[1] === "edit") {
selection = "edit";
scenarioId = slug[0];
} else {
selection = "view";
selection = "list";
scenarioId = slug[0];
}
}
return <>
{selection === "list" && <ScenarioList projectId={projectId} />}
{selection === "new" && <NewScenario projectId={projectId} />}
{selection === "view" && scenarioId && <ViewScenario projectId={projectId} scenarioId={scenarioId} />}
{selection === "edit" && scenarioId && <EditScenario projectId={projectId} scenarioId={scenarioId} />}
</>;
return (
<div className="h-full">
{selection === "list" && <ScenarioList projectId={projectId} />}
{selection === "new" && <NewScenario projectId={projectId} />}
{selection === "edit" && scenarioId && (
<EditScenario projectId={projectId} scenarioId={scenarioId} />
)}
</div>
);
}

View file

@ -0,0 +1,53 @@
'use client';
import { useRouter } from "next/navigation";
import { StructuredPanel } from "../../../../lib/components/structured-panel";
import { ListItem } from "../../../../lib/components/structured-list";
export function TestingMenu({
projectId,
app,
}: {
projectId: string;
app: "scenarios" | "simulations" | "profiles" | "runs";
}) {
const router = useRouter();
const menuItems = [
{
label: "Scenarios",
href: `/projects/${projectId}/test/scenarios`,
isSelected: app === "scenarios"
},
{
label: "Profiles",
href: `/projects/${projectId}/test/profiles`,
isSelected: app === "profiles"
},
{
label: "Simulations",
href: `/projects/${projectId}/test/simulations`,
isSelected: app === "simulations"
},
{
label: "Test Runs",
href: `/projects/${projectId}/test/runs`,
isSelected: app === "runs"
},
];
return (
<StructuredPanel title="TEST" tooltip="Browse and manage your test scenarios and runs">
<div className="overflow-auto flex flex-col gap-1 justify-start">
{menuItems.map((item) => (
<ListItem
key={item.label}
name={item.label}
isSelected={item.isSelected}
onClick={() => router.push(item.href)}
/>
))}
</div>
</StructuredPanel>
);
}

View file

@ -0,0 +1,4 @@
export const isValidDate = (date: any): boolean => {
const parsed = new Date(date);
return parsed instanceof Date && !isNaN(parsed.getTime());
};

View file

@ -1,5 +1,5 @@
"use client";
import { WithStringId } from "../../../lib/types/types";
import { MCPServer, WithStringId } from "../../../lib/types/types";
import { Workflow } from "../../../lib/types/workflow_types";
import { DataSource } from "../../../lib/types/datasource_types";
import { z } from "zod";
@ -9,6 +9,8 @@ import { WorkflowSelector } from "./workflow_selector";
import { Spinner } from "@heroui/react";
import { cloneWorkflow, createWorkflow, fetchPublishedWorkflowId, fetchWorkflow } from "../../../actions/workflow_actions";
import { listDataSources } from "../../../actions/datasource_actions";
import { listMcpServers } from "@/app/actions/mcp_actions";
import { getProjectConfig } from "@/app/actions/project_actions";
export function App({
projectId,
@ -23,17 +25,23 @@ export function App({
const [dataSources, setDataSources] = useState<WithStringId<z.infer<typeof DataSource>>[] | null>(null);
const [loading, setLoading] = useState(false);
const [autoSelectIfOnlyOneWorkflow, setAutoSelectIfOnlyOneWorkflow] = useState(true);
const [mcpServerUrls, setMcpServerUrls] = useState<Array<z.infer<typeof MCPServer>>>([]);
const [toolWebhookUrl, setToolWebhookUrl] = useState<string>('');
const handleSelect = useCallback(async (workflowId: string) => {
setLoading(true);
const workflow = await fetchWorkflow(projectId, workflowId);
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
const dataSources = await listDataSources(projectId);
const mcpServers = await listMcpServers(projectId);
const projectConfig = await getProjectConfig(projectId);
// Store the selected workflow ID in local storage
localStorage.setItem(`lastWorkflowId_${projectId}`, workflowId);
setWorkflow(workflow);
setPublishedWorkflowId(publishedWorkflowId);
setDataSources(dataSources);
setMcpServerUrls(mcpServers);
setToolWebhookUrl(projectConfig.webhookUrl ?? '');
setLoading(false);
}, [projectId]);
@ -108,6 +116,8 @@ export function App({
handleShowSelector={handleShowSelector}
handleCloneVersion={handleCloneVersion}
useRag={useRag}
mcpServerUrls={mcpServerUrls}
toolWebhookUrl={toolWebhookUrl}
/>}
</>
}

View file

@ -15,14 +15,15 @@ import clsx from "clsx";
import { Action as WorkflowDispatch } from "./workflow_editor";
import MarkdownContent from "../../../lib/components/markdown-content";
import { CopyAsJsonButton } from "../playground/copy-as-json-button";
import { CornerDownLeftIcon, SendIcon } from "lucide-react";
import { CornerDownLeftIcon, PlusIcon, SendIcon } from "lucide-react";
import { useSearchParams } from 'next/navigation';
const CopilotContext = createContext<{
workflow: z.infer<typeof Workflow> | null;
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
appliedChanges: Record<string, boolean>;
}>({ workflow: null, handleApplyChange: () => {}, appliedChanges: {} });
}>({ workflow: null, handleApplyChange: () => { }, appliedChanges: {} });
export function getAppliedChangeKey(messageIndex: number, actionIndex: number, field: string) {
return `${messageIndex}-${actionIndex}-${field}`;
@ -173,35 +174,35 @@ function App({
projectId,
workflow,
dispatch,
chatContext=undefined,
messages,
setMessages,
loadingResponse,
setLoadingResponse,
loadingMessage,
setLoadingMessage,
responseError,
setResponseError,
chatContext = undefined,
}: {
projectId: string;
workflow: z.infer<typeof Workflow>;
dispatch: (action: WorkflowDispatch) => void;
chatContext?: z.infer<typeof CopilotChatContext>;
messages: z.infer<typeof CopilotMessage>[];
setMessages: (messages: z.infer<typeof CopilotMessage>[]) => void;
loadingResponse: boolean;
setLoadingResponse: (loading: boolean) => void;
loadingMessage: string;
setLoadingMessage: (message: string) => void;
responseError: string | null;
setResponseError: (error: string | null) => void;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [messages, setMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
const [loadingResponse, setLoadingResponse] = useState(false);
const [loadingMessage, setLoadingMessage] = useState("Thinking");
const [responseError, setResponseError] = useState<string | null>(null);
const [appliedChanges, setAppliedChanges] = useState<Record<string, boolean>>({});
const [discardContext, setDiscardContext] = useState(false);
const [lastRequest, setLastRequest] = useState<unknown | null>(null);
const [lastResponse, setLastResponse] = useState<unknown | null>(null);
// Check for initial prompt in local storage and send it
useEffect(() => {
const prompt = localStorage.getItem(`project_prompt_${projectId}`);
if (prompt && messages.length === 0) {
localStorage.removeItem(`project_prompt_${projectId}`);
setMessages([{
role: 'user',
content: prompt
}]);
}
}, [projectId, messages.length, setMessages]);
// First useEffect for loading messages
useEffect(() => {
setLoadingMessage("Thinking");
@ -480,8 +481,8 @@ function App({
{effectiveContext.type === 'tool' && `Tool: ${effectiveContext.name}`}
{effectiveContext.type === 'prompt' && `Prompt: ${effectiveContext.name}`}
</div>
<button
className="text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
<button
className="text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
onClick={() => setDiscardContext(true)}
>
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
@ -502,65 +503,42 @@ function App({
export function Copilot({
projectId,
workflow,
chatContext=undefined,
chatContext = undefined,
dispatch,
onNewChat,
messages,
setMessages,
loadingResponse,
setLoadingResponse,
loadingMessage,
setLoadingMessage,
responseError,
setResponseError,
}: {
projectId: string;
workflow: z.infer<typeof Workflow>;
chatContext?: z.infer<typeof CopilotChatContext>;
dispatch: (action: WorkflowDispatch) => void;
onNewChat: () => void;
messages: z.infer<typeof CopilotMessage>[];
setMessages: (messages: z.infer<typeof CopilotMessage>[]) => void;
loadingResponse: boolean;
setLoadingResponse: (loading: boolean) => void;
loadingMessage: string;
setLoadingMessage: (message: string) => void;
responseError: string | null;
setResponseError: (error: string | null) => void;
}) {
const [copilotKey, setCopilotKey] = useState(0);
function handleNewChat() {
setCopilotKey(prev => prev + 1);
}
return (
<StructuredPanel
fancy
title="COPILOT"
<StructuredPanel
fancy
title="COPILOT"
tooltip="Get AI assistance for creating and improving your multi-agent system"
actions={[
<ActionButton
key="ask"
primary
icon={
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
</svg>
}
onClick={onNewChat}
icon={<PlusIcon className="w-4 h-4" />}
onClick={handleNewChat}
>
New
</ActionButton>
]}
>
<App
key={copilotKey}
projectId={projectId}
workflow={workflow}
dispatch={dispatch}
chatContext={chatContext}
messages={messages}
setMessages={setMessages}
loadingResponse={loadingResponse}
setLoadingResponse={setLoadingResponse}
loadingMessage={loadingMessage}
setLoadingMessage={setLoadingMessage}
responseError={responseError}
setResponseError={setResponseError}
/>
</StructuredPanel>
);

View file

@ -6,7 +6,7 @@ import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@heroui/r
import { useRef, useEffect } from "react";
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
import clsx from "clsx";
import { EllipsisVerticalIcon } from "lucide-react";
import { EllipsisVerticalIcon, ImportIcon, PlusIcon } from "lucide-react";
import { SectionHeader, ListItem } from "../../../lib/components/structured-list";
interface EntityListProps {
@ -29,6 +29,7 @@ interface EntityListProps {
onDeleteAgent: (name: string) => void;
onDeleteTool: (name: string) => void;
onDeletePrompt: (name: string) => void;
triggerMcpImport: () => void;
}
export function EntityList({
@ -48,6 +49,7 @@ export function EntityList({
onDeleteAgent,
onDeleteTool,
onDeletePrompt,
triggerMcpImport,
}: EntityListProps) {
const selectedRef = useRef<HTMLButtonElement | null>(null);
@ -58,13 +60,20 @@ export function EntityList({
}, [selectedEntity]);
return (
<StructuredPanel
title="WORKFLOW"
<StructuredPanel
title="WORKFLOW"
tooltip="Browse and manage your agents, tools, and prompts in this sidebar"
>
<div className="overflow-auto flex flex-col gap-1 justify-start">
{/* Agents Section */}
<SectionHeader title="Agents" onAdd={() => onAddAgent({})} />
<SectionHeader title="Agents">
<ActionButton
icon={<PlusIcon className="w-4 h-4" />}
onClick={() => onAddAgent({})}
>
Add
</ActionButton>
</SectionHeader>
{agents.map((agent, index) => (
<ListItem
key={`agent-${index}`}
@ -91,7 +100,21 @@ export function EntityList({
))}
{/* Tools Section */}
<SectionHeader title="Tools" onAdd={() => onAddTool({})} />
<SectionHeader title="Tools">
<ActionButton
icon={<PlusIcon className="w-4 h-4" />}
onClick={() => onAddTool({})}
>
Add
</ActionButton>
<ActionButton
icon={<ImportIcon className="w-4 h-4" />}
onClick={triggerMcpImport}
>
MCP
</ActionButton>
</SectionHeader>
{tools.map((tool, index) => (
<ListItem
key={`tool-${index}`}
@ -100,11 +123,19 @@ export function EntityList({
onClick={() => onSelectTool(tool.name)}
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
rightElement={<EntityDropdown name={tool.name} onDelete={onDeleteTool} />}
icon={tool.isMcp ? <ImportIcon className="w-4 h-4 text-blue-700" /> : undefined}
/>
))}
{/* Prompts Section */}
<SectionHeader title="Prompts" onAdd={() => onAddPrompt({})} />
<SectionHeader title="Prompts">
<ActionButton
icon={<PlusIcon className="w-4 h-4" />}
onClick={() => onAddPrompt({})}
>
Add
</ActionButton>
</SectionHeader>
{prompts.map((prompt, index) => (
<ListItem
key={`prompt-${index}`}

View file

@ -0,0 +1,151 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Button, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Checkbox } from "@heroui/react";
import { z } from "zod";
import { WorkflowTool } from "@/app/lib/types/workflow_types";
import { RefreshCwIcon } from "lucide-react";
import { fetchMcpTools } from "@/app/actions/mcp_actions";
interface McpImportToolsProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onImport: (tools: z.infer<typeof WorkflowTool>[]) => void;
}
export function McpImportTools({ projectId, isOpen, onOpenChange, onImport }: McpImportToolsProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tools, setTools] = useState<z.infer<typeof WorkflowTool>[]>([]);
const [selectedTools, setSelectedTools] = useState<Set<number>>(new Set());
const process = useCallback(async () => {
setLoading(true);
setError(null);
setSelectedTools(new Set());
try {
const result = await fetchMcpTools(projectId);
setTools(result);
// Select all tools by default
setSelectedTools(new Set(result.map((_, index) => index)));
} catch (error) {
setError(`Unable to fetch tools: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
console.log("mcp import tools useEffect", isOpen);
if (isOpen) {
process();
}
}, [isOpen, process]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Import from MCP servers</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Fetching tools...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => process()}>Retry</Button>
</div>}
{!loading && !error && <>
<div className="flex items-center justify-between mb-4">
<div className="text-gray-600">
{tools.length === 0 ? "No tools found" : `Found ${tools.length} tools:`}
</div>
<Button
size="sm"
variant="flat"
onPress={() => {
setTools([]);
process();
}}
startContent={<RefreshCwIcon className="w-4 h-4" />}
>
Refresh
</Button>
</div>
{tools.length > 0 && <div className="flex flex-col w-full mt-4">
<div className="flex items-center gap-4 px-4 py-2 bg-gray-50 rounded-t-lg border-b text-sm text-gray-700 font-medium">
<div className="w-8">
<Checkbox
size="sm"
isSelected={selectedTools.size === tools.length}
isIndeterminate={selectedTools.size > 0 && selectedTools.size < tools.length}
onValueChange={(checked) => {
if (checked) {
setSelectedTools(new Set(tools.map((_, i) => i)));
} else {
setSelectedTools(new Set());
}
}}
/>
</div>
<div className="w-36">Server</div>
<div className="flex-1">Tool Name</div>
</div>
<div className="border rounded-b-lg divide-y overflow-y-auto max-h-[300px]">
{tools.map((t, index) => (
<div
key={index}
className="flex items-center gap-4 px-4 py-2 hover:bg-gray-50 transition-colors"
>
<div className="w-8">
<Checkbox
size="sm"
isSelected={selectedTools.has(index)}
onValueChange={(checked) => {
const newSelected = new Set(selectedTools);
if (checked) {
newSelected.add(index);
} else {
newSelected.delete(index);
}
setSelectedTools(newSelected);
}}
/>
</div>
<div className="w-36">
<div className="bg-blue-50 px-2 py-1 rounded text-blue-700 text-sm font-medium border border-blue-100">
{t.mcpServerName}
</div>
</div>
<div className="flex-1 truncate text-gray-700">{t.name}</div>
</div>
))}
</div>
</div>}
{tools.length > 0 && (
<div className="mt-4 text-sm text-gray-600">
{selectedTools.size} of {tools.length} tools selected
</div>
)}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
{tools.length > 0 && <Button size="sm" onPress={() => {
const selectedToolsList = tools.filter((_, index) => selectedTools.has(index));
onImport(selectedToolsList);
onClose();
}}>
Import
</Button>}
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -1,8 +1,8 @@
import { Metadata } from "next";
import { App } from "./app";
import { USE_RAG } from "@/app/lib/feature_flags";
export const dynamic = 'force-dynamic';
import { projectsCollection } from "@/app/lib/mongodb";
import { notFound } from "next/navigation";
export const metadata: Metadata = {
title: "Workflow"
@ -13,6 +13,14 @@ export default async function Page({
}: {
params: { projectId: string };
}) {
console.log('->>> workflow page being rendered');
const project = await projectsCollection.findOne({
_id: params.projectId,
});
if (!project) {
notFound();
}
return <App
projectId={params.projectId}
useRag={USE_RAG}

View file

@ -6,16 +6,15 @@ import { ActionButton, StructuredPanel } from "../../../lib/components/structure
import { EditableField } from "../../../lib/components/editable-field";
import { Divider } from "@heroui/react";
import { Label } from "../../../lib/components/label";
import { TrashIcon, XIcon } from "lucide-react";
import { ImportIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { Link as NextUILink } from "@heroui/react";
import Link from "next/link";
export function ParameterConfig({
param,
handleUpdate,
handleDelete,
handleRename
handleRename,
readOnly
}: {
param: {
name: string,
@ -29,11 +28,12 @@ export function ParameterConfig({
required: boolean
}) => void,
handleDelete: (name: string) => void,
handleRename: (oldName: string, newName: string) => void
handleRename: (oldName: string, newName: string) => void,
readOnly?: boolean
}) {
return <StructuredPanel
title={param.name}
actions={[
actions={!readOnly ? [
<ActionButton
key="delete"
onClick={() => handleDelete(param.name)}
@ -41,7 +41,7 @@ export function ParameterConfig({
>
Remove
</ActionButton>
]}
] : []}
>
<div className="flex flex-col gap-2">
<EditableField
@ -52,6 +52,7 @@ export function ParameterConfig({
handleRename(param.name, newName);
}
}}
locked={readOnly}
/>
<Divider />
@ -69,6 +70,7 @@ export function ParameterConfig({
type: Array.from(keys)[0] as string
});
}}
isDisabled={readOnly}
>
{['string', 'number', 'boolean', 'array', 'object'].map(type => (
<SelectItem key={type}>
@ -89,6 +91,7 @@ export function ParameterConfig({
description: desc
});
}}
locked={readOnly}
/>
<Divider />
@ -102,6 +105,7 @@ export function ParameterConfig({
required: !param.required
});
}}
isDisabled={readOnly}
>
Required
</Checkbox>
@ -121,6 +125,7 @@ export function ToolConfig({
handleClose: () => void
}) {
const [selectedParams, setSelectedParams] = useState(new Set([]));
const isReadOnly = tool.isMcp;
function handleParamRename(oldName: string, newName: string) {
const newProperties = { ...tool.parameters!.properties };
@ -193,6 +198,13 @@ export function ToolConfig({
</ActionButton>
]}>
<div className="flex flex-col gap-4">
{tool.isMcp && <div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-sm font-normal bg-gray-100 px-2 py-1 rounded-md text-gray-700">
<ImportIcon className="w-4 h-4 text-blue-700" />
<div className="text-sm font-normal">Imported from MCP server: <span className="font-bold">{tool.mcpServerName}</span></div>
</div>
</div>}
<EditableField
label="Name"
value={tool.name}
@ -209,6 +221,7 @@ export function ToolConfig({
}
return { valid: true };
}}
locked={isReadOnly}
/>
<Divider />
@ -221,89 +234,92 @@ export function ToolConfig({
description: value
})}
placeholder="Describe what this tool does..."
locked={isReadOnly}
/>
<Divider />
<Label label="TOOL RESPONSES" />
{!isReadOnly && <>
<Label label="TOOL RESPONSES" />
<div className="ml-4 flex flex-col gap-2">
<RadioGroup
defaultValue="mock"
value={tool.mockTool ? "mock" : "api"}
onValueChange={(value) => handleUpdate({
...tool,
mockTool: value === "mock",
autoSubmitMockedResponse: value === "mock" ? true : undefined
})}
orientation="horizontal"
classNames={{
wrapper: "gap-8",
label: "text-sm"
}}
>
<Radio
value="mock"
size="sm"
<div className="ml-4 flex flex-col gap-2">
<RadioGroup
defaultValue="mock"
value={tool.mockTool ? "mock" : "api"}
onValueChange={(value) => handleUpdate({
...tool,
mockTool: value === "mock",
autoSubmitMockedResponse: value === "mock" ? true : undefined
})}
orientation="horizontal"
classNames={{
base: "max-w-[50%]",
label: "text-sm font-normal"
wrapper: "gap-8",
label: "text-sm"
}}
>
Mock tool responses
</Radio>
<Radio
value="api"
size="sm"
classNames={{
base: "max-w-[50%]",
label: "text-sm font-normal"
}}
>
Connect tool to your API
</Radio>
</RadioGroup>
{tool.mockTool && <>
<div className="ml-0">
<Checkbox
key="autoSubmitMockedResponse"
<Radio
value="mock"
size="sm"
classNames={{
label: "text-xs font-normal"
base: "max-w-[50%]",
label: "text-sm font-normal"
}}
isSelected={tool.autoSubmitMockedResponse ?? true}
onValueChange={(value) => handleUpdate({
...tool,
autoSubmitMockedResponse: value
})}
>
Auto-submit mocked response in playground
</Checkbox>
</div>
Mock tool responses
</Radio>
<Radio
value="api"
size="sm"
classNames={{
base: "max-w-[50%]",
label: "text-sm font-normal"
}}
>
Connect tool to your API
</Radio>
</RadioGroup>
<Divider />
{tool.mockTool && <>
<div className="ml-0">
<Checkbox
key="autoSubmitMockedResponse"
size="sm"
classNames={{
label: "text-xs font-normal"
}}
isSelected={tool.autoSubmitMockedResponse ?? true}
onValueChange={(value) => handleUpdate({
...tool,
autoSubmitMockedResponse: value
})}
>
Auto-submit mocked response in playground
</Checkbox>
</div>
<EditableField
label="Mock instructions"
value={tool.mockInstructions || ''}
onChange={(value) => handleUpdate({
...tool,
mockInstructions: value
})}
placeholder="Enter mock instructions..."
multiline
/>
</>}
<Divider />
{!tool.mockTool && (
<div className="ml-0 text-danger text-xs">
Please configure your webhook in the <strong>Integrate</strong> page if you haven&apos;t already.
</div>
)}
</div>
<EditableField
label="Mock instructions"
value={tool.mockInstructions || ''}
onChange={(value) => handleUpdate({
...tool,
mockInstructions: value
})}
placeholder="Enter mock instructions..."
multiline
/>
</>}
<Divider />
{!tool.mockTool && (
<div className="ml-0 text-danger text-xs">
Please configure your webhook in the <strong>Integrate</strong> page if you haven&apos;t already.
</div>
)}
</div>
<Divider />
</>}
<Label label="Parameters" />
@ -320,11 +336,12 @@ export function ToolConfig({
handleUpdate={handleParamUpdate}
handleDelete={handleParamDelete}
handleRename={handleParamRename}
readOnly={isReadOnly}
/>
))}
</div>
<Button
{!isReadOnly && <Button
className="self-start shrink-0"
variant="light"
size="sm"
@ -352,7 +369,7 @@ export function ToolConfig({
}}
>
Add Parameter
</Button>
</Button>}
</div>
</StructuredPanel>
);

View file

@ -1,5 +1,5 @@
"use client";
import { WithStringId } from "../../../lib/types/types";
import { MCPServer, WithStringId } from "../../../lib/types/types";
import { Workflow } from "../../../lib/types/workflow_types";
import { WorkflowTool } from "../../../lib/types/workflow_types";
import { WorkflowPrompt } from "../../../lib/types/workflow_types";
@ -26,10 +26,9 @@ import { apiV1 } from "rowboat-shared";
import { publishWorkflow, renameWorkflow, saveWorkflow } from "../../../actions/workflow_actions";
import { PublishedBadge } from "./published_badge";
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
import { CopyIcon, Layers2Icon, RadioIcon, RedoIcon, Sparkles, UndoIcon } from "lucide-react";
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon } from "lucide-react";
import { EntityList } from "./entity_list";
import { CopilotMessage } from "../../../lib/types/copilot_types";
import { TestProfile } from "@/app/lib/types/testing_types";
import { McpImportTools } from "./mcp_imports";
enablePatches();
@ -132,6 +131,9 @@ export type Action = {
} | {
type: "restore_state";
state: StateItem;
} | {
type: "import_mcp_tools";
tools: z.infer<typeof WorkflowTool>[];
};
function reducer(state: State, action: Action): State {
@ -273,10 +275,10 @@ function reducer(state: State, action: Action): State {
if (isLive) {
break;
}
let newToolName = "New tool";
let newToolName = "new_tool";
if (draft.workflow?.tools.some((tool) => tool.name === newToolName)) {
newToolName = `New tool ${draft.workflow.tools.filter((tool) =>
tool.name.startsWith("New tool")).length + 1}`;
newToolName = `new_tool_${draft.workflow.tools.filter((tool) =>
tool.name.startsWith("new_tool")).length + 1}`;
}
draft.workflow?.tools.push({
name: newToolName,
@ -509,6 +511,26 @@ function reducer(state: State, action: Action): State {
draft.workflow.startAgent = action.name;
draft.chatKey++;
break;
case "import_mcp_tools":
if (isLive) {
break;
}
// Process each tool one by one
action.tools.forEach(newTool => {
const existingToolIndex = draft.workflow.tools.findIndex(
tool => tool.name === newTool.name
);
if (existingToolIndex !== -1) {
// Replace existing tool
draft.workflow.tools[existingToolIndex] = newTool;
} else {
// Add new tool
draft.workflow.tools.push(newTool);
}
});
draft.chatKey++;
break;
}
}
);
@ -535,6 +557,8 @@ export function WorkflowEditor({
handleShowSelector,
handleCloneVersion,
useRag,
mcpServerUrls,
toolWebhookUrl,
}: {
dataSources: WithStringId<z.infer<typeof DataSource>>[];
workflow: WithStringId<z.infer<typeof Workflow>>;
@ -542,6 +566,8 @@ export function WorkflowEditor({
handleShowSelector: () => void;
handleCloneVersion: (workflowId: string) => void;
useRag: boolean;
mcpServerUrls: Array<z.infer<typeof MCPServer>>;
toolWebhookUrl: string;
}) {
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
patches: [],
@ -570,14 +596,19 @@ export function WorkflowEditor({
const [showCopySuccess, setShowCopySuccess] = useState(false);
const [showCopilot, setShowCopilot] = useState(false);
const [copilotWidth, setCopilotWidth] = useState(25);
const [copilotKey, setCopilotKey] = useState(0);
const [copilotMessages, setCopilotMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
const [loadingResponse, setLoadingResponse] = useState(false);
const [loadingMessage, setLoadingMessage] = useState("Thinking...");
const [responseError, setResponseError] = useState<string | null>(null);
const [isMcpImportModalOpen, setIsMcpImportModalOpen] = useState(false);
console.log(`workflow editor chat key: ${state.present.chatKey}`);
// Auto-show copilot and increment key when prompt is present
useEffect(() => {
const prompt = localStorage.getItem(`project_prompt_${state.present.workflow.projectId}`);
console.log('init project prompt', prompt);
if (prompt) {
setShowCopilot(true);
}
}, [state.present.workflow.projectId]);
function handleSelectAgent(name: string) {
dispatch({ type: "select_agent", name });
}
@ -674,6 +705,10 @@ export function WorkflowEditor({
}, 1500);
}
function triggerMcpImport() {
setIsMcpImportModalOpen(true);
}
const processQueue = useCallback(async (state: State, dispatch: React.Dispatch<Action>) => {
if (saving.current || saveQueue.current.length === 0) return;
@ -697,6 +732,10 @@ export function WorkflowEditor({
}
}, [isLive]);
function handleImportMcpTools(tools: z.infer<typeof WorkflowTool>[]) {
dispatch({ type: "import_mcp_tools", tools });
}
useEffect(() => {
if (state.present.pendingChanges && state.present.workflow) {
saveQueue.current.push(state.present.workflow);
@ -732,7 +771,7 @@ export function WorkflowEditor({
<DropdownMenu
disabledKeys={[
...(state.present.pendingChanges ? ['switch', 'clone'] : []),
...(isLive ? ['publish'] : []),
...(isLive ? ['publish', 'mcp'] : []),
]}
onAction={(key) => {
if (key === 'switch') {
@ -848,6 +887,7 @@ export function WorkflowEditor({
onDeleteAgent={handleDeleteAgent}
onDeleteTool={handleDeleteTool}
onDeletePrompt={handleDeletePrompt}
triggerMcpImport={triggerMcpImport}
/>
</ResizablePanel>
<ResizableHandle />
@ -862,6 +902,8 @@ export function WorkflowEditor({
projectId={state.present.workflow.projectId}
workflow={state.present.workflow}
messageSubscriber={updateChatMessages}
mcpServerUrls={mcpServerUrls}
toolWebhookUrl={toolWebhookUrl}
/>
{state.present.selection?.type === "agent" && <AgentConfig
key={state.present.selection.name}
@ -903,7 +945,6 @@ export function WorkflowEditor({
onResize={(size) => setCopilotWidth(size)}
>
<Copilot
key={copilotKey}
projectId={state.present.workflow.projectId}
workflow={state.present.workflow}
dispatch={dispatch}
@ -916,24 +957,15 @@ export function WorkflowEditor({
messages: chatMessages
} : undefined
}
onNewChat={() => {
setCopilotKey(prev => prev + 1);
setCopilotMessages([]);
setLoadingResponse(false);
setLoadingMessage("Thinking...");
setResponseError(null);
}}
messages={copilotMessages}
setMessages={setCopilotMessages}
loadingResponse={loadingResponse}
setLoadingResponse={setLoadingResponse}
loadingMessage={loadingMessage}
setLoadingMessage={setLoadingMessage}
responseError={responseError}
setResponseError={setResponseError}
/>
</ResizablePanel>
</>}
</ResizablePanelGroup>
<McpImportTools
projectId={state.present.workflow.projectId}
isOpen={isMcpImportModalOpen}
onOpenChange={setIsMcpImportModalOpen}
onImport={handleImportMcpTools}
/>
</div>;
}

View file

@ -4,6 +4,9 @@ import Image from "next/image";
import Link from "next/link";
import { UserButton } from "../lib/components/user_button";
import { ThemeToggle } from "../lib/components/theme-toggle";
import { USE_AUTH } from "../lib/feature_flags";
export const dynamic = 'force-dynamic';
export default function Layout({
children,
@ -30,7 +33,7 @@ export default function Layout({
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<UserButton />
{USE_AUTH && <UserButton />}
</div>
</header>
<main className="grow overflow-auto">

View file

@ -1,42 +1,155 @@
'use client';
import { cn, Input } from "@heroui/react";
import { createProject } from "../../actions/project_actions";
import { templates } from "../../lib/project_templates";
import { cn, Input, Textarea } from "@heroui/react";
import { createProject, createProjectFromPrompt } from "../../actions/project_actions";
import { templates, starting_copilot_prompts } from "../../lib/project_templates";
import { WorkflowTemplate } from "../../lib/types/workflow_types";
import { FormStatusButton } from "../../lib/components/form-status-button";
import { useFormStatus } from "react-dom";
import { z } from "zod";
import { useState } from "react";
import { CheckIcon, PlusIcon } from "lucide-react";
import { CheckIcon, PlusIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useRouter } from 'next/navigation';
import React from "react";
function TemplateCard({
templateKey,
template,
function CustomPromptCard({
onSelect,
selected
selected,
onPromptChange,
customPrompt
}: {
templateKey: string,
template: z.infer<typeof WorkflowTemplate>,
onSelect: (templateKey: string) => void,
selected: boolean
onSelect: () => void,
selected: boolean,
onPromptChange: (prompt: string) => void,
customPrompt: string
}) {
return <button
className={cn(
"relative flex flex-col gap-2 rounded p-4 pt-6 shadow-sm",
"relative flex flex-col gap-2 rounded p-4 pt-6 shadow-sm w-full",
"border border-gray-300 dark:border-gray-700",
"hover:border-gray-500 dark:hover:border-gray-500",
"bg-white dark:bg-gray-900",
selected && "border-gray-800 dark:border-gray-300 shadow-md"
)}
type="button"
onClick={onSelect}
>
{selected && <div className="absolute top-0 right-0 bg-gray-200 dark:bg-gray-800 flex items-center justify-center rounded p-1">
<CheckIcon size={16} />
</div>}
<div className="text-lg dark:text-gray-100 text-left">Custom Prompt</div>
{selected ? (
<Textarea
placeholder="Enter your custom prompt here..."
value={customPrompt}
onChange={(e) => {
e.stopPropagation();
onPromptChange(e.target.value);
}}
onClick={(e) => e.stopPropagation()}
className="min-h-[100px] text-sm w-full"
/>
) : (
<div
className={cn(
"min-h-[60px] w-full p-2 text-sm text-gray-500 dark:text-gray-400 text-left",
"border border-gray-200 dark:border-gray-700 rounded",
"bg-gray-50 dark:bg-gray-800"
)}
>
&ldquo;Create an assistant for a food delivery app that can take new orders, cancel existing orders and answer questions about refund policies&rdquo;
</div>
)}
</button>
}
function TemplateCard({
templateKey,
template,
onSelect,
selected,
type = "template"
}: {
templateKey: string,
template: z.infer<typeof WorkflowTemplate> | string,
onSelect: (templateKey: string) => void,
selected: boolean,
type?: "template" | "prompt"
}) {
const [isExpanded, setIsExpanded] = useState(false);
const name = typeof template === "string" ? templateKey : template.name;
const description = typeof template === "string"
? `"${template}"`
: template.description;
// Check if text needs expansion button
const textRef = React.useRef<HTMLDivElement>(null);
const [needsExpansion, setNeedsExpansion] = useState(false);
React.useEffect(() => {
if (textRef.current) {
const needsButton = textRef.current.scrollHeight > textRef.current.clientHeight;
setNeedsExpansion(needsButton);
}
}, [description]);
return <div
className={cn(
"relative flex flex-col rounded p-4 pt-6 shadow-sm cursor-pointer",
"border border-gray-300 dark:border-gray-700",
"hover:border-gray-500 dark:hover:border-gray-500",
"bg-white dark:bg-gray-900",
selected && "border-gray-800 dark:border-gray-300 shadow-md",
isExpanded ? "h-auto" : "h-[160px]"
)}
onClick={() => onSelect(templateKey)}
>
{selected && <div className="absolute top-0 right-0 bg-gray-200 dark:bg-gray-800 flex items-center justify-center rounded p-1">
<CheckIcon size={16} />
</div>}
<div className="text-lg dark:text-gray-100">{template.name}</div>
<div className="shrink-0 text-sm text-gray-500 dark:text-gray-400 text-left">{template.description}</div>
</button>
<div className="flex flex-col h-full">
<div className="text-lg dark:text-gray-100 text-left mb-2">{name}</div>
<div className="relative flex-1">
<div
ref={textRef}
className={cn(
"text-sm text-gray-500 dark:text-gray-400 text-left pr-6",
!isExpanded && "line-clamp-3"
)}
>
{description}
</div>
{needsExpansion && (
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setIsExpanded(!isExpanded);
}
}}
className={cn(
"absolute right-0 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 cursor-pointer",
isExpanded ? "relative mt-1" : "bottom-0"
)}
aria-label={isExpanded ? "Show less" : "Show more"}
>
{isExpanded ? (
<ChevronUpIcon size={16} />
) : (
<ChevronDownIcon size={16} />
)}
</div>
)}
</div>
</div>
</div>
}
function Submit() {
@ -57,46 +170,119 @@ function Submit() {
export default function App() {
const [selectedTemplate, setSelectedTemplate] = useState<string>('default');
const [selectedType, setSelectedType] = useState<"template" | "prompt">("template");
const [customPrompt, setCustomPrompt] = useState<string>('');
const { default: defaultTemplate, ...otherTemplates } = templates;
const router = useRouter();
function handleTemplateClick(templateKey: string) {
function handleTemplateClick(templateKey: string, type: "template" | "prompt" = "template") {
setSelectedTemplate(templateKey);
setSelectedType(type);
}
async function handleSubmit(formData: FormData) {
if (selectedType === "template") {
console.log('Creating template project');
return await createProject(formData);
}
if (selectedType === "prompt") {
console.log('Starting prompt-based project creation');
try {
const newFormData = new FormData();
const projectName = formData.get('name') as string;
const promptText = selectedTemplate === 'custom'
? customPrompt
: starting_copilot_prompts[selectedTemplate];
newFormData.append('name', projectName);
newFormData.append('prompt', promptText);
console.log('Creating project...');
const response = await createProjectFromPrompt(newFormData);
console.log('Create project response:', response);
if (!response?.id) {
throw new Error('Project creation failed - no project ID returned');
}
// write prompt to local storage
localStorage.setItem(`project_prompt_${response.id}`, promptText);
router.push(`/projects/${response.id}/workflow`);
} catch (error) {
console.error('Error creating project:', error);
}
}
}
return <div className="h-full pt-4 px-4 overflow-auto bg-gray-50 dark:bg-gray-950">
<div className="max-w-[768px] mx-auto p-4 bg-white dark:bg-gray-900 rounded-lg">
<div className="text-lg pb-2 border-b border-b-gray-100 dark:border-b-gray-800 dark:text-gray-100">Create a new project</div>
<form className="mt-4 flex flex-col gap-4" action={createProject}>
<Input
required
name="name"
label="Name this project"
placeholder="Project name or description (internal only)"
variant="bordered"
labelPlacement="outside"
/>
<input type="hidden" name="template" value={selectedTemplate} />
<div className="text-sm dark:text-gray-300">Select a template</div>
<div className="grid grid-cols-3 gap-4">
<TemplateCard
key="default"
templateKey="default"
template={defaultTemplate}
onSelect={handleTemplateClick}
selected={selectedTemplate === 'default'}
<div className="text-lg pb-2 border-b border-b-gray-100 dark:border-b-gray-800 dark:text-gray-100 text-left">Create a new project</div>
<form className="mt-4 flex flex-col gap-6" action={handleSubmit}>
<div>
<div className="text-lg dark:text-gray-300 mb-4 text-left">Name your assistant</div>
<Input
required
name="name"
placeholder="Give an internal name for your assistant"
variant="bordered"
/>
{Object.entries(otherTemplates).map(([key, template]) => (
<TemplateCard
key={key}
templateKey={key}
template={template}
onSelect={handleTemplateClick}
selected={selectedTemplate === key}
/>
))}
</div>
<input type="hidden" name="template" value={selectedTemplate} />
<input type="hidden" name="type" value={selectedType} />
<div className="space-y-8">
<div>
<div className="text-lg dark:text-gray-300 mb-4 text-left">Tell us what you would like to build</div>
<CustomPromptCard
onSelect={() => handleTemplateClick('custom', 'prompt')}
selected={selectedTemplate === 'custom' && selectedType === "prompt"}
onPromptChange={setCustomPrompt}
customPrompt={customPrompt}
/>
</div>
<div>
<div className="text-lg dark:text-gray-300 mb-4 text-left">Or start with an example starting prompt</div>
<div className="grid grid-cols-3 gap-4">
{Object.entries(starting_copilot_prompts).map(([key, prompt]) => (
<TemplateCard
key={key}
templateKey={key}
template={prompt}
onSelect={(key) => handleTemplateClick(key, "prompt")}
selected={selectedTemplate === key && selectedType === "prompt"}
type="prompt"
/>
))}
</div>
</div>
<div>
<div className="text-lg dark:text-gray-300 mb-4 text-left">Or choose a pre-built example assistant</div>
<div className="grid grid-cols-3 gap-4">
<TemplateCard
key="default"
templateKey="default"
template={defaultTemplate}
onSelect={(key) => handleTemplateClick(key, "template")}
selected={selectedTemplate === 'default' && selectedType === "template"}
/>
{Object.entries(otherTemplates).map(([key, template]) => (
<TemplateCard
key={key}
templateKey={key}
template={template}
onSelect={(key) => handleTemplateClick(key, "template")}
selected={selectedTemplate === key && selectedType === "template"}
/>
))}
</div>
</div>
</div>
<Submit />
</form>
</div>
</div>;
}
}

View file

@ -0,0 +1,285 @@
import '../lib/loadenv';
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { z } from 'zod';
import { dataSourceDocsCollection, dataSourcesCollection } from '../lib/mongodb';
import { EmbeddingRecord, DataSourceDoc, DataSource } from "../lib/types/datasource_types";
import { WithId } from 'mongodb';
import { embedMany } from 'ai';
import { embeddingModel } from '../lib/embedding';
import { qdrantClient } from '../lib/qdrant';
import { PrefixLogger } from "../lib/utils";
import crypto from 'crypto';
const splitter = new RecursiveCharacterTextSplitter({
separators: ['\n\n', '\n', '. ', '.', ''],
chunkSize: 1024,
chunkOverlap: 20,
});
const second = 1000;
const minute = 60 * second;
const hour = 60 * minute;
async function runProcessPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
const logger = _logger
.child(doc._id.toString())
.child(doc.name);
if (doc.data.type !== 'text') {
throw new Error("Invalid data source type");
}
// split into chunks
logger.log("Splitting into chunks");
const splits = await splitter.createDocuments([doc.data.content]);
// generate embeddings
logger.log("Generating embeddings");
const { embeddings } = await embedMany({
model: embeddingModel,
values: splits.map((split) => split.pageContent)
});
// store embeddings in qdrant
logger.log("Storing embeddings in Qdrant");
const points: z.infer<typeof EmbeddingRecord>[] = embeddings.map((embedding, i) => ({
id: crypto.randomUUID(),
vector: embedding,
payload: {
projectId: job.projectId,
sourceId: job._id.toString(),
docId: doc._id.toString(),
content: splits[i].pageContent,
title: doc.name,
name: doc.name,
},
}));
await qdrantClient.upsert("embeddings", {
points,
});
// store content in doc record
logger.log("Storing content in doc record");
await dataSourceDocsCollection.updateOne({
_id: doc._id,
version: doc.version,
}, {
$set: {
content: doc.data.content,
status: "ready",
lastUpdatedAt: new Date().toISOString(),
}
});
}
async function runDeletionPipeline(_logger: PrefixLogger, job: WithId<z.infer<typeof DataSource>>, doc: WithId<z.infer<typeof DataSourceDoc>>): Promise<void> {
const logger = _logger
.child(doc._id.toString())
.child(doc.name);
// Delete embeddings from qdrant
logger.log("Deleting embeddings from Qdrant");
await qdrantClient.delete("embeddings", {
filter: {
must: [
{
key: "projectId",
match: {
value: job.projectId,
}
},
{
key: "sourceId",
match: {
value: job._id.toString(),
}
},
{
key: "docId",
match: {
value: doc._id.toString(),
}
}
],
},
});
// Delete docs from db
logger.log("Deleting doc from db");
await dataSourceDocsCollection.deleteOne({ _id: doc._id });
}
// fetch next job from mongodb
(async () => {
while (true) {
console.log("Polling for job...")
const now = Date.now();
let job: WithId<z.infer<typeof DataSource>> | null = null;
// first try to find a job that needs deleting
job = await dataSourcesCollection.findOneAndUpdate({
status: "deleted",
$or: [
{ attempts: { $exists: false } },
{ attempts: { $lte: 3 } }
]
}, { $set: { lastAttemptAt: new Date().toISOString() }, $inc: { attempts: 1 } }, { returnDocument: "after", sort: { createdAt: 1 } });
if (job === null) {
job = await dataSourcesCollection.findOneAndUpdate(
{
$and: [
{ 'data.type': { $eq: "text" } },
{
$or: [
// if the job has never been attempted
{
status: "pending",
attempts: 0,
},
// if the job was attempted but wasn't completed in the last hour
{
status: "pending",
lastAttemptAt: { $lt: new Date(now - 1 * hour).toISOString() },
},
// if the job errored out but hasn't been retried 3 times yet
{
status: "error",
attempts: { $lt: 3 },
},
// if the job errored out but hasn't been retried in the last 5 minutes
{
status: "error",
lastAttemptAt: { $lt: new Date(now - 1 * hour).toISOString() },
},
]
}
]
},
{
$set: {
status: "pending",
lastAttemptAt: new Date().toISOString(),
},
$inc: {
attempts: 1
},
},
{ returnDocument: "after", sort: { createdAt: 1 } }
);
}
if (job === null) {
// if no doc found, sleep for a bit and start again
await new Promise(resolve => setTimeout(resolve, 5 * second));
continue;
}
const logger = new PrefixLogger(`${job._id.toString()}-${job.version}`);
logger.log(`Starting job ${job._id}. Type: ${job.data.type}. Status: ${job.status}`);
let errors = false;
try {
if (job.data.type !== 'text') {
throw new Error("Invalid data source type");
}
if (job.status === "deleted") {
// delete all embeddings for this source
logger.log("Deleting embeddings from Qdrant");
await qdrantClient.delete("embeddings", {
filter: {
must: [
{ key: "projectId", match: { value: job.projectId } },
{ key: "sourceId", match: { value: job._id.toString() } },
],
},
});
// delete all docs for this source
logger.log("Deleting docs from db");
await dataSourceDocsCollection.deleteMany({
sourceId: job._id.toString(),
});
// delete the source record from db
logger.log("Deleting source record from db");
await dataSourcesCollection.deleteOne({
_id: job._id,
});
logger.log("Job deleted");
continue;
}
// fetch docs that need updating
const pendingDocs = await dataSourceDocsCollection.find({
sourceId: job._id.toString(),
status: { $in: ["pending", "error"] },
}).toArray();
logger.log(`Found ${pendingDocs.length} docs to process`);
// for each doc
for (const doc of pendingDocs) {
try {
await runProcessPipeline(logger, job, doc);
} catch (e: any) {
errors = true;
logger.log("Error processing doc:", e);
await dataSourceDocsCollection.updateOne({
_id: doc._id,
version: doc.version,
}, {
$set: {
status: "error",
error: e.message,
}
});
}
}
// fetch docs that need to be deleted
const deletedDocs = await dataSourceDocsCollection.find({
sourceId: job._id.toString(),
status: "deleted",
}).toArray();
logger.log(`Found ${deletedDocs.length} docs to delete`);
for (const doc of deletedDocs) {
try {
await runDeletionPipeline(logger, job, doc);
} catch (e: any) {
errors = true;
logger.log("Error deleting doc:", e);
await dataSourceDocsCollection.updateOne({
_id: doc._id,
version: doc.version,
}, {
$set: {
status: "error",
error: e.message,
}
});
}
}
} catch (e) {
logger.log("Error processing job; will retry:", e);
await dataSourcesCollection.updateOne({ _id: job._id, version: job.version }, { $set: { status: "error" } });
continue;
}
// mark job as complete
logger.log("Marking job as completed...");
await dataSourcesCollection.updateOne({
_id: job._id,
version: job.version,
}, {
$set: {
status: errors ? "error" : "ready",
...(errors ? { error: "There were some errors processing this job" } : {}),
}
});
}
})();

View file

@ -34,6 +34,10 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) {
}
if (request.nextUrl.pathname.startsWith('/projects')) {
// Skip auth check if USE_AUTH is not enabled
if (process.env.USE_AUTH !== 'true') {
return NextResponse.next();
}
return auth0MiddlewareHandler(request, event);
}

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@
"name": "demo.rowboatlabs.com",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
@ -10,7 +11,8 @@
"setupQdrant": "tsx app/scripts/setup_qdrant.ts",
"deleteQdrant": "tsx app/scripts/delete_qdrant.ts",
"ragUrlsWorker": "tsx app/scripts/rag_urls_worker.ts",
"ragFilesWorker": "tsx app/scripts/rag_files_worker.ts"
"ragFilesWorker": "tsx app/scripts/rag_files_worker.ts",
"ragTextWorker": "tsx app/scripts/rag_text_worker.ts"
},
"dependencies": {
"@ai-sdk/openai": "^0.0.37",
@ -19,12 +21,13 @@
"@aws-sdk/s3-request-presigner": "^3.743.0",
"@google/generative-ai": "^0.21.0",
"@heroicons/react": "^2.2.0",
"@langchain/core": "^0.3.7",
"@langchain/textsplitters": "^0.1.0",
"@mendable/firecrawl-js": "^1.0.3",
"@heroui/react": "2.7.4",
"@heroui/system": "2.4.11",
"@heroui/theme": "2.4.11",
"@langchain/core": "^0.3.7",
"@langchain/textsplitters": "^0.1.0",
"@mendable/firecrawl-js": "^1.0.3",
"@modelcontextprotocol/sdk": "^1.7.0",
"@primer/react": "^36.27.0",
"@qdrant/js-client-rest": "^1.13.0",
"ai": "^3.3.28",
@ -56,6 +59,7 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"tiktoken": "^1.0.17",
"twilio": "^5.4.5",
"typewriter-effect": "^2.21.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.5"
@ -72,4 +76,4 @@
"tsx": "^4.19.1",
"typescript": "^5"
}
}
}

View file

@ -2,3 +2,4 @@
.env*
__pycache__/
venv/
.venv/

View file

@ -20,9 +20,9 @@ RUN poetry install --no-interaction --no-ansi
COPY . .
# Set environment variables
ENV FLASK_APP=src.app.main
ENV QUART_APP=src.app.main
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
# Command to run Flask development server
CMD ["flask", "run", "--host=0.0.0.0", "--port=3001"]
CMD ["quart", "run", "--host=0.0.0.0", "--port=3001"]

4153
apps/rowboat_agents/poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,111 @@
[tool.poetry]
name = "agents"
version = "0.1.0"
description = "RowBoat Labs Agent OS"
authors = ["Akhilesh <akhilesh@rowboatlabs.com>"]
license = "MIT"
readme = "README.md"
homepage = "https://github.com/rowboatlabs/agents"
package-mode = false
[tool.poetry.dependencies]
python = ">=3.10,<4.0"
# Dependencies
aiohttp = "^3.9.3"
annotated-types = "^0.7.0"
anyio = "^4.8.0"
asgiref = "*"
beautifulsoup4 = "^4.12.3"
blinker = "^1.9.0"
build = "1.2.2.post1"
CacheControl = "^0.14.2"
certifi = "^2024.12.14"
cffi = "^1.17.1"
charset-normalizer = "^3.4.1"
cleo = "^2.1.0"
click = "^8.1.8"
crashtest = "^0.4.1"
distlib = "^0.3.9"
distro = "^1.9.0"
dnspython = "^2.7.0"
dulwich = "^0.22.7"
et_xmlfile = "^2.0.0"
eval_type_backport = "^0.2.2"
fastjsonschema = "^2.21.1"
filelock = "^3.17.0"
firecrawl = "^1.9.0"
Flask = "^3.1.0"
gunicorn = "^23.0.0"
h11 = "^0.14.0"
httpcore = "^1.0.7"
httpx = "^0.27.2"
hypercorn = "*"
idna = "^3.10"
installer = "^0.7.0"
itsdangerous = "^2.2.0"
"jaraco.classes" = "^3.4.0"
"jaraco.context" = "^6.0.1"
"jaraco.functools" = "^4.1.0"
Jinja2 = "^3.1.5"
jiter = "^0.6.1"
jsonpath-python = "^1.0.6"
keyring = "^25.6.0"
lxml = "^5.3.0"
markdownify = "^0.13.1"
MarkupSafe = "^3.0.2"
mcp = "*"
more-itertools = "^10.6.0"
motor = "*"
msgpack = "^1.1.0"
mypy-extensions = "^1.0.0"
nest-asyncio = "^1.6.0"
numpy = "^2.2.1"
openai = "*"
openai-agents = "*"
openpyxl = "^3.1.5"
packaging = "^24.2"
pandas = "^2.2.3"
pkginfo = "^1.12.0"
platformdirs = "^4.3.6"
poetry = "^2.0.1"
poetry-core = "^2.0.1"
pycparser = "^2.22"
pydantic = "^2.10.5"
pydantic_core = "^2.27.2"
PyJWT = "^2.10.1"
pymongo = "^4.10.1"
pyproject_hooks = "^1.2.0"
python-dateutil = "^2.9.0.post0"
python-docx = "^1.1.2"
python-dotenv = "^1.0.1"
pytz = "^2024.2"
qdrant-client = "*"
Quart = "^0.20.0"
RapidFuzz = "^3.11.0"
redis = "^5.2.1"
requests = "^2.32.3"
requests-toolbelt = "^1.0.0"
setuptools = "^75.8.0"
shellingham = "^1.5.4"
six = "^1.17.0"
sniffio = "^1.3.1"
soupsieve = "^2.6"
tabulate = "^0.9.0"
tomlkit = "^0.13.2"
tqdm = "^4.67.1"
trove-classifiers = "^2025.1.15.22"
typing-inspect = "^0.9.0"
typing_extensions = "^4.12.2"
tzdata = "^2024.2"
urllib3 = "^2.3.0"
virtualenv = "^20.29.1"
waitress = "^2.1.2"
websockets = "^13.1"
Werkzeug = "^3.1.3"
wheel = "^0.44.0"
xattr = "^1.1.4"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -1,5 +1,7 @@
aiohttp==3.9.3
annotated-types==0.7.0
anyio==4.8.0
asgiref
beautifulsoup4==4.12.3
blinker==1.9.0
build==1.2.2.post1
@ -24,6 +26,7 @@ gunicorn==23.0.0
h11==0.14.0
httpcore==1.0.7
httpx==0.27.2
hypercorn
idna==3.10
installer==0.7.0
itsdangerous==2.2.0
@ -37,12 +40,15 @@ keyring==25.6.0
lxml==5.3.0
markdownify==0.13.1
MarkupSafe==3.0.2
mcp
more-itertools==10.6.0
motor
msgpack==1.1.0
mypy-extensions==1.0.0
nest-asyncio==1.6.0
numpy==2.2.1
openai==1.59.7
openai
openai-agents
openpyxl==3.1.5
packaging==24.2
pandas==2.2.3
@ -53,13 +59,17 @@ poetry-core==2.0.1
pycparser==2.22
pydantic==2.10.5
pydantic_core==2.27.2
PyJWT==2.10.1
pymongo==4.10.1
pyproject_hooks==1.2.0
python-dateutil==2.9.0.post0
python-docx==1.1.2
python-dotenv==1.0.1
pytz==2024.2
qdrant-client
Quart==0.20.0
RapidFuzz==3.11.0
redis==5.2.1
requests==2.32.3
requests-toolbelt==1.0.0
setuptools==75.8.0
@ -76,7 +86,8 @@ typing_extensions==4.12.2
tzdata==2024.2
urllib3==2.3.0
virtualenv==20.29.1
waitress==2.1.2
websockets==13.1
Werkzeug==3.1.3
wheel==0.44.0
xattr==1.1.4
xattr==1.1.4

View file

@ -0,0 +1,198 @@
from quart import Quart, request, jsonify, Response
from datetime import datetime
from functools import wraps
import os
import redis
import uuid
import json
from hypercorn.config import Config
from hypercorn.asyncio import serve
import asyncio
from src.graph.core import run_turn, run_turn_streamed
from src.graph.tools import RAG_TOOL, CLOSE_CHAT_TOOL
from src.utils.common import common_logger, read_json_from_file
from pprint import pprint
logger = common_logger
redis_client = redis.from_url(os.environ.get('REDIS_URL', 'redis://localhost:6379'))
app = Quart(__name__)
# filter out agent transfer messages using a function
def is_agent_transfer_message(msg):
if (msg.get("role") == "assistant" and
msg.get("content") is None and
msg.get("tool_calls") is not None and
len(msg.get("tool_calls")) > 0 and
msg.get("tool_calls")[0].get("function").get("name") == "transfer_to_agent"):
return True
if (msg.get("role") == "tool" and
msg.get("tool_calls") is None and
msg.get("tool_call_id") is not None and
msg.get("tool_name") == "transfer_to_agent"):
return True
return False
@app.route("/health", methods=["GET"])
async def health():
return jsonify({"status": "ok"})
@app.route("/")
async def home():
return "Hello, World!"
def require_api_key(f):
@wraps(f)
async def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Missing or invalid authorization header'}), 401
token = auth_header.split('Bearer ')[1]
actual = os.environ.get('API_KEY', '').strip()
if actual and token != actual:
return jsonify({'error': 'Invalid API key'}), 403
return await f(*args, **kwargs)
return decorated
@app.route("/chat", methods=["POST"])
@require_api_key
async def chat():
logger.info('='*100)
logger.info(f"{'*'*100}Running server mode{'*'*100}")
try:
request_data = await request.get_json()
config = read_json_from_file("./configs/default_config.json")
# filter out agent transfer messages
input_messages = [msg for msg in request_data["messages"] if not is_agent_transfer_message(msg)]
# Preprocess messages to handle null content and role issues
for msg in input_messages:
if (msg.get("role") == "assistant" and
msg.get("content") is None and
msg.get("tool_calls") is not None and
len(msg.get("tool_calls")) > 0):
msg["content"] = "Calling tool"
if msg.get("role") == "tool":
msg["role"] = "developer"
elif not msg.get("role"):
msg["role"] = "user"
print("Request:")
pprint(request_data)
data = request_data
resp_messages, resp_tokens_used, resp_state = await run_turn(
messages=input_messages,
start_agent_name=data.get("startAgent", ""),
agent_configs=data.get("agents", []),
tool_configs=data.get("tools", []),
start_turn_with_start_agent=config.get("start_turn_with_start_agent", False),
state=data.get("state", {}),
additional_tool_configs=[RAG_TOOL, CLOSE_CHAT_TOOL],
complete_request=data
)
logger.info('-'*100)
logger.info('Raw output:')
logger.info((resp_messages, resp_tokens_used, resp_state))
out = {
"messages": resp_messages,
"tokens_used": resp_tokens_used,
"state": resp_state,
}
logger.info("Output:")
for k, v in out.items():
logger.info(f"{k}: {v}")
logger.info('*'*100)
return jsonify(out)
except Exception as e:
logger.error(f"Error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/chat_stream_init", methods=["POST"])
@require_api_key
async def chat_stream_init():
# create a uuid for the stream
stream_id = str(uuid.uuid4())
# store the request data in redis with 10 minute TTL
data = await request.get_json()
redis_client.setex(f"stream_request_{stream_id}", 600, json.dumps(data))
return jsonify({"streamId": stream_id})
def format_sse(data: dict, event: str = None) -> str:
msg = f"data: {json.dumps(data)}\n\n"
if event is not None:
msg = f"event: {event}\n{msg}"
return msg
@app.route("/chat_stream/<stream_id>", methods=["GET"])
@require_api_key
async def chat_stream(stream_id):
# get the request data from redis
request_data = redis_client.get(f"stream_request_{stream_id}")
if not request_data:
return jsonify({"error": "Stream not found"}), 404
request_data = json.loads(request_data)
config = read_json_from_file("./configs/default_config.json")
# filter out agent transfer messages
input_messages = [msg for msg in request_data["messages"] if not is_agent_transfer_message(msg)]
# Preprocess messages to handle null content and role issues
for msg in input_messages:
if (msg.get("role") == "assistant" and
msg.get("content") is None and
msg.get("tool_calls") is not None and
len(msg.get("tool_calls")) > 0):
msg["content"] = "Calling tool"
if msg.get("role") == "tool":
msg["role"] = "developer"
elif not msg.get("role"):
msg["role"] = "user"
print("Request:")
pprint(request_data)
async def generate():
try:
async for event_type, event_data in run_turn_streamed(
messages=input_messages,
start_agent_name=request_data.get("startAgent", ""),
agent_configs=request_data.get("agents", []),
tool_configs=request_data.get("tools", []),
start_turn_with_start_agent=config.get("start_turn_with_start_agent", False),
state=request_data.get("state", {}),
additional_tool_configs=[RAG_TOOL, CLOSE_CHAT_TOOL],
complete_request=request_data
):
if event_type == 'message':
print("Yielding message:")
yield format_sse(event_data, "message")
elif event_type == 'done':
print("Yielding done:")
yield format_sse(event_data, "done")
except Exception as e:
logger.error(f"Streaming error: {str(e)}")
yield format_sse({"error": str(e)}, "error")
return Response(generate(), mimetype='text/event-stream')
if __name__ == "__main__":
print("Starting async server...")
config = Config()
config.bind = ["0.0.0.0:4040"]
asyncio.run(serve(app, config))

View file

@ -0,0 +1,400 @@
from copy import deepcopy
from datetime import datetime
import json
import uuid
import logging
from .helpers.access import (
get_agent_by_name,
get_external_tools,
)
from .helpers.state import (
construct_state_from_response
)
from .helpers.control import get_latest_assistant_msg, get_latest_non_assistant_messages, get_last_agent_name
from .swarm_wrapper import run as swarm_run, run_streamed as swarm_run_streamed, create_response, get_agents
from src.utils.common import common_logger as logger
import asyncio
# Create a dedicated logger for swarm wrapper
logger.setLevel(logging.INFO)
print("Logger level set to INFO")
def order_messages(messages):
"""
Sorts each message's keys in a specified order and returns a new list of ordered messages.
"""
ordered_messages = []
for msg in messages:
# Filter out None values
msg = {k: v for k, v in msg.items() if v is not None}
# Specify the exact order
ordered = {}
for key in ['role', 'sender', 'content', 'created_at', 'timestamp']:
if key in msg:
ordered[key] = msg[key]
# Add remaining keys in alphabetical order
remaining_keys = sorted(k for k in msg if k not in ordered)
for key in remaining_keys:
ordered[key] = msg[key]
ordered_messages.append(ordered)
return ordered_messages
def clean_up_history(agent_data):
"""
Ensures each agent's history is sorted using order_messages.
"""
for data in agent_data:
data["history"] = order_messages(data["history"])
return agent_data
def create_final_response(response, turn_messages, tokens_used, all_agents):
"""
Constructs the final response data (messages, tokens_used, updated state) that a caller would need.
"""
# Ensure response has a messages attribute
if not hasattr(response, 'messages'):
response.messages = []
# Assign the appropriate messages to the response
response.messages = turn_messages
# Ensure tokens_used is a valid dictionary
if not isinstance(tokens_used, dict):
tokens_used = {"total": 100, "prompt": 50, "completion": 50} # Default values if not a dictionary
# Ensure response has a tokens_used attribute that's a dictionary
if not hasattr(response, 'tokens_used') or not isinstance(response.tokens_used, dict):
response.tokens_used = {}
response.tokens_used = tokens_used
# Ensure response has an agent attribute for state construction
if not hasattr(response, 'agent'):
if all_agents and len(all_agents) > 0:
response.agent = all_agents[0] # Set default agent if missing
new_state = construct_state_from_response(response, all_agents)
return response.messages, response.tokens_used, new_state
async def run_turn(
messages, start_agent_name, agent_configs, tool_configs, start_turn_with_start_agent, state={}, additional_tool_configs=[], complete_request={}
):
"""
Coordinates a single 'turn' of conversation or processing among agents.
Includes validation, agent setup, optional greeting logic, error handling, and post-processing steps.
"""
logger.info("Running stateless turn")
print("Running stateless turn")
# Sort messages by the specified ordering
#messages = order_messages(messages)
# Merge any additional tool configs
tool_configs = tool_configs + additional_tool_configs
# Determine if this is a greeting turn
greeting_turn = not any(msg.get("role") != "system" for msg in messages)
turn_messages = []
# Initialize tokens_used as a dictionary
tokens_used = {"total": 0, "prompt": 0, "completion": 0}
agent_data = state.get("agent_data", [])
# If not a greeting turn, localize the last user or system messages
if not greeting_turn:
latest_assistant_msg = get_latest_assistant_msg(messages)
latest_non_assistant_msgs = get_latest_non_assistant_messages(messages)
msg_type = latest_non_assistant_msgs[-1]["role"]
# Determine the last agent from state/config
last_agent_name = get_last_agent_name(
state=state,
agent_configs=agent_configs,
start_agent_name=start_agent_name,
msg_type=msg_type,
latest_assistant_msg=latest_assistant_msg,
start_turn_with_start_agent=start_turn_with_start_agent
)
else:
# For a greeting turn, we assume the last agent is the start_agent_name
last_agent_name = start_agent_name
state["agent_data"] = agent_data
# Initialize all agents
logger.info("Initializing agents")
print("Initializing agents")
new_agents = get_agents(
agent_configs=agent_configs,
tool_configs=tool_configs,
complete_request=complete_request
)
# Prepare escalation agent
last_new_agent = get_agent_by_name(last_agent_name, new_agents)
# Gather external tools for Swarm
external_tools = get_external_tools(tool_configs)
logger.info(f"Found {len(external_tools)} external tools")
print(f"Found {len(external_tools)} external tools")
# If no validation error yet, proceed with the main run
logger.info("Running swarm run")
print("Running swarm run")
response = await swarm_run(
agent=last_new_agent,
messages=messages,
external_tools=external_tools,
tokens_used=tokens_used
)
logger.info("Swarm run completed")
print("Swarm run completed")
# Initialize response.messages if it doesn't exist
if not hasattr(response, 'messages'):
response.messages = []
# Convert the ResponseOutputMessage to a standard message format
if hasattr(response, 'new_items') and response.new_items and hasattr(response.new_items[-1], 'raw_item'):
raw_item = response.new_items[-1].raw_item
# Extract text content from ResponseOutputText objects
content = ""
if hasattr(raw_item, 'content') and raw_item.content:
for content_item in raw_item.content:
if hasattr(content_item, 'text'):
content += content_item.text
# Create a standard message dictionary
standard_message = {
"role": raw_item.role if hasattr(raw_item, 'role') else "assistant",
"content": content,
"sender": last_new_agent.name,
"created_at": None,
"response_type": "internal"
}
# Add the converted message to response messages
response.messages.append(standard_message)
logger.info("Converted message added to response messages")
print("Converted message added to response messages")
# Use a dictionary for tokens_used instead of a hard-coded integer
tokens_used = {"total": 100, "prompt": 50, "completion": 50} # Dummy values as placeholders
# Ensure turn_messages can be extended with response.messages
if hasattr(response, 'messages') and isinstance(response.messages, list):
turn_messages.extend(response.messages)
logger.info(f"Completed run of agent: {last_new_agent.name}")
print(f"Completed run of agent: {last_new_agent.name}")
# Otherwise, duplicate the last response as external
logger.info("No post-processing agent found. Duplicating last response and setting to external.")
print("No post-processing agent found. Duplicating last response and setting to external.")
if turn_messages:
duplicate_msg = deepcopy(turn_messages[-1])
duplicate_msg["response_type"] = "external"
duplicate_msg["sender"] += " >> External"
# Ensure tokens_used remains a proper dictionary
if not isinstance(tokens_used, dict):
tokens_used = {"total": 100, "prompt": 50, "completion": 50} # Default values if not a dictionary
response = create_response(
messages=[duplicate_msg],
tokens_used=tokens_used,
agent=last_new_agent,
error_msg=''
)
# Ensure response has messages attribute
if hasattr(response, 'messages') and isinstance(response.messages, list):
turn_messages.extend(response.messages)
# Finalize the response
logger.info("Finalizing response")
print("Finalizing response")
return create_final_response(
response=response,
turn_messages=turn_messages,
tokens_used=tokens_used,
all_agents=new_agents
)
async def run_turn_streamed(
messages,
start_agent_name,
agent_configs,
tool_configs,
start_turn_with_start_agent,
state={},
additional_tool_configs=[],
complete_request={}
):
final_state = None # Initialize outside try block
try:
# Initialize agents and get external tools
new_agents = get_agents(agent_configs=agent_configs, tool_configs=tool_configs, complete_request=complete_request)
last_agent_name = get_last_agent_name(
state=state,
agent_configs=agent_configs,
start_agent_name=start_agent_name,
msg_type="user",
latest_assistant_msg=None,
start_turn_with_start_agent=start_turn_with_start_agent
)
last_new_agent = get_agent_by_name(last_agent_name, new_agents)
external_tools = get_external_tools(tool_configs)
current_agent = last_new_agent
tokens_used = {"total": 0, "prompt": 0, "completion": 0}
stream_result = await swarm_run_streamed(
agent=last_new_agent,
messages=messages,
external_tools=external_tools,
tokens_used=tokens_used
)
# Process streaming events
async for event in stream_result.stream_events():
print('='*50)
print("Received event: ", event)
print('-'*50)
# Handle raw response events and accumulate tokens
if event.type == "raw_response_event":
if hasattr(event.data, 'type') and event.data.type == "response.completed":
if hasattr(event.data.response, 'usage'):
tokens_used["total"] += event.data.response.usage.total_tokens
tokens_used["prompt"] += event.data.response.usage.input_tokens
tokens_used["completion"] += event.data.response.usage.output_tokens
print('-'*50)
print(f"Found usage information. Updated cumulative tokens: {tokens_used}")
print('-'*50)
continue
# Update current agent when it changes
elif event.type == "agent_updated_stream_event":
if current_agent.name == event.new_agent.name:
continue
tool_call_id = str(uuid.uuid4())
# yield the transfer invocation
message = {
'content': None,
'role': 'assistant',
'sender': current_agent.name,
'tool_calls': [{
'function': {
'name': 'transfer_to_agent',
'arguments': json.dumps({
'assistant': event.new_agent.name
})
},
'id': tool_call_id,
'type': 'function'
}],
'tool_call_id': None,
'tool_name': None,
'response_type': 'internal'
}
print("Yielding message: ", message)
yield ('message', message)
# yield the transfer result
message = {
'content': json.dumps({
'assistant': event.new_agent.name
}),
'role': 'tool',
'sender': None,
'tool_calls': None,
'tool_call_id': tool_call_id,
'tool_name': 'transfer_to_agent',
}
print("Yielding message: ", message)
yield ('message', message)
current_agent = event.new_agent
continue
# Handle run items (tools, messages, etc)
elif event.type == "run_item_stream_event":
current_agent = event.item.agent
if event.item.type == "tool_call_item":
message = {
'content': None,
'role': 'assistant',
'sender': current_agent.name if current_agent else None,
'tool_calls': [{
'function': {
'name': event.item.raw_item.name,
'arguments': event.item.raw_item.arguments
},
'id': event.item.raw_item.call_id,
'type': 'function'
}],
'tool_call_id': None,
'tool_name': None,
'response_type': 'internal'
}
print("Yielding message: ", message)
yield ('message', message)
elif event.item.type == "tool_call_output_item":
message = {
'content': str(event.item.output),
'role': 'tool',
'sender': None,
'tool_calls': None,
'tool_call_id': event.item.raw_item['call_id'],
'tool_name': event.item.raw_item.get('name', None),
'response_type': 'internal'
}
print("Yielding message: ", message)
yield ('message', message)
elif event.item.type == "message_output_item":
content = ""
if hasattr(event.item.raw_item, 'content'):
for content_item in event.item.raw_item.content:
if hasattr(content_item, 'text'):
content += content_item.text
message = {
'content': content,
'role': 'assistant',
'sender': current_agent.name,
'tool_calls': None,
'tool_call_id': None,
'tool_name': None,
'response_type': 'external'
}
print("Yielding message: ", message)
yield ('message', message)
print(f"\n{'='*50}\n")
# After all events are processed, set final state
final_state = {
"last_agent_name": current_agent.name if current_agent else None,
"tokens": tokens_used
}
yield ('done', {'state': final_state})
except Exception as e:
import traceback
print(traceback.format_exc())
print(f"Error in stream processing: {str(e)}")
yield ('error', {'error': str(e), 'state': final_state}) # Include final_state in error response

View file

@ -3,7 +3,7 @@ from src.utils.common import generate_llm_output
import os
import copy
from src.swarm.types import Response, Agent
from .swarm_wrapper import Agent, Response, create_response
from src.utils.common import common_logger, generate_openai_output, update_tokens_used
logger = common_logger
@ -20,12 +20,12 @@ def classify_hallucination(context: str, assistant_response: str, chat_history:
Returns:
str: Verdict indicating level of hallucination:
'yes-absolute' - completely supported by context
'yes-common-sensical' - supported with common sense interpretation
'yes-common-sensical' - supported with common sense interpretation
'no-absolute' - not supported by context
'no-subtle' - not supported but difference is subtle
"""
chat_history_str = "\n".join([f"{message['role']}: {message['content']}" for message in chat_history])
prompt = f"""
You are a guardrail agent. Your job is to check if the response is hallucinating.
@ -51,40 +51,40 @@ def classify_hallucination(context: str, assistant_response: str, chat_history:
no-absolute: not supported by the context
no-subtle: not supported by the context but the difference is subtle
Output of of the classes:
verdict : yes-absolute/yes-common-sensical/no-absolute/no-subtle
Output of of the classes:
verdict : yes-absolute/yes-common-sensical/no-absolute/no-subtle
Example 1: The response is completely supported by the context.
User Input:
User Input:
Context: "Our airline provides complimentary meals and beverages on all international flights. Passengers are allowed one carry-on bag and one personal item."
Chat History:
Chat History:
User: "Do international flights with your airline offer free meals?"
Response: "Yes, all international flights with our airline offer free meals and beverages."
Output: verdict: yes-absolute
Example 2: The response is generally true and could be deduced with common sense interpretation, though not explicitly stated in the context.
User Input:
User Input:
Context: "Flights may experience delays due to weather conditions. In such cases, the airline staff will provide updates at the airport."
Chat History:
Chat History:
User: "Will there be announcements if my flight is delayed?"
Response: "Yes, if your flight is delayed, there will be announcements at the airport."
Output: verdict: yes-common-sensical
Output: verdict: yes-common-sensical
Example 3: The response is not supported by the context and contains glaring inaccuracies.
User Input:
User Input:
Context: "You can cancel your ticket online up to 24 hours before the flight's departure time and receive a full refund."
Chat History:
Chat History:
User: "Can I get a refund if I cancel 12 hours before the flight?"
Response: "Yes, you can get a refund if you cancel 12 hours before the flight."
Output: verdict: no-absolute
Example 4: The response is not supported by the context but the difference is subtle.
User Input:
User Input:
Context: "Our frequent flyer program offers discounts on checked bags for members who have achieved Gold status."
Chat History:
Chat History:
User: "As a member, do I get discounts on checked bags?"
Response: "Yes, members of our frequent flyer program get discounts on checked bags."
Output: verdict: no-subtle
Output: verdict: no-subtle
"""
messages = [
{
@ -105,7 +105,7 @@ def post_process_response(messages: list, post_processing_agent_name: str, post_
logger.debug(f"Pending message keys: {pending_msg.keys()}")
skip = False
if pending_msg.get("tool_calls"):
logger.info("Last message is a tool call, skipping post processing and setting last message to external")
skip = True
@ -113,11 +113,11 @@ def post_process_response(messages: list, post_processing_agent_name: str, post_
elif not pending_msg['response_type'] == "internal":
logger.info("Last message is not internal, skipping post processing and setting last message to external")
skip = True
elif not pending_msg['content']:
logger.info("Last message has no content, skipping post processing and setting last message to external")
skip = True
elif not post_process_instructions:
logger.info("No post process instructions, skipping post processing and setting last message to external")
skip = True
@ -131,7 +131,7 @@ def post_process_response(messages: list, post_processing_agent_name: str, post_
error_msg=''
)
return response
agent_history_str = f"\n{'*'*100}\n".join([f"Role: {message['role']} | Content: {message.get('content', 'None')} | Tool Calls: {message.get('tool_calls', 'None')}" for message in agent_history[:-1]])
logger.debug(f"Agent history: {agent_history_str}")
@ -147,7 +147,7 @@ def post_process_response(messages: list, post_processing_agent_name: str, post_
{post_process_instructions}
------------------------------------------------------------------------
# CHAT HISTORY
Here is the chat history:
@ -186,7 +186,7 @@ def post_process_response(messages: list, post_processing_agent_name: str, post_
Here is the response that the agent has generated:
{pending_msg['content']}
"""
prompt += agent_response_and_instructions

Some files were not shown because too many files have changed in this diff Show more