Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions console/src/api/types/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface AgentProfileConfig {
}

export interface CreateAgentRequest {
id?: string;
name: string;
description?: string;
workspace_dir?: string;
Expand Down
4 changes: 3 additions & 1 deletion console/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@
"loadConfigFailed": "Failed to load agent config",
"saveFailed": "Failed to save agent",
"idRequired": "Please enter agent ID",
"idPattern": "ID can only contain lowercase letters, numbers, underscores and hyphens",
"idLabel": "Agent ID (optional)",
"idHelp": "Leave empty to auto-generate. Only letters, digits, hyphens and underscores allowed.",
"idPattern": "ID can only contain letters, numbers, underscores and hyphens",
"idPlaceholder": "e.g.: my-agent",
"nameRequired": "Please enter agent name",
"namePlaceholder": "e.g.: My Agent",
Expand Down
4 changes: 3 additions & 1 deletion console/src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@
"loadConfigFailed": "エージェント設定の読み込みに失敗しました",
"saveFailed": "エージェントの保存に失敗しました",
"idRequired": "エージェント ID を入力してください",
"idPattern": "ID は小文字、数字、アンダースコア、ハイフンのみ使用できます",
"idLabel": "エージェント ID(任意)",
"idHelp": "空欄のままにすると自動生成されます。英数字、ハイフン、アンダースコアのみ使用可能。",
"idPattern": "ID は英数字、アンダースコア、ハイフンのみ使用できます",
"idPlaceholder": "例:my-agent",
"nameRequired": "エージェント名を入力してください",
"namePlaceholder": "例:マイエージェント",
Expand Down
4 changes: 3 additions & 1 deletion console/src/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@
"loadConfigFailed": "Не удалось загрузить конфигурацию агента",
"saveFailed": "Не удалось сохранить агента",
"idRequired": "Пожалуйста, введите ID агента",
"idPattern": "ID может содержать только строчные буквы, цифры, подчёркивания и дефисы",
"idLabel": "ID агента (необязательно)",
"idHelp": "Оставьте пустым для автогенерации. Допускаются только буквы, цифры, дефисы и подчёркивания.",
"idPattern": "ID может содержать только буквы, цифры, подчёркивания и дефисы",
"idPlaceholder": "например: my-agent",
"nameRequired": "Пожалуйста, введите название агента",
"namePlaceholder": "например: Мой агент",
Expand Down
4 changes: 3 additions & 1 deletion console/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@
"loadConfigFailed": "加载智能体配置失败",
"saveFailed": "保存智能体失败",
"idRequired": "请输入智能体ID",
"idPattern": "ID只能包含小写字母、数字、下划线和连字符",
"idLabel": "智能体 ID(可选)",
"idHelp": "留空则自动生成。仅允许字母、数字、连字符和下划线。",
"idPattern": "ID只能包含字母、数字、下划线和连字符",
"idPlaceholder": "例如:my-agent",
"nameRequired": "请输入智能体名称",
"namePlaceholder": "例如:我的智能体",
Expand Down
15 changes: 15 additions & 0 deletions console/src/pages/Settings/Agents/components/AgentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ export function AgentModal({
<Input disabled />
</Form.Item>
)}
{!editingAgent && (
<Form.Item
name="id"
label={t("agent.idLabel")}
help={t("agent.idHelp")}
rules={[
{
pattern: /^[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9]$/,
message: t("agent.idPattern"),
},
]}
>
<Input placeholder={t("agent.idPlaceholder")} />
Comment thread
rayrayraykk marked this conversation as resolved.
</Form.Item>
)}
<Form.Item
name="name"
label={t("agent.name")}
Expand Down
72 changes: 56 additions & 16 deletions src/qwenpaw/app/routers/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
load_agent_config,
save_agent_config,
generate_short_agent_id,
sanitize_agent_id,
validate_agent_id,
)
from ...config.utils import load_config, save_config
from ...agents.memory.agent_md_manager import AgentMdManager
Expand Down Expand Up @@ -59,14 +61,31 @@ class ReorderAgentsRequest(BaseModel):


class CreateAgentRequest(BaseModel):
"""Request model for creating a new agent (id is auto-generated)."""
"""Request model for creating a new agent.

The ``id`` field is optional. When provided the server uses it as
the agent identifier (after sanitization); when omitted a random
short UUID is generated automatically.
"""

id: str | None = None
name: str
description: str = ""
workspace_dir: str | None = None
language: str = "en"
skill_names: list[str] | None = None

@field_validator("id", mode="before")
@classmethod
def sanitize_id(cls, value: str | None) -> str | None:
"""Strip whitespace from the custom ID."""
if value is None:
return None
if isinstance(value, str):
sanitized = sanitize_agent_id(value)
return sanitized if sanitized else None
Comment thread
rayrayraykk marked this conversation as resolved.
return value

@field_validator("workspace_dir", mode="before")
@classmethod
def strip_workspace_dir(cls, value: str | None) -> str | None:
Expand Down Expand Up @@ -244,32 +263,53 @@ async def get_agent(agentId: str = PathParam(...)) -> AgentProfileConfig:
raise HTTPException(status_code=500, detail=str(e)) from e


def _generate_unique_id(existing_ids: set[str]) -> str:
"""Generate a unique random short agent ID.

Raises:
HTTPException: If a unique ID could not be generated.
"""
max_attempts = 10
for _ in range(max_attempts):
candidate_id = generate_short_agent_id()
if candidate_id not in existing_ids:
return candidate_id
raise HTTPException(
status_code=500,
detail="Failed to generate unique agent ID after 10 attempts",
)


@router.post(
"",
response_model=AgentProfileRef,
status_code=201,
summary="Create new agent",
description="Create a new agent (ID is auto-generated by server)",
description="Create a new agent with optional custom ID",
)
async def create_agent(
request: CreateAgentRequest = Body(...),
) -> AgentProfileRef:
"""Create a new agent with auto-generated ID."""
config = load_config()
"""Create a new agent.

max_attempts = 10
new_id = None
for _ in range(max_attempts):
candidate_id = generate_short_agent_id()
if candidate_id not in config.agents.profiles:
new_id = candidate_id
break
When ``request.id`` is provided, it is used as the agent identifier
(validated for URL-safe characters, length, reserved words, and
uniqueness). Otherwise a random short UUID is generated.
"""
config = load_config()
existing_ids = set(config.agents.profiles.keys())

if new_id is None:
raise HTTPException(
status_code=500,
detail="Failed to generate unique agent ID after 10 attempts",
)
if request.id:
try:
validate_agent_id(request.id, existing_ids)
except ValueError as e:
raise HTTPException(
status_code=400,
detail=str(e),
) from e
new_id = request.id
else:
new_id = _generate_unique_id(existing_ids)

workspace_dir = Path(
request.workspace_dir or f"{WORKING_DIR}/workspaces/{new_id}",
Expand Down
64 changes: 63 additions & 1 deletion src/qwenpaw/config/config.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
import os
import json
import re
from pathlib import Path
from typing import Optional, Union, Dict, List, Literal, Any
from typing import Optional, Union, Dict, List, Literal, Any, Set

from pydantic import BaseModel, Field, ConfigDict, model_validator
import shortuuid
Expand All @@ -26,6 +27,14 @@
)
from ..providers.models import ModelSlotConfig

# Agent ID validation: alphanumeric, hyphens, underscores.
_AGENT_ID_PATTERN = re.compile(
r"^[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9]$",
)
Comment thread
rayrayraykk marked this conversation as resolved.
_AGENT_ID_MIN_LENGTH = 2
_AGENT_ID_MAX_LENGTH = 64
_RESERVED_AGENT_IDS = frozenset({"default"})


def generate_short_agent_id() -> str:
"""Generate a 6-character short UUID for agent identification.
Expand All @@ -36,6 +45,59 @@ def generate_short_agent_id() -> str:
return shortuuid.ShortUUID().random(length=6)


def sanitize_agent_id(raw: str) -> str:
"""Normalize raw agent ID input: strip whitespace.

Args:
raw: Raw user input for agent ID.

Returns:
Sanitized agent ID string.
"""
return raw.strip()
Comment thread
rayrayraykk marked this conversation as resolved.


def validate_agent_id(
agent_id: str,
existing_ids: Set[str],
) -> None:
"""Validate a custom agent ID.

Checks length, character set, reserved words, and uniqueness.

Args:
agent_id: The sanitized agent ID to validate.
existing_ids: Set of already-registered agent IDs.

Raises:
ValueError: If the ID is invalid.
"""
if len(agent_id) < _AGENT_ID_MIN_LENGTH:
raise ValueError(
f"Agent ID must be at least {_AGENT_ID_MIN_LENGTH} characters, "
f"got {len(agent_id)}.",
)
if len(agent_id) > _AGENT_ID_MAX_LENGTH:
raise ValueError(
f"Agent ID must be at most {_AGENT_ID_MAX_LENGTH} characters, "
f"got {len(agent_id)}.",
)
if not _AGENT_ID_PATTERN.match(agent_id):
raise ValueError(
f"Agent ID '{agent_id}' contains invalid characters. "
"Only letters, digits, hyphens, and underscores "
"are allowed. Cannot start or end with '-' or '_'.",
)
if agent_id in _RESERVED_AGENT_IDS:
raise ValueError(
f"Agent ID '{agent_id}' is reserved and cannot be used.",
)
if agent_id in existing_ids:
raise ValueError(
f"Agent ID '{agent_id}' already exists.",
)
Comment thread
rayrayraykk marked this conversation as resolved.


class BaseChannelConfig(BaseModel):
"""Base for channel config (read from config.json, no env)."""

Expand Down
102 changes: 100 additions & 2 deletions tests/unit/workspace/test_agent_id.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# -*- coding: utf-8 -*-
"""Tests for agent ID generation and short UUID functionality."""
from qwenpaw.config.config import generate_short_agent_id
"""Tests for agent ID generation, sanitization, and validation."""
import pytest

from qwenpaw.config.config import (
generate_short_agent_id,
sanitize_agent_id,
validate_agent_id,
)


def test_generate_short_agent_id_length():
Expand All @@ -24,3 +30,95 @@ def test_generate_short_agent_id_alphanumeric():
# shortuuid uses base57 alphabet by default
# (0-9, A-Z, a-z minus ambiguous chars like I, l, O, 0, etc.)
assert agent_id.isalnum()


# --- sanitize_agent_id ---


def test_sanitize_strips_whitespace():
"""Test sanitize_agent_id strips whitespace."""
assert sanitize_agent_id(" Browse-Agent ") == "Browse-Agent"
assert sanitize_agent_id("MY_BOT") == "MY_BOT"
assert sanitize_agent_id("hello") == "hello"


def test_sanitize_empty_string():
"""Test sanitize_agent_id with empty / whitespace-only input."""
assert sanitize_agent_id("") == ""
assert sanitize_agent_id(" ") == ""


# --- validate_agent_id ---


@pytest.mark.parametrize(
"agent_id",
[
"browse-agent",
"my_bot_v2",
"a1",
"agent123",
"Browse-Agent",
"MY_BOT",
],
)
def test_validate_valid_ids(agent_id):
"""Test that well-formed IDs pass validation."""
validate_agent_id(agent_id, set())


@pytest.mark.parametrize(
"agent_id,expected_msg",
[
("a", "at least 2 characters"),
("", "at least 2 characters"),
],
)
def test_validate_too_short(agent_id, expected_msg):
"""Test that IDs shorter than 2 chars are rejected."""
with pytest.raises(ValueError, match=expected_msg):
validate_agent_id(agent_id, set())


def test_validate_too_long():
"""Test that IDs longer than 64 chars are rejected."""
long_id = "a" * 65
with pytest.raises(ValueError, match="at most 64 characters"):
validate_agent_id(long_id, set())


@pytest.mark.parametrize(
"agent_id",
[
"Browse Agent", # space
"中文名", # non-ASCII
"special!chars", # special chars
"-starts-dash", # starts with dash
"_starts-under", # starts with underscore
"ends-dash-", # ends with dash
"ends_under_", # ends with underscore
],
)
def test_validate_invalid_pattern(agent_id):
"""Test that IDs with invalid characters are rejected."""
with pytest.raises(ValueError, match="invalid characters"):
validate_agent_id(agent_id, set())


def test_validate_reserved_id():
"""Test that reserved IDs are rejected."""
with pytest.raises(ValueError, match="reserved"):
validate_agent_id("default", set())


def test_validate_duplicate_id():
"""Test that duplicate IDs are rejected."""
existing = {"browse-agent", "my-bot"}
with pytest.raises(ValueError, match="already exists"):
validate_agent_id("browse-agent", existing)


def test_validate_ok_when_no_conflict():
"""Test that a valid ID passes when there is no conflict."""
existing = {"other-agent"}
validate_agent_id("browse-agent", existing) # should not raise
Loading