CI / test (push) Canceled after 0s
fix các bug theo yêu cầu https://fptsoftware362-my.sharepoint.com/❌/g/personal/nampdt_fpt_com/IQAHBJ4A9xqDTLgvt2bhukJEAdRB5LRz2hbJpTivvIiBSYM?wdExp=TEAMS-TREATMENT&web=1&isSPOFile=1&ovuser=f01e930a-b52e-42b1-b70f-a8882b5d043b%2CAnhTNM1%40fpt.com&clickparams=eyJBcHBOYW1lIjoiVGVhbXMtRGVza3RvcCIsIkFwcFZlcnNpb24iOiI0OS8yNjA4MTMxOTMxNyIsIkhhc0ZlZGVyYXRlZFVzZXIiOmZhbHNlfQ%3D%3D --------- Co-authored-by: Duy Le Huu <duylh19@fpt.com> Reviewed-on: #10 Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
224 lines
10 KiB
Python
224 lines
10 KiB
Python
"""Admin-managed Agent catalog — named agent presets every machine shares.
|
|
|
|
An *admin agent* is defined once by the Admin (Monitoring → Agents Admin):
|
|
a name, the app function it performs (picked from a fixed droplist —
|
|
search / monitor / cowork / graphrag / schedule / security), optional extra
|
|
instructions, and the model to run on. The model defaults to each machine's
|
|
own Settings model when left empty; when the Admin pins one, every machine
|
|
runs that agent on the pinned model.
|
|
|
|
Storage mirrors ``accounts.py``'s pattern: one JSON per agent under
|
|
``<shared_dir>/agents_admin/`` so the catalog syncs across machines through
|
|
the same OneDrive/network share the accounts already use (the Admin edits,
|
|
other machines pick the change up next refresh once the share syncs). With
|
|
no shared folder configured it falls back to a local folder so the feature
|
|
still works single-machine.
|
|
|
|
This catalog is deliberately SEPARATE from ``custom_agents.py`` (per-user
|
|
Flow sub-agent presets stored locally): these are org-wide, admin-owned, and
|
|
selectable from the Cowork tab's Agent picker and the Schedule Task editor.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import uuid
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
from ..config import CONFIG_DIR
|
|
|
|
# The app functions an agent can be assigned to (droplist in the editor).
|
|
# Labels come from i18n keys ``agents_admin.kind.<kind>``.
|
|
TASK_KINDS = ("search", "monitor", "cowork", "graphrag", "schedule", "security", "help")
|
|
|
|
# Stable id for the built-in in-app Help assistant (the floating icon widget).
|
|
# Seeded once so the Admin can pick its provider/model in Agents Admin, while
|
|
# the widget always looks it up by this id.
|
|
HELP_AGENT_ID = "help-agent-builtin"
|
|
|
|
# Base instructions injected for each kind — the admin's own prompt (if any)
|
|
# is appended after these.
|
|
_KIND_PROMPTS = {
|
|
"search": ("You are a dedicated SEARCH agent: locate the requested information in the "
|
|
"provided files/folders/links and answer with precise findings and their "
|
|
"sources. Do not create files unless explicitly asked."),
|
|
"monitor": ("You are a dedicated MONITORING agent: review the provided logs/data for "
|
|
"anomalies, errors, security events and trends, and report a concise "
|
|
"status summary with anything needing attention first."),
|
|
"cowork": "",
|
|
"graphrag": ("You are a dedicated KNOWLEDGE agent: answer strictly from the project's "
|
|
"knowledge files/graph, citing which file each fact came from."),
|
|
"schedule": ("You are a dedicated TASK agent executing a scheduled job: complete the "
|
|
"task end-to-end without asking questions, and save the deliverable."),
|
|
"security": ("You are a dedicated SECURITY agent: review the given prompt/attachment/"
|
|
"command against the org security rules and decide whether it is safe to "
|
|
"allow. Reply strictly with the requested JSON verdict; err on the side of "
|
|
"blocking anything that could exfiltrate data or damage the system."),
|
|
"help": ("You are the in-app HELP assistant for this desktop application. Your ONLY job "
|
|
"is to help the user understand and use THIS app: which screen they are on, what "
|
|
"they can do there, and how to get things done. Be concise, friendly and practical.\n"
|
|
"A handbook of this app's REAL screens and buttons is appended below, together with "
|
|
"the screen the user currently has open. Answer from those two, never from how other "
|
|
"software you know is laid out. If the handbook does not cover something, say so "
|
|
"instead of guessing a menu path.\n"
|
|
"STRICT RULES:\n"
|
|
"- Answer ONLY questions about using this app. If asked to do anything else "
|
|
"(write code for other purposes, do general research, chit-chat, run tasks, "
|
|
"act as a general assistant), politely decline and steer back to app help.\n"
|
|
"- You have no tools and cannot perform actions — you only explain and guide.\n"
|
|
"- ALWAYS reply in the SAME language the user wrote their message in, "
|
|
"regardless of the app's display language."),
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class AdminAgent:
|
|
"""Một agent chuyên trách do quản trị cấu hình: prompt riêng, provider và model riêng."""
|
|
agent_id: str
|
|
name: str
|
|
task_kind: str = "cowork"
|
|
prompt: str = "" # admin's extra instructions (appended to the kind's base)
|
|
provider: str = "" # "" = each machine's active provider
|
|
model: str = "" # "" = each machine's Settings model for that provider
|
|
enabled: bool = True
|
|
updated: str = ""
|
|
updated_by: str = ""
|
|
|
|
def effective_prompt(self) -> str:
|
|
"""Prompt hệ thống thật sự dùng: prompt mặc định theo loại việc, rồi tới phần
|
|
quản trị viết thêm.
|
|
"""
|
|
parts = [_KIND_PROMPTS.get(self.task_kind, ""), (self.prompt or "").strip()]
|
|
return "\n\n".join(p for p in parts if p)
|
|
|
|
|
|
def agents_admin_dir(shared_dir: str = "") -> Path:
|
|
"""Shared catalog folder when a shared dir is configured (cross-machine
|
|
sync), else a local fallback so the feature works single-machine too."""
|
|
if (shared_dir or "").strip():
|
|
return Path(shared_dir).expanduser() / "agents_admin"
|
|
return CONFIG_DIR / "agents_admin"
|
|
|
|
|
|
def _slug(name: str) -> str:
|
|
"""Định danh an toàn cho tên file, suy từ tên agent."""
|
|
s = re.sub(r"[^\w\-]+", "-", (name or "").strip().lower()).strip("-")
|
|
return s or "agent"
|
|
|
|
|
|
def new_agent(name: str, task_kind: str = "cowork", prompt: str = "",
|
|
provider: str = "", model: str = "", updated_by: str = "") -> AdminAgent:
|
|
"""Tạo một agent quản trị mới; loại việc lạ thì rơi về 'cowork'.
|
|
|
|
Id ghép slug với 6 ký tự ngẫu nhiên để hai agent trùng tên không đè file nhau.
|
|
"""
|
|
return AdminAgent(
|
|
agent_id=f"{_slug(name)}-{uuid.uuid4().hex[:6]}",
|
|
name=name.strip(), task_kind=task_kind if task_kind in TASK_KINDS else "cowork",
|
|
prompt=prompt, provider=provider, model=model, enabled=True,
|
|
updated=datetime.now().isoformat(timespec="seconds"), updated_by=updated_by,
|
|
)
|
|
|
|
|
|
def save_agent(agent: AdminAgent, directory: Path) -> Path:
|
|
"""Ghi một agent ra ``<agent_id>.json``."""
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
path = directory / f"{agent.agent_id}.json"
|
|
path.write_text(json.dumps(asdict(agent), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def load_agent(agent_id: str, directory: Path) -> Optional[AdminAgent]:
|
|
"""Đọc một agent theo id; không có thì trả ``None``."""
|
|
path = directory / f"{agent_id}.json"
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
known = {f for f in AdminAgent.__dataclass_fields__}
|
|
return AdminAgent(**{k: v for k, v in data.items() if k in known})
|
|
except (OSError, json.JSONDecodeError, TypeError):
|
|
return None
|
|
|
|
|
|
def list_agents(directory: Path, enabled_only: bool = False) -> List[AdminAgent]:
|
|
"""Liệt kê agent trong thư mục; ``enabled_only`` chỉ lấy agent đang bật."""
|
|
if not directory.exists():
|
|
return []
|
|
out: List[AdminAgent] = []
|
|
for path in sorted(directory.glob("*.json")):
|
|
agent = load_agent(path.stem, directory)
|
|
if agent is not None and (agent.enabled or not enabled_only):
|
|
out.append(agent)
|
|
out.sort(key=lambda a: a.name.lower())
|
|
return out
|
|
|
|
|
|
def ensure_help_agent(directory: Path) -> AdminAgent:
|
|
"""Return the built-in Help assistant, seeding it on first run so it shows
|
|
up in Agents Admin for the admin to pick a provider/model. Idempotent: an
|
|
existing entry (possibly with admin edits) is loaded and returned as-is —
|
|
only its immutable identity (id / kind) is guaranteed. The floating Help
|
|
widget always resolves the agent through this."""
|
|
existing = load_agent(HELP_AGENT_ID, directory)
|
|
if existing is not None:
|
|
return existing
|
|
agent = AdminAgent(
|
|
agent_id=HELP_AGENT_ID, name="App Help Assistant", task_kind="help",
|
|
prompt="", provider="", model="", enabled=True,
|
|
updated=datetime.now().isoformat(timespec="seconds"), updated_by="system",
|
|
)
|
|
try:
|
|
save_agent(agent, directory)
|
|
except OSError:
|
|
pass # read-only share — still usable in-memory this session
|
|
return agent
|
|
|
|
|
|
def delete_agent(agent_id: str, directory: Path) -> bool:
|
|
"""Xoá file agent; trả về ``True`` nếu có file để xoá."""
|
|
try:
|
|
(directory / f"{agent_id}.json").unlink()
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def build_agent_provider(ctx, agent: Optional[AdminAgent]):
|
|
"""The provider an admin agent runs on: its own pinned provider/model
|
|
when set, else the machine's active provider with its Settings model —
|
|
exactly the default the requirement asks for ("default là Model được
|
|
chọn trong setting")."""
|
|
if agent is None:
|
|
return ctx.build_active_provider()
|
|
return ctx.build_provider_for(agent.provider or None, agent.model or None)
|
|
|
|
|
|
def check_agent(ctx, agent: AdminAgent) -> tuple[bool, str]:
|
|
"""Best-effort OPERATIONAL health check for one admin agent: can its
|
|
EFFECTIVE provider (its pinned provider/model, or — when unset — the
|
|
machine's Settings provider/model) actually be reached right now?
|
|
|
|
Probes the provider's ``list_models()`` (the same lightweight call the
|
|
'Load models' button makes) rather than spending a real chat turn.
|
|
Returns ``(ok, message)`` and NEVER raises, so the UI can render a status
|
|
without a broken agent config taking the whole table down."""
|
|
if not agent.enabled:
|
|
return False, "disabled"
|
|
try:
|
|
provider = build_agent_provider(ctx, agent)
|
|
except Exception as exc: # noqa: BLE001 — a bad config must not crash the check
|
|
return False, f"config error: {exc}"
|
|
try:
|
|
models = provider.list_models()
|
|
except Exception as exc: # noqa: BLE001 — gateway unreachable / auth / TLS…
|
|
return False, str(exc)[:200]
|
|
if not models:
|
|
return False, "no models returned by provider"
|
|
if agent.model and agent.model not in models:
|
|
return True, f"reachable — note: pinned model '{agent.model}' not in provider's list"
|
|
return True, "reachable"
|