Files
cowork-local/core/admin_agents.py
T
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

222 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 — its screens and features "
"(Dashboard, Schedule, Workspace with Cowork chat and the Co4E flow studio, "
"Monitoring, Connectors, Settings), how to get things done in it, and how to "
"troubleshoot using it. Be concise, friendly and practical.\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"