Prompt cũ chỉ liệt kê TÊN các màn hình, nên model không có cách nào biết trên mỗi màn có gì và nó lấp khoảng trống bằng thứ nghe hợp lý: người dùng thật đã được hướng dẫn vào "Dashboard → Add Project" và "Settings → Project Settings → New Project". Không một thứ nào trong đó tồn tại. Người dùng đi tìm rồi mới phát hiện ra — câu trả lời trôi chảy mà sai còn tệ hơn câu "tôi không biết". Ba thứ ghép thêm vào prompt: - docs/help/app_guide.md — sổ tay viết tay, bám mã nguồn thật, có test chốt rằng nó nhắc đủ mọi màn trong docs/screens/manifest.json; - luật chống bịa, kèm ví dụ few-shot nêu đúng câu trả lời sai đã xảy ra cạnh câu đúng, và một ví dụ dạy nó NÓI KHÔNG BIẾT; - ngữ cảnh sống: màn hình đang mở và nhãn các nút/tab ĐANG hiện. Ngữ cảnh sống đọc từ cây widget thật, KHÔNG từ docs/screens/controls.json: file đó trích tự động nhưng đã cũ — 5/41 file trong đó không còn tồn tại và nó không có file nào trong presentation/ (chưa sinh lại sau refactor R08). Nạp nó vào prompt là dạy trợ lý về nút của những file đã bị xoá. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.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"
|