diff --git a/.github/drone.yml b/.github/drone.yml index a20d5ef12..c8e53ec22 100644 --- a/.github/drone.yml +++ b/.github/drone.yml @@ -37,9 +37,12 @@ steps: - git config --global core.compression 0 - git config --global http.postBuffer 524288000 - git clone --no-single-branch https://github.com/dataelement/Clawith.git . - # 将 shallow clone 转换为完整克隆,获取完整 commit 历史 - - git fetch --unshallow --tags || git fetch --tags - - git checkout --detach $DRONE_COMMIT + - git fetch --tags + - | + if [ -n "${DRONE_PULL_REQUEST:-}" ]; then + git fetch origin "refs/pull/${DRONE_PULL_REQUEST}/head" + fi + - git checkout --detach "$DRONE_COMMIT" - echo "当前 commit $(git log --oneline -1)" - | PREVIOUS_RELEASE_TAG="" diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 42ff8875f..b28778cdd 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -88,6 +88,11 @@ UI-, channel-, and provider-specific behavior in the owning adapter or consumer. Do not widen a public service for one internal caller; keep single-consumer capabilities private until a real shared contract exists. +Agent creation accepts an optional `locale` (`en` or `zh`) to select the +default Soul file. English uses `soul.en.md`; Chinese and omitted values retain +`soul.md`. This is creation-time input, not persisted, and a selected +`AgentTemplate.soul_template` remains authoritative. + ## Public choices Do not invent public defaults, modes, operation sets, API fields, event fields, diff --git a/backend/agent_template/soul.en.md b/backend/agent_template/soul.en.md new file mode 100644 index 000000000..a4642f26a --- /dev/null +++ b/backend/agent_template/soul.en.md @@ -0,0 +1,15 @@ +# Soul — {{agent_name}} + +## Identity +- **Name**: {{agent_name}} +- **Creator**: {{creator_name}} +- **Created**: {{created_at}} + +## Personality +- Responsible and detail-oriented +- Proactively reports work progress +- Asks for clarification when information is uncertain + +## Boundaries +- Follows enterprise confidentiality policies +- Requires creator approval for sensitive actions diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 98cc1079f..7b4ab7765 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -232,6 +232,7 @@ async def _background_agent_setup( skill_ids: list[uuid.UUID], template_skill_folder_names: list[str], template_mcp_servers: list[str], + locale: str | None = None, ) -> None: """Run all creation tasks asynchronously with small, short-lived transactions.""" # 1. Initialize agent file system from template @@ -246,6 +247,7 @@ async def _background_agent_setup( agent, personality=personality, boundaries=boundaries, + locale=locale, ) await db.commit() except Exception as e: @@ -565,6 +567,7 @@ async def create_agent( skill_ids=list(data.skill_ids or []), template_skill_folder_names=folder_names, template_mcp_servers=template_mcp_servers, + locale=data.locale, ) return out diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 76d3af6fa..eb145c3e3 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -2,6 +2,7 @@ import uuid from datetime import datetime +from typing import Literal from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator @@ -216,6 +217,7 @@ class UserUpdate(BaseModel): class AgentCreate(BaseModel): name: str = Field(min_length=2, max_length=100, description="Agent name, 2-100 characters") agent_type: str = "native" # native | openclaw + locale: Literal["en", "zh"] | None = None role_description: str = Field(default="", max_length=500, description="Role description, max 500 characters") bio: str | None = None welcome_message: str | None = None diff --git a/backend/app/services/agent_manager.py b/backend/app/services/agent_manager.py index af3058413..49e2ed4b3 100644 --- a/backend/app/services/agent_manager.py +++ b/backend/app/services/agent_manager.py @@ -93,7 +93,8 @@ async def _materialize_entry(self, storage, storage_key: str, local_root: Path) local_path.write_bytes(await storage.read_bytes(storage_key)) async def initialize_agent_files(self, db: AsyncSession, agent: Agent, - personality: str = "", boundaries: str = "") -> None: + personality: str = "", boundaries: str = "", + locale: str | None = None) -> None: """Copy template files and customize for this agent.""" agent_dir = self._agent_dir(agent.id) template_dir = self._template_dir() @@ -113,7 +114,10 @@ async def initialize_agent_files(self, db: AsyncSession, agent: Agent, if src.is_dir(): continue rel = src.relative_to(template_dir).as_posix() - if rel == "tasks.json" or rel == "todo.json" or rel.startswith("enterprise_info/"): + if ( + rel in {"tasks.json", "todo.json", "soul.en.md"} + or rel.startswith("enterprise_info/") + ): continue tasks.append( storage.write_bytes( @@ -154,6 +158,11 @@ async def initialize_agent_files(self, db: AsyncSession, agent: Agent, selected_soul = template_result.scalar_one_or_none() if isinstance(selected_soul, str) and selected_soul.strip(): template_content = selected_soul + elif locale == "en": + english_soul_path = template_dir / "soul.en.md" + if not english_soul_path.is_file(): + raise FileNotFoundError(f"English agent Soul template not found: {english_soul_path}") + template_content = english_soul_path.read_text(encoding="utf-8") if template_content is None and await storage.exists(soul_key): template_content = await storage.read_text(soul_key, encoding="utf-8", errors="replace") soul_content = _render_soul_template( diff --git a/backend/tests/test_agent_manager_soul.py b/backend/tests/test_agent_manager_soul.py index 685608eb8..f32c7caa0 100644 --- a/backend/tests/test_agent_manager_soul.py +++ b/backend/tests/test_agent_manager_soul.py @@ -1,3 +1,147 @@ +import uuid +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +class _MemoryStorage: + def __init__(self): + self.objects: dict[str, bytes] = {} + + async def exists(self, key): + return key in self.objects + + async def is_dir(self, key): + return any(stored_key.startswith(f"{key}/") for stored_key in self.objects) + + async def write_bytes(self, key, value): + self.objects[key] = value + + async def write_text(self, key, value, encoding="utf-8"): + self.objects[key] = value.encode(encoding) + + async def read_text(self, key, encoding="utf-8", errors=None): + return self.objects[key].decode(encoding, errors=errors) + + +class _Result: + def __init__(self, value): + self.value = value + + def scalar_one_or_none(self): + return self.value + + +class _Database: + def __init__(self, selected_soul=None): + self.selected_soul = selected_soul + + async def execute(self, _query): + return _Result(self.selected_soul) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("locale", "expected_text"), + [ + ("en", "Responsible and detail-oriented"), + ("zh", "认真负责、注重细节"), + (None, "认真负责、注重细节"), + ], +) +async def test_agent_initialization_selects_default_soul_by_locale( + monkeypatch, locale, expected_text +): + from app.services import agent_manager as agent_manager_module + + storage = _MemoryStorage() + monkeypatch.setattr(agent_manager_module, "get_storage_backend", lambda: storage) + monkeypatch.setattr( + agent_manager_module.settings, + "AGENT_TEMPLATE_DIR", + str(Path(__file__).parents[1] / "agent_template"), + ) + creator = SimpleNamespace(display_name="Ray") + + async def execute_query(_db, _query): + return _Result(creator) + + monkeypatch.setattr(agent_manager_module.query_dao, "execute", execute_query) + agent = SimpleNamespace( + id=uuid.uuid4(), + creator_id=uuid.uuid4(), + template_id=None, + name="Evidence Agent", + ) + + await agent_manager_module.AgentManager().initialize_agent_files( + _Database(), + agent, + locale=locale, + ) + + soul = storage.objects[f"{agent.id}/soul.md"].decode() + assert expected_text in soul + assert "Evidence Agent" in soul + assert "Ray" in soul + assert "{{agent_name}}" not in soul + assert "{{creator_name}}" not in soul + assert "{{created_at}}" not in soul + assert f"{agent.id}/soul.en.md" not in storage.objects + + +@pytest.mark.asyncio +async def test_explicit_agent_template_soul_takes_precedence_over_locale_default( + monkeypatch +): + from app.services import agent_manager as agent_manager_module + + storage = _MemoryStorage() + monkeypatch.setattr(agent_manager_module, "get_storage_backend", lambda: storage) + monkeypatch.setattr( + agent_manager_module.settings, + "AGENT_TEMPLATE_DIR", + str(Path(__file__).parents[1] / "agent_template"), + ) + + async def execute_query(_db, _query): + return _Result(SimpleNamespace(display_name="Ray")) + + monkeypatch.setattr(agent_manager_module.query_dao, "execute", execute_query) + agent = SimpleNamespace( + id=uuid.uuid4(), + creator_id=uuid.uuid4(), + template_id=uuid.uuid4(), + name="Selected Agent", + ) + db = _Database( + selected_soul="# Selected Template\n\n## Identity\nCreated for {{agent_name}}" + ) + + await agent_manager_module.AgentManager().initialize_agent_files( + db, + agent, + locale="en", + ) + + soul = storage.objects[f"{agent.id}/soul.md"].decode() + assert soul == "# Selected Template\n\n## Identity\nCreated for Selected Agent" + assert "Responsible and detail-oriented" not in soul + + +def test_agent_create_locale_is_optional_and_validated(): + from pydantic import ValidationError + + from app.schemas.schemas import AgentCreate + + assert AgentCreate(name="Agent").locale is None + assert AgentCreate(name="Agent", locale="en").locale == "en" + assert AgentCreate(name="Agent", locale="zh").locale == "zh" + with pytest.raises(ValidationError): + AgentCreate(name="Agent", locale="fr") + + def test_agent_soul_template_never_receives_role_description_metadata(): from app.services.agent_manager import _render_soul_template diff --git a/frontend/src/components/CustomAgentModal.tsx b/frontend/src/components/CustomAgentModal.tsx index a9d3ce62b..a2caaae05 100644 --- a/frontend/src/components/CustomAgentModal.tsx +++ b/frontend/src/components/CustomAgentModal.tsx @@ -141,6 +141,7 @@ export default function CustomAgentModal({ open, initialMode = 'native', onClose const payload: any = { name: trimmedName, agent_type: mode, + locale: i18n.language.startsWith('zh') ? 'zh' : 'en', role_description: roleDescription.trim() || undefined, permission_scope_type: visibility === 'company' ? 'company' : visibility === 'custom' ? 'custom' : 'user', permission_scope_ids: [], diff --git a/frontend/src/components/PostHireSettingsModal.tsx b/frontend/src/components/PostHireSettingsModal.tsx index 216d1e7d2..bd484110a 100644 --- a/frontend/src/components/PostHireSettingsModal.tsx +++ b/frontend/src/components/PostHireSettingsModal.tsx @@ -130,6 +130,7 @@ export default function PostHireSettingsModal({ template, open, onClose, onDone name: localized.name, role_description: localized.description, template_id: template.id, + locale: isChinese ? 'zh' : 'en', primary_model_id: modelId || undefined, permission_access_level: 'manage', }; diff --git a/frontend/src/pages/AgentCreate.tsx b/frontend/src/pages/AgentCreate.tsx index 95cb4230f..1b69f41e4 100644 --- a/frontend/src/pages/AgentCreate.tsx +++ b/frontend/src/pages/AgentCreate.tsx @@ -219,6 +219,7 @@ export default function AgentCreate() { createMutation.mutate({ name: form.name, agent_type: agentType, + locale: i18n.language?.startsWith('zh') ? 'zh' : 'en', role_description: form.role_description, personality: agentType === 'native' ? form.personality : undefined, boundaries: agentType === 'native' ? form.boundaries : undefined,