diff --git a/dashboard/src/api/modules/expertMarket.ts b/dashboard/src/api/modules/expertMarket.ts
index 61be8555..5881664f 100644
--- a/dashboard/src/api/modules/expertMarket.ts
+++ b/dashboard/src/api/modules/expertMarket.ts
@@ -57,6 +57,7 @@ export interface CreateMarketExpertResponse {
export interface CreateMarketExpertBody {
name?: string;
+ agent_id?: string;
description?: string;
providers?: string[];
default_model?: string;
diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json
index 4c9a49dd..be389efb 100644
--- a/dashboard/src/locales/en.json
+++ b/dashboard/src/locales/en.json
@@ -395,6 +395,10 @@
"mbtiDefault": "Default",
"mbtiViewDetail": "Click to view personality details",
"agentId": "Expert ID",
+ "agentIdHelp": "Optional. If left empty, a random ID will be auto-generated.",
+ "agentIdPlaceholder": "e.g. my-expert-id",
+ "agentIdInvalid": "Only letters, numbers, hyphens, and underscores allowed. Must start with a letter or number.",
+ "agentIdTooLong": "Agent ID must be 1-64 characters.",
"copyAgentId": "Click to copy expert ID",
"table": {
"name": "Name",
@@ -417,6 +421,8 @@
"marketLoadFailed": "Failed to load the expert market",
"marketBackendMissing": "The backend has not loaded the Expert Market API yet. Restart Octop or confirm it is running this updated code.",
"marketSearchPlaceholder": "Search expert market",
+ "marketCreateModalTitle": "Create Expert from Market",
+ "marketCreateModalHint": "Specify an optional Expert ID for the new agent. If left empty, a random ID will be generated automatically.",
"sceneAll": "All",
"scenes": {
"academic": "Academic",
diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json
index 5bee04af..51cb0728 100644
--- a/dashboard/src/locales/zh.json
+++ b/dashboard/src/locales/zh.json
@@ -395,6 +395,10 @@
"mbtiDefault": "默认",
"mbtiViewDetail": "点击查看人格说明",
"agentId": "专家 ID",
+ "agentIdHelp": "可选。留空将自动生成随机 ID。",
+ "agentIdPlaceholder": "例如 my-expert-id",
+ "agentIdInvalid": "仅允许字母、数字、连字符和下划线,且必须以字母或数字开头。",
+ "agentIdTooLong": "专家 ID 长度为 1-64 个字符。",
"copyAgentId": "点击复制专家 ID",
"table": {
"name": "名称",
@@ -417,6 +421,8 @@
"marketLoadFailed": "加载专家市场失败",
"marketBackendMissing": "后端还没有加载专家市场接口,请重启 Octop 后端或确认当前运行的是这份新代码。",
"marketSearchPlaceholder": "搜索专家市场",
+ "marketCreateModalTitle": "从市场创建专家",
+ "marketCreateModalHint": "可选指定新专家的 ID。留空将自动生成随机 ID。",
"sceneAll": "全部",
"scenes": {
"academic": "学术",
diff --git a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx
index 477b688d..36611dc7 100644
--- a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx
+++ b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx
@@ -64,6 +64,7 @@ export default function CreateFromExpertDrawer({
const skillSlugDisplayName = useSkillSlugDisplayName();
const [form] = Form.useForm<{
name: string;
+ agent_id?: string;
description: string;
default_model: string;
backend_choice: string;
@@ -93,6 +94,7 @@ export default function CreateFromExpertDrawer({
setPathMappings([]);
form.setFieldsValue({
name: pickLocale(expert.label, lang) || expert.id,
+ agent_id: undefined,
description: pickLocale(expert.description, lang),
default_model: MODEL_AUTO_VALUE,
backend_choice: DEFAULT_BACKEND,
@@ -173,6 +175,7 @@ export default function CreateFromExpertDrawer({
method: "POST",
body: JSON.stringify({
name: values.name,
+ agent_id: values.agent_id || undefined,
description: values.description || undefined,
default_model:
defaultModelFromForm(values.default_model) ?? undefined,
@@ -265,6 +268,30 @@ export default function CreateFromExpertDrawer({
+
{
+ if (!value || !value.trim()) return Promise.resolve();
+ const trimmed = value.trim();
+ if (trimmed.length > 64) {
+ return Promise.reject(new Error(t("experts.agentIdTooLong")));
+ }
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(trimmed)) {
+ return Promise.reject(new Error(t("experts.agentIdInvalid")));
+ }
+ return Promise.resolve();
+ },
+ },
+ ]}
+ getValueFromEvent={(e) => e.target.value?.trimStart() ?? ""}
+ >
+
+
+
diff --git a/dashboard/src/pages/Experts/components/ExpertMarketTab.tsx b/dashboard/src/pages/Experts/components/ExpertMarketTab.tsx
index f5ee4270..91ca9d84 100644
--- a/dashboard/src/pages/Experts/components/ExpertMarketTab.tsx
+++ b/dashboard/src/pages/Experts/components/ExpertMarketTab.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
-import { Button, Drawer, Input, Segmented, Spin, Tag } from "antd";
+import { Button, Drawer, Form, Input, Modal, Segmented, Spin, Tag } from "antd";
import { message } from "@/utils/antdMessage";
import {
@@ -15,6 +15,7 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import {
expertMarketApi,
+ type CreateMarketExpertBody,
type ExpertMarketQuickPrompt,
type MarketExpert,
} from "../../../api/modules/expertMarket";
@@ -82,6 +83,9 @@ export default function ExpertMarketTab({
const [selected, setSelected] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [creatingSlug, setCreatingSlug] = useState(null);
+ const [createModalOpen, setCreateModalOpen] = useState(false);
+ const [pendingExpert, setPendingExpert] = useState(null);
+ const [configForm] = Form.useForm<{ agent_id?: string }>();
const debounceRef = useRef | null>(null);
const fetchMarket = useCallback(
@@ -137,11 +141,11 @@ export default function ExpertMarketTab({
);
const createMarketExpert = useCallback(
- async (expert: MarketExpert) => {
+ async (expert: MarketExpert, body: CreateMarketExpertBody = {}) => {
if (creatingSlug) return;
setCreatingSlug(expert.slug);
try {
- const result = await expertMarketApi.install(expert.slug);
+ const result = await expertMarketApi.install(expert.slug, body);
const enrichment = result.market?.welcome_enrichment;
if (enrichment === "pending") {
message.success(
@@ -163,6 +167,28 @@ export default function ExpertMarketTab({
[creatingSlug, onCreated, t],
);
+ const handleOpenCreateModal = useCallback((expert: MarketExpert) => {
+ setPendingExpert(expert);
+ setCreateModalOpen(true);
+ configForm.resetFields();
+ }, [configForm]);
+
+ const handleConfirmCreate = useCallback(async () => {
+ if (!pendingExpert) return;
+ const values = await configForm.validateFields();
+ const body: CreateMarketExpertBody = {};
+ if (values.agent_id) body.agent_id = values.agent_id;
+ await createMarketExpert(pendingExpert, body);
+ setCreateModalOpen(false);
+ setPendingExpert(null);
+ }, [pendingExpert, configForm, createMarketExpert]);
+
+ const handleCreateModalClose = useCallback(() => {
+ if (creatingSlug) return;
+ setCreateModalOpen(false);
+ setPendingExpert(null);
+ }, [creatingSlug]);
+
const totalText = useMemo(
() => t("experts.totalMarket", { count: items.length }),
[items.length, t],
@@ -340,7 +366,7 @@ export default function ExpertMarketTab({
disabled={Boolean(creatingSlug)}
onClick={(e) => {
e.stopPropagation();
- void createMarketExpert(expert);
+ handleOpenCreateModal(expert);
}}
>
{installed
@@ -369,7 +395,7 @@ export default function ExpertMarketTab({
icon={}
loading={creatingSlug === selected.slug}
disabled={Boolean(creatingSlug)}
- onClick={() => void createMarketExpert(selected)}
+ onClick={() => handleOpenCreateModal(selected)}
>
{installedExpertIds.has(selected.id)
? t("experts.createAgainFromMarket")
@@ -501,6 +527,37 @@ export default function ExpertMarketTab({
)}
+
+ void handleConfirmCreate()}
+ okText={t("common.create")}
+ cancelText={t("common.cancel")}
+ >
+ {pendingExpert && (
+
+
+ {t("experts.marketCreateModalHint")}
+
+
+ {labelOf(pendingExpert, lang)}
+
+
+ )}
+
+
+
+
+
);
}
diff --git a/src/octop/api/routers/agents.py b/src/octop/api/routers/agents.py
index bf4585c9..da4dbf3c 100644
--- a/src/octop/api/routers/agents.py
+++ b/src/octop/api/routers/agents.py
@@ -4,10 +4,11 @@
import json
import logging
+import re
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query
-from pydantic import BaseModel
+from pydantic import BaseModel, field_validator
from octop.api.common.agent import assert_agent_owner
from octop.api.deps import current_user, get_server
@@ -17,9 +18,13 @@
router = APIRouter()
+# Agent ID validation pattern: starts with letter/number, then letters/numbers/hyphens/underscores
+_AGENT_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
+
class AgentCreateBody(BaseModel):
name: str
+ agent_id: str | None = None
description: str | None = None
persona_mbti: str | None = None
default_model: str | None = None
@@ -28,6 +33,23 @@ class AgentCreateBody(BaseModel):
icon: str | None = None
template_name: str | None = None
+ @field_validator("agent_id")
+ @classmethod
+ def _validate_agent_id(cls, v: str | None) -> str | None:
+ if v is None:
+ return v
+ stripped = v.strip()
+ if not stripped:
+ raise ValueError("Agent ID cannot be empty or whitespace only")
+ if len(stripped) > 64:
+ raise ValueError("Agent ID must be 1-64 characters")
+ if not _AGENT_ID_RE.match(stripped):
+ raise ValueError(
+ "Agent ID can only contain letters, numbers, hyphens, and underscores, "
+ "and must start with a letter or number"
+ )
+ return stripped
+
class AgentPatchBody(BaseModel):
name: str | None = None
@@ -148,6 +170,7 @@ async def create_agent(
assert server.app_runtime is not None
spec = AgentCreateSpec(
name=body.name,
+ agent_id=body.agent_id,
user_id=user.id,
description=body.description,
persona_mbti=body.persona_mbti,
diff --git a/src/octop/api/routers/experts.py b/src/octop/api/routers/experts.py
index 20a213ce..95fce2a9 100644
--- a/src/octop/api/routers/experts.py
+++ b/src/octop/api/routers/experts.py
@@ -12,10 +12,11 @@
from __future__ import annotations
import asyncio
+import re
from typing import Any
from fastapi import APIRouter, Depends
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
from octop.api.deps import current_user, get_server
from octop.infra.agents.experts.catalog import (
@@ -53,12 +54,30 @@
class FromExpertBody(BaseModel):
name: str | None = None
+ agent_id: str | None = None
description: str | None = None
providers: list[str] | None = None
default_model: str | None = None
backend: dict[str, Any] | None = None
skill_package_ids: list[str] | None = None
+ @field_validator("agent_id")
+ @classmethod
+ def _validate_agent_id(cls, v: str | None) -> str | None:
+ if v is None:
+ return v
+ stripped = v.strip()
+ if not stripped:
+ raise ValueError("Agent ID cannot be empty or whitespace only")
+ if len(stripped) > 64:
+ raise ValueError("Agent ID must be 1-64 characters")
+ if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$", stripped):
+ raise ValueError(
+ "Agent ID can only contain letters, numbers, hyphens, and underscores, "
+ "and must start with a letter or number"
+ )
+ return stripped
+
class LocalizedTextResponse(BaseModel):
zh: str = ""
@@ -262,6 +281,7 @@ async def install_expert_hub_item(
slug=slug,
options=SkillHubMarketAgentCreateOptions(
name=body.name,
+ agent_id=body.agent_id,
description=body.description,
providers=body.providers,
default_model=body.default_model,
@@ -344,6 +364,7 @@ async def create_agent_from_expert(
expert=expert,
user_id=user.id,
name=body.name,
+ agent_id=body.agent_id,
description=body.description,
locale=locale,
default_model=body.default_model,
diff --git a/src/octop/cli/commands/agent.py b/src/octop/cli/commands/agent.py
index 35b63b8b..c2595bf7 100644
--- a/src/octop/cli/commands/agent.py
+++ b/src/octop/cli/commands/agent.py
@@ -19,12 +19,16 @@ def agent() -> None:
@agent.command("create")
@click.argument("name")
+@click.option(
+ "--agent-id", "agent_id", default=None, help="Custom agent ID (short string, optional)."
+)
@click.option("--persona-mbti", "persona_mbti", default=None)
@click.option("--default-model", "default_model", default=None)
@click.option("--template", "template_name", default=None, help="Agent template name.")
@click.option("--user", "as_user", default=None, help="Create for another user (admin)")
def create(
name: str,
+ agent_id: str | None,
persona_mbti: str | None,
default_model: str | None,
template_name: str | None,
@@ -47,6 +51,7 @@ async def _run() -> Any:
assert server.app_runtime is not None
spec = AgentCreateSpec(
name=name,
+ agent_id=agent_id,
user_id=uid,
persona_mbti=persona_mbti,
default_model=default_model,
@@ -64,8 +69,13 @@ async def _run() -> Any:
@agent.command("from-expert")
@click.argument("expert_id")
@click.option("--name", default=None)
+@click.option(
+ "--agent-id", "agent_id", default=None, help="Custom agent ID (short string, optional)."
+)
@click.option("--user", "as_user", default=None)
-def from_expert(expert_id: str, name: str | None, as_user: str | None) -> None:
+def from_expert(
+ expert_id: str, name: str | None, agent_id: str | None, as_user: str | None
+) -> None:
"""Create an agent from a bundled expert template (embedded server)."""
import asyncio
@@ -93,6 +103,7 @@ async def _run() -> Any:
expert=expert,
user_id=uid,
name=name,
+ agent_id=agent_id,
locale=locale,
)
return await server.app_runtime.agent_registry.create(spec, defer_bootstrap=True)
diff --git a/src/octop/infra/agents/experts/market_creation.py b/src/octop/infra/agents/experts/market_creation.py
index dae08ecb..34a52b5d 100644
--- a/src/octop/infra/agents/experts/market_creation.py
+++ b/src/octop/infra/agents/experts/market_creation.py
@@ -39,6 +39,7 @@
@dataclass(frozen=True)
class SkillHubMarketAgentCreateOptions:
name: str | None = None
+ agent_id: str | None = None
description: str | None = None
providers: list[str] | None = None
default_model: str | None = None
@@ -284,6 +285,7 @@ async def create_agent_from_skillhub_skillset(
expert=expert,
user_id=user.id,
name=options.name,
+ agent_id=options.agent_id,
description=options.description,
locale=locale,
default_model=options.default_model,