Files
deerflow-code/offline-backend-20260512/backend/app/gateway/routers/strategy_agents.py
T

803 lines
29 KiB
Python

"""Global strategy-selection agent role management.
The strategy workbench owns three fixed roles (intent, judge and solution)
plus an administrator-managed set of angle roles. Role metadata is stored in
``strategy_agent_roles`` while model/SOUL/skills remain in the normal
folder-backed agent configuration, so the same configuration is shared by
every project and every user.
"""
from __future__ import annotations
import asyncio
import logging
import os
import shutil
import weakref
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Literal
from uuid import uuid4
import yaml
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.auth.disabled_mode import is_auth_disabled
from app.gateway.deps import (
get_agent_store,
get_config,
get_current_user_from_request,
get_skill_store,
get_strategy_agent_role_store,
)
from app.gateway.routers import agents as agents_router
from app.gateway.routers._strategy_selection_seed import (
STRATEGY_ANGLE_AGENT_PREFIX,
default_strategy_agent_roles,
ensure_strategy_selection_agents,
)
from deerflow.config.paths import get_paths
from deerflow.persistence.agents import AgentStore
from deerflow.persistence.strategy_agent_roles import (
MemoryStrategyAgentRoleStore,
StrategyAgentRoleStore,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/strategy-agents", tags=["strategy-agents"])
StrategyAgentCategory = Literal["intent", "angle", "judge", "solution"]
_DYNAMIC_ANGLE_TOOL_GROUPS = ["web", "file:read"]
_ROLE_SIDECAR = ".strategy-role.yaml"
_PROCESS_LOCKS: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
def _process_lock(agent_id: str) -> asyncio.Lock:
key = agent_id.casefold()
lock = _PROCESS_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_PROCESS_LOCKS[key] = lock
return lock
def _open_cross_process_lock(agent_id: str):
lock_dir = get_paths().agents_dir / ".strategy-locks"
lock_dir.mkdir(parents=True, exist_ok=True)
path = lock_dir / f"{agent_id}.lock"
handle = open(path, "a+b") # noqa: SIM115 - owned by async context below
if path.stat().st_size == 0:
handle.write(b"\0")
handle.flush()
return handle
def _try_cross_process_lock(handle) -> bool:
handle.seek(0)
try:
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except OSError:
return False
def _release_cross_process_lock(handle) -> None:
try:
handle.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
finally:
handle.close()
@asynccontextmanager
async def _strategy_mutation_guard(agent_id: str):
"""Serialize one strategy agent across coroutines and worker processes.
The zero-byte lock-file namespace is durable, but OS locks are tied to the
open handle and are automatically released if a worker dies. The local
lock table uses weak values so one-off dynamic ids do not leak forever.
"""
async with _process_lock(agent_id):
handle = _open_cross_process_lock(agent_id)
acquired = False
try:
while not acquired:
acquired = _try_cross_process_lock(handle)
if not acquired:
await asyncio.sleep(0.05)
yield
finally:
if acquired:
# Unlock synchronously: this is a tiny OS call and must not be
# skipped if the request is already being cancelled.
_release_cross_process_lock(handle)
else:
handle.close()
def _write_role_sidecar(agent_id: str, role: dict) -> None:
payload = {
"agent_id": agent_id,
"category": "angle",
"fixed": False,
"enabled": bool(role.get("enabled", True)),
"sort_order": int(role.get("sort_order", 0)),
}
agents_router._atomic_write_text(
get_paths().agent_dir(agent_id) / _ROLE_SIDECAR,
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
)
def _read_optional_text(path) -> str | None:
return path.read_text(encoding="utf-8") if path.is_file() else None
def _restore_optional_text(path, content: str | None) -> None:
if content is None:
path.unlink(missing_ok=True)
return
agents_router._atomic_write_text(path, content)
async def _rollback_strategy_update(
*,
agent_id: str,
agent_store: AgentStore,
role_store: StrategyAgentRoleStore,
record: dict,
role: dict,
extras: dict,
file_snapshot: dict,
) -> None:
"""Best-effort compensation for the mixed DB/filesystem update."""
try:
agent_dir = get_paths().agent_dir(agent_id)
await asyncio.to_thread(
_restore_optional_text,
agent_dir / "config.yaml",
file_snapshot["config"],
)
await asyncio.to_thread(
_restore_optional_text,
agent_dir / "SOUL.md",
file_snapshot["soul"],
)
await asyncio.to_thread(
_restore_optional_text,
agent_dir / _ROLE_SIDECAR,
file_snapshot["sidecar"],
)
await agent_store.update_any(
agent_id,
{
"name": record["name"],
"description": record.get("description") or "",
},
)
await agent_store.replace_skills(agent_id, extras["skills"])
await agent_store.set_extras(
agent_id,
tool_groups=extras["tool_groups"],
model=extras["model"],
)
await role_store.update_role(
agent_id,
enabled=bool(role["enabled"]),
sort_order=int(role["sort_order"]),
)
except Exception:
logger.exception("Failed to compensate strategy-agent update for '%s'", agent_id)
async def _recover_memory_angle_roles(role_store: StrategyAgentRoleStore) -> None:
"""Recover dynamic role metadata after a memory-mode process restart."""
if not isinstance(role_store, MemoryStrategyAgentRoleStore):
return
agents_dir = get_paths().agents_dir
if not agents_dir.is_dir():
return
for agent_dir in agents_dir.iterdir():
sidecar = agent_dir / _ROLE_SIDECAR
if not sidecar.is_file():
continue
try:
raw = yaml.safe_load(sidecar.read_text(encoding="utf-8")) or {}
agent_id = str(raw.get("agent_id") or "")
if agent_id != agent_dir.name or not agent_id.startswith(STRATEGY_ANGLE_AGENT_PREFIX) or raw.get("category") != "angle" or bool(raw.get("fixed", False)):
raise ValueError("invalid dynamic angle sidecar")
existing = await role_store.get_role(agent_id)
if existing is None:
await role_store.create_role(
{
"agent_id": agent_id,
"category": "angle",
"fixed": False,
"enabled": bool(raw.get("enabled", True)),
"sort_order": int(raw.get("sort_order", 0)),
}
)
elif not bool(existing.get("fixed")) and existing.get("category") == "angle":
# Memory stores are rebuilt with code defaults first. The
# sidecar is the durable authority for every non-fixed angle,
# including the six shipped angles, so disabled defaults do
# not silently revive after a worker restart.
await role_store.update_role(
agent_id,
enabled=bool(raw.get("enabled", True)),
sort_order=int(raw.get("sort_order", 0)),
)
except Exception:
logger.exception("Ignoring invalid strategy role sidecar at %s", sidecar)
def _default_angle_soul(name: str, description: str) -> str:
focus = description.strip() or "围绕任务目标,从本角度给出独立、可执行的策略建议"
return (
f"# 角色\n\n你是“{name}”,负责{focus}。\n\n"
"# 工作要求\n\n"
"1. 先核对任务目标、约束、前置条件和时间范围;信息不足时明确假设。\n"
"2. 只从自己的专业角度分析,不替代其他角度,也不冒充评判或方案整理角色。\n"
"3. 输出具体策略、实施步骤、预期收益、主要风险、触发条件和退出条件。\n"
"4. 对时效性事实优先检索核验并标注依据;不得编造来源。\n"
"5. 遵守法律、安全与非升级边界,最终交付使用清晰的 Markdown。\n"
)
class StrategyAgentItem(BaseModel):
id: str
category: StrategyAgentCategory
fixed: bool
enabled: bool
sort_order: int
name: str
description: str = ""
model: str | None = None
soul: str | None = None
skills: list[str] | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class StrategyAgentListResponse(BaseModel):
agents: list[StrategyAgentItem]
class StrategyAngleCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str = Field(default="", max_length=2000)
soul: str = Field(default="", max_length=200_000)
model: str | None = Field(default=None, max_length=200)
skills: list[str] = Field(default_factory=list, max_length=100)
sort_order: int | None = Field(default=None, ge=-10_000, le=10_000)
class StrategyAgentUpdateRequest(BaseModel):
name: str | None = Field(default=None, max_length=100)
description: str | None = Field(default=None, max_length=2000)
soul: str | None = Field(default=None, max_length=200_000)
model: str | None = Field(default=None, max_length=200)
skills: list[str] | None = Field(default=None, max_length=100)
enabled: bool | None = None
sort_order: int | None = Field(default=None, ge=-10_000, le=10_000)
async def _require_admin(request: Request) -> None:
"""Require an administrator, except in explicit auth-disabled mode."""
user = getattr(request.state, "user", None)
if user is None:
if is_auth_disabled():
return
user = await get_current_user_from_request(request)
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="策略智能体管理仅限管理员")
def _normalize_model(request: Request, raw_model: str | None) -> str | None:
return agents_router._validate_strategy_agent_model(
raw_model,
app_config=get_config(request),
)
async def _ensure_registry(
agent_store: AgentStore,
role_store: StrategyAgentRoleStore,
) -> None:
"""Seed missing role metadata and repair missing default agent rows."""
defaults = default_strategy_agent_roles()
await role_store.ensure_defaults(defaults)
await _recover_memory_angle_roles(role_store)
default_ids = [str(row["agent_id"]) for row in defaults]
for agent_id in default_ids:
existing = await agent_store.get_any(agent_id)
if existing is not None and existing.get("user_id") is not None:
# Never let legacy sync's ensure_builtin semantics silently treat a
# user-owned collision as one of the fixed global roles.
raise HTTPException(
status_code=409,
detail=f"Reserved strategy agent '{agent_id}' is owned by a user",
)
roles = await role_store.list_roles(include_disabled=True)
missing = [str(role["agent_id"]) for role in roles if await agent_store.get_any(str(role["agent_id"])) is None]
if missing:
# This path is normally handled by lifespan. It also rebuilds the
# memory-mode agent table from durable dynamic-angle folders.
await asyncio.to_thread(ensure_strategy_selection_agents)
await agents_router._sync_legacy_agents(agent_store)
for role in roles:
agent_id = str(role["agent_id"])
record = await agent_store.get_any(agent_id)
if record is None:
raise HTTPException(
status_code=503,
detail=f"Strategy agent '{agent_id}' configuration is unavailable",
)
if str(record.get("id") or "") != agent_id or record.get("user_id") is not None:
raise HTTPException(
status_code=409,
detail=f"Strategy agent '{agent_id}' must be a canonical global agent",
)
async def ensure_strategy_agent_registry(app) -> None:
"""Synchronize one worker's registry, including memory-mode sidecars."""
agent_store = getattr(app.state, "agent_store", None)
role_store = getattr(app.state, "strategy_agent_role_store", None)
if agent_store is None or role_store is None:
raise RuntimeError("Strategy agent persistence is unavailable")
await _ensure_registry(agent_store, role_store)
async def _to_item(
role: dict,
record: dict,
*,
include_config: bool,
) -> StrategyAgentItem:
if include_config:
agent = await agents_router._record_to_response_async(
record,
include_soul=True,
include_extras=True,
)
return StrategyAgentItem(
id=agent.id,
category=role["category"],
fixed=bool(role["fixed"]),
enabled=bool(role["enabled"]),
sort_order=int(role["sort_order"]),
name=agent.name,
description=agent.description,
model=agent.model,
soul=agent.soul,
skills=agent.skills,
created_at=agent.created_at,
updated_at=agent.updated_at,
)
# Public consumers need only identity and lifecycle metadata. Avoid
# leaking prompts/model/skills and avoid synchronous filesystem reads.
return StrategyAgentItem(
id=str(record["id"]),
category=role["category"],
fixed=bool(role["fixed"]),
enabled=bool(role["enabled"]),
sort_order=int(role["sort_order"]),
name=str(record["name"]),
description=str(record.get("description") or ""),
created_at=record.get("created_at"),
updated_at=record.get("updated_at"),
)
async def _get_role_and_agent(
request: Request,
agent_id: str,
) -> tuple[dict, dict]:
role_store = get_strategy_agent_role_store(request)
agent_store = get_agent_store(request)
await _ensure_registry(agent_store, role_store)
role = await role_store.get_role(agent_id)
if role is None:
raise HTTPException(status_code=404, detail=f"Strategy agent '{agent_id}' not found")
record = await agent_store.get_any(agent_id)
if record is None:
raise HTTPException(
status_code=409,
detail=f"Strategy agent '{agent_id}' has no backing agent configuration",
)
if record.get("user_id") is not None:
raise HTTPException(
status_code=409,
detail=f"Strategy agent '{agent_id}' must be globally owned",
)
canonical_role_id = str(role.get("agent_id") or "")
if canonical_role_id != agent_id:
raise HTTPException(
status_code=409,
detail=(f"Strategy agent id casing mismatch; use canonical id '{canonical_role_id}'"),
)
agents_router._require_canonical_record_id(agent_id, record)
return role, record
@router.get("", response_model=StrategyAgentListResponse)
async def list_strategy_agents(
request: Request,
include_disabled: bool = False,
include_config: bool = False,
) -> StrategyAgentListResponse:
"""List the shared role catalog; sensitive configuration is admin-only."""
if include_disabled or include_config:
await _require_admin(request)
role_store = get_strategy_agent_role_store(request)
agent_store = get_agent_store(request)
await _ensure_registry(agent_store, role_store)
roles = await role_store.list_roles(include_disabled=include_disabled)
items: list[StrategyAgentItem] = []
for role in roles:
record = await agent_store.get_any(str(role["agent_id"]))
if record is None:
if bool(role["enabled"]):
raise HTTPException(
status_code=503,
detail=(f"Strategy agent '{role['agent_id']}' configuration is unavailable"),
)
continue
if str(record.get("id") or "") != str(role["agent_id"]) or record.get("user_id") is not None:
raise HTTPException(
status_code=409,
detail=f"Strategy agent '{role['agent_id']}' must be a canonical global agent",
)
items.append(await _to_item(role, record, include_config=include_config))
return StrategyAgentListResponse(agents=items)
@router.post(
"/angles",
response_model=StrategyAgentItem,
status_code=201,
)
async def create_strategy_angle(
payload: StrategyAngleCreateRequest,
request: Request,
) -> StrategyAgentItem:
"""Create one global angle agent. Fixed role categories are not creatable."""
await _require_admin(request)
name = agents_router._validate_display_name(payload.name)
model = _normalize_model(request, payload.model)
skills = await agents_router._validate_strategy_agent_skills(
payload.skills,
app_config=get_config(request),
skill_store=get_skill_store(request),
)
agent_store = get_agent_store(request)
role_store = get_strategy_agent_role_store(request)
await _ensure_registry(agent_store, role_store)
agent_id = f"{STRATEGY_ANGLE_AGENT_PREFIX}{uuid4().hex}"
# The generated id has already passed the fixed prefix/storage contract;
# all filesystem and persistence mutations happen under the cross-worker
# guard below.
agent_id = agents_router._validate_storage_id(agent_id)
async with _strategy_mutation_guard(agent_id):
return await _create_strategy_angle_locked(
agent_id=agent_id,
payload=payload,
name=name,
model=model,
skills=skills,
agent_store=agent_store,
role_store=role_store,
)
async def _cleanup_failed_create(
agent_id: str,
agent_store: AgentStore,
role_store: StrategyAgentRoleStore,
) -> None:
async def attempt(label: str, operation) -> None:
try:
result = operation()
if asyncio.iscoroutine(result):
await result
except BaseException: # cleanup must continue through cancellation/failures
logger.exception("Failed create cleanup step %s for '%s'", label, agent_id)
await attempt("role", lambda: role_store.delete_role(agent_id))
await attempt("agent", lambda: agent_store.delete_builtin(agent_id))
await attempt("extras", lambda: agent_store.delete_extras_for_agent(agent_id))
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
await attempt("folder", lambda: asyncio.to_thread(shutil.rmtree, agent_dir))
async def _create_strategy_angle_locked(
*,
agent_id: str,
payload: StrategyAngleCreateRequest,
name: str,
model: str | None,
skills: list[str] | None,
agent_store: AgentStore,
role_store: StrategyAgentRoleStore,
) -> StrategyAgentItem:
agent_dir = get_paths().agent_dir(agent_id)
if await agent_store.get_any(agent_id) is not None or agent_dir.exists():
# Cryptographically improbable, but never overwrite a folder if the
# deployment restored a row without its registry metadata.
raise HTTPException(status_code=409, detail="Generated strategy agent id already exists")
sort_order = payload.sort_order
if sort_order is None:
current = await role_store.list_roles(include_disabled=True)
angle_orders = [int(row["sort_order"]) for row in current if row.get("category") == "angle"]
sort_order = (max(angle_orders) + 1) if angle_orders else 100
soul = (
payload.soul
if payload.soul.strip()
else _default_angle_soul(
name,
payload.description,
)
)
completed = False
try:
await asyncio.to_thread(
agents_router._write_agent_config,
agent_id,
name=name,
description=payload.description,
skills=skills,
model=model,
tool_groups=_DYNAMIC_ANGLE_TOOL_GROUPS,
)
await asyncio.to_thread(agents_router._write_agent_soul, agent_id, soul)
record = await agent_store.ensure_builtin(
{
"id": agent_id,
"name": name,
"description": payload.description,
"published": True,
}
)
await agent_store.replace_skills(agent_id, skills)
await agent_store.set_extras(
agent_id,
tool_groups=_DYNAMIC_ANGLE_TOOL_GROUPS,
model=model,
)
role = await role_store.create_role(
{
"agent_id": agent_id,
"category": "angle",
"fixed": False,
"enabled": True,
"sort_order": sort_order,
}
)
await asyncio.to_thread(_write_role_sidecar, agent_id, role)
completed = True
return await _to_item(role, record, include_config=True)
except asyncio.CancelledError:
raise
except HTTPException:
raise
except Exception as exc:
logger.exception("Failed to create strategy angle agent '%s'", agent_id)
raise HTTPException(
status_code=500,
detail="Failed to create strategy angle agent",
) from exc
finally:
if not completed:
cleanup = asyncio.create_task(_cleanup_failed_create(agent_id, agent_store, role_store))
try:
await asyncio.shield(cleanup)
except asyncio.CancelledError:
# A second cancellation must not leave a half-created global
# agent behind while the file lock is released.
await cleanup
raise
@router.put("/{agent_id}", response_model=StrategyAgentItem)
async def update_strategy_agent(
agent_id: str,
payload: StrategyAgentUpdateRequest,
request: Request,
) -> StrategyAgentItem:
"""Update configuration; fixed roles cannot change lifecycle or category."""
await _require_admin(request)
agent_id = agents_router._validate_storage_id(agent_id)
async with _strategy_mutation_guard(agent_id):
return await _update_strategy_agent_locked(agent_id, payload, request)
async def _update_strategy_agent_locked(
agent_id: str,
payload: StrategyAgentUpdateRequest,
request: Request,
) -> StrategyAgentItem:
role, record = await _get_role_and_agent(request, agent_id)
agent_store = get_agent_store(request)
fields = payload.model_fields_set
if bool(role["fixed"]):
if "enabled" in fields and payload.enabled is not True:
raise HTTPException(status_code=409, detail="Fixed strategy roles cannot be disabled")
if "sort_order" in fields and payload.sort_order != int(role["sort_order"]):
raise HTTPException(status_code=409, detail="Fixed strategy role order cannot be changed")
next_name = agents_router._validate_display_name(payload.name or "") if "name" in fields else str(record["name"])
next_description = payload.description or "" if "description" in fields else str(record.get("description") or "")
extras = await asyncio.to_thread(agents_router._load_config_extras, agent_id)
next_model = _normalize_model(request, payload.model) if "model" in fields else extras["model"]
next_skills = (
await agents_router._validate_strategy_agent_skills(
payload.skills,
app_config=get_config(request),
skill_store=get_skill_store(request),
)
if "skills" in fields
else extras["skills"]
)
agent_dir = get_paths().agent_dir(agent_id)
file_snapshot = await asyncio.to_thread(
lambda: {
"config": _read_optional_text(agent_dir / "config.yaml"),
"soul": _read_optional_text(agent_dir / "SOUL.md"),
"sidecar": _read_optional_text(agent_dir / _ROLE_SIDECAR),
}
)
original_record = dict(record)
original_role = dict(role)
role_store = get_strategy_agent_role_store(request)
try:
config_fields = {"name", "description", "model", "skills"}
if fields & config_fields:
await asyncio.to_thread(
agents_router._write_agent_config,
agent_id,
name=next_name,
description=next_description,
skills=next_skills,
model=next_model,
tool_groups=extras["tool_groups"],
common_questions=extras["common_questions"],
template_name=extras["template_name"],
seat_skill_directive=extras["seat_skill_directive"],
llmwiki_knowledge_base_ids=extras["llmwiki_knowledge_base_ids"],
)
await agent_store.replace_skills(agent_id, next_skills)
await agent_store.set_extras(
agent_id,
tool_groups=extras["tool_groups"],
model=next_model,
)
if "soul" in fields:
await asyncio.to_thread(
agents_router._write_agent_soul,
agent_id,
payload.soul or "",
)
if {"name", "description"} & fields:
updated = await agent_store.update_any(
agent_id,
{"name": next_name, "description": next_description},
)
if updated is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
record = updated
enabled = payload.enabled if "enabled" in fields and not bool(role["fixed"]) else None
sort_order = payload.sort_order if "sort_order" in fields and not bool(role["fixed"]) else None
if enabled is not None or sort_order is not None:
updated_role = await role_store.update_role(
agent_id,
enabled=enabled,
sort_order=sort_order,
)
if updated_role is None:
raise HTTPException(status_code=404, detail=f"Strategy agent '{agent_id}' not found")
role = updated_role
if not bool(role["fixed"]):
await asyncio.to_thread(_write_role_sidecar, agent_id, role)
except Exception:
await _rollback_strategy_update(
agent_id=agent_id,
agent_store=agent_store,
role_store=role_store,
record=original_record,
role=original_role,
extras=extras,
file_snapshot=file_snapshot,
)
raise
return await _to_item(role, record, include_config=True)
@router.delete("/{agent_id}", response_model=StrategyAgentItem)
async def disable_strategy_angle(agent_id: str, request: Request) -> StrategyAgentItem:
"""Soft-disable an angle while retaining its agent and historical links."""
await _require_admin(request)
agent_id = agents_router._validate_storage_id(agent_id)
async with _strategy_mutation_guard(agent_id):
return await _disable_strategy_angle_locked(agent_id, request)
async def _disable_strategy_angle_locked(
agent_id: str,
request: Request,
) -> StrategyAgentItem:
role, record = await _get_role_and_agent(request, agent_id)
if bool(role["fixed"]):
raise HTTPException(status_code=409, detail="Fixed strategy roles cannot be deleted")
if role.get("category") != "angle":
raise HTTPException(status_code=409, detail="Only strategy angle roles can be disabled")
updated = await get_strategy_agent_role_store(request).update_role(
agent_id,
enabled=False,
)
if updated is None:
raise HTTPException(status_code=404, detail=f"Strategy agent '{agent_id}' not found")
try:
await asyncio.to_thread(_write_role_sidecar, agent_id, updated)
except Exception:
await get_strategy_agent_role_store(request).update_role(
agent_id,
enabled=bool(role["enabled"]),
sort_order=int(role["sort_order"]),
)
raise
return await _to_item(updated, record, include_config=True)
__all__ = [
"StrategyAgentItem",
"StrategyAgentListResponse",
"StrategyAgentUpdateRequest",
"StrategyAngleCreateRequest",
"create_strategy_angle",
"disable_strategy_angle",
"ensure_strategy_agent_registry",
"list_strategy_agents",
"router",
"update_strategy_agent",
]