Add registration form redesign with completion flow for LibreChat-synced agents

Rebuilds the agent registration form into 7 governance sections (Identity,
Classification, Autonomy, IP, Tech Stack, Data Safety, Performance, Declarations)
and introduces a completion flow for agents that come in via the LibreChat
collector without the new required fields.

- New form fields: business_entity, client_scope, agent_classification,
  autonomy_level, ip_ownership, foundation_model, validated_by/date, evals_method,
  plus nested safety / pii / declarations objects and a registration_complete flag.
- registration_complete defaults to true for form-submitted agents and false for
  collector-created ones; existing agents are grandfathered via a startup migration.
- Owner-by-email lookup so LibreChat-synced agents surface in the user's "My Agents"
  view with an Incomplete badge and Complete CTA. Submitting the completion form
  reassigns created_by from the collector marker to the user.
- Daily APScheduler job sends a digest reminder email per owner with a 7-day
  cooldown and 4-nudge cap (configurable). Manual trigger via
  POST /api/admin/completion-reminders/send.
- Admin banner + modal for collector agents whose contact email doesn't match an
  active user, with one-click reassignment.
- Gemini audit extended to also return agent_classification and an autonomy hint;
  applied to agents on next batch run alongside discipline/department.
- New filter dimensions on agent management + admin dashboard: Business Entity,
  Agent Type, Autonomy, plus a Compliance Risks quick toggle.
- CSV export/import gains 21 columns covering all governance fields.
- Discipline 'Optimization' renamed to 'Optimisation' with idempotent startup
  migration; Gemini system prompt and template dropdowns updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nickviljoen 2026-05-03 18:26:55 +02:00
parent 32b08f8b0c
commit 54ecd31bdd
10 changed files with 2119 additions and 462 deletions

View file

@ -41,6 +41,12 @@ Create `.env` file with:
- `CLIENT_AGENT_NOTIFY_EMAILS`: Comma-separated list of emails for client agent notifications
- `WEEKLY_DIGEST_HOUR`: Hour (24h format) to send weekly digest on Mondays (default: 7)
#### Optional: Completion-Reminder Emails (LibreChat completion flow)
- `COMPLETION_REMINDER_COOLDOWN_DAYS`: Min days between reminders for the same user (default: 7)
- `COMPLETION_REMINDER_MAX_NUDGES`: After this many nudges, stop emailing the user (default: 4)
- `COMPLETION_REMINDER_HOUR`: Hour (24h) for the daily reminder cron (default: 8)
- `AGENTHUB_PUBLIC_URL`: Public absolute URL used in email links (e.g. `https://agenthub.example.com`). If unset, links are relative and won't work in email clients.
### Default Login Credentials
- Admin: `admin@agenthub.com` / `admin123`
- Test User: `test@example.com` / `testpass123`
@ -83,7 +89,7 @@ Create `.env` file with:
- All operations use ObjectId for MongoDB document IDs
**database.py**: MongoDB connection setup with Motor async client
- Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings`, `audit_history`
- Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings`, `audit_history`, `completion_reminders`
- `ensure_indexes()`: Creates compound unique index on `agent_ratings(agent_id, user_id)`, indexes on `verification_status`, `audit_status`, and `audit_history(agent_id, audit_date)`
**audit_analyzer.py**: Gemini-powered agent classification and audit system:
@ -254,7 +260,7 @@ Located in `templates/` directory:
- Async operations using Motor driver
- Indexed queries for performance
- Data aggregation for statistics
- Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings`, `audit_history`
- Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings`, `audit_history`, `completion_reminders`
## Development Guidelines
@ -316,4 +322,71 @@ Key dependencies from requirements.txt:
### Prompt Audit
- `POST /api/admin/audit/run` — Run Gemini audit batch. Optional JSON body: `{agent_id, unclassified_only}`. Returns `{status, total, audited_count, failed_count, skipped_count, results_summary}` (admin only)
- `GET /api/admin/audit/results` — Get all agents with audit data + `config_status.gemini_configured` (admin + readonly_admin)
- `PUT /api/admin/audit/{agent_id}/review` — Mark audit as reviewed/cleared with `{audit_status, reviewer_notes}` (admin only)
- `PUT /api/admin/audit/{agent_id}/review` — Mark audit as reviewed/cleared with `{audit_status, reviewer_notes}` (admin only)
### Registration Completion Flow (LibreChat-synced agents)
- `GET /api/agents/incomplete` — Current user's incomplete agents (admins see all). Used by the agent management page banner. Owner resolution: `created_by == user_id` OR (`created_by == "agent_collector_api"` AND `agent_contact_person == user_email`).
- `GET /agent-complete/{agent_id}` — HTML form (parameterised `agent_register.html` in completion mode). Pre-fills existing fields, required new governance fields blank. Auth: agent owner (by ID or email) or admin.
- `POST /agent-complete/{agent_id}` — Submit completion. Sets `registration_complete=True`, reassigns `created_by` from `"agent_collector_api"` to the submitting user's ID.
- `POST /api/admin/completion-reminders/send` — Manually trigger the daily reminder digest. Optional `?force=true` to bypass cooldown + max-nudges cap (admin only).
- `GET /api/admin/agents/unresolved-owner` — Collector-created agents whose `agent_contact_person` doesn't match an active user (admin only)
- `PUT /api/admin/agents/{agent_id}/reassign-owner` — Admin reassigns ownership; body `{new_contact_email, new_owner_user_id?}`. Resolves user by email if `new_owner_user_id` omitted (admin only)
## Registration Form Redesign (2026-05)
The agent registration form was rebuilt around 7 governance sections per `Indext2.html` mockup. See `PLAN-registration-form-redesign.md` for full design rationale.
### New schema fields on agents
Top-level (all `Optional`):
- `business_entity` (enum: OLIVER, DARE, Brandtech Group, Pencil, Jellyfish, Adjust, Other) — required on form
- `client_scope` (enum: `internal`, `all`, `specific`) — required on form. Maps to legacy `client` field: `specific``yes`, else→`no`. Both fields stay in sync to preserve existing verification + Mailgun client-agent-email flows.
- `agent_classification` (enum: Utility, Functional, Supervisory, Guardian) — required on form
- `autonomy_level` (enum: Human-Led, Hybrid, Autopilot) — required on form
- `ip_ownership` (enum: Brandtech IP, Client IP, Shared/TBD) — required on form
- `foundation_model` (string) — optional, dropdown with optgroups by provider
- `validated_by`, `validation_date`, `evals_method` — optional Performance & Testing fields
- `registration_complete` (bool) — `True` for form-submitted agents, `False` for collector-created. Drives the completion-reminder flow.
Nested objects:
- `safety: { off_switch_confirmed, access_rights_confirmed }` — booleans, recorded but don't block submission
- `pii: { handles_pii, legal_ref, data_types, consent_recorded }` — Yes/No radio with conditional fields
- `declarations: { governance, accuracy, upkeep }` — three required checkboxes
Module-level enum constants live in `models.py` (`BUSINESS_ENTITIES`, `CLIENT_SCOPES`, `AGENT_CLASSIFICATIONS`, `AUTONOMY_LEVELS`, `IP_OWNERSHIPS`, `DISCIPLINES`).
### Form field mapping
- "Studio / Department" form input → stored as `studio_name` (not `agent_department``agent_department` continues to be auto-populated by Gemini audit)
- Author / Contact Person — autofilled from logged-in user's email; not a user-editable input
- Capabilities — 8 fixed checkboxes comma-split values from "Other" free-text input, stored as `agent_capabilities: list[str]`
### Discipline rename
Discipline value `Optimization` (US) was renamed to `Optimisation` (UK) in 2026-05. Migration runs on startup (`crud.migrate_optimisation_spelling`). All template dropdowns + Gemini system prompt updated. Defensive `Optimization → Optimisation` normalisation in `audit_analyzer.store_audit_result` and `apply_classification_fields`.
### Required-field enforcement
The `/agent-register` POST handler enforces required fields server-side via `Form(...)` defaults plus explicit conditional checks (e.g. `client_name` required if `client_scope == "specific"`, all 3 declarations required). Existing agents are grandfathered via the startup migration that backfilled `registration_complete` based on `created_by`.
### Completion flow for LibreChat-synced agents
LibreChat-synced agents enter via the collector with `created_by="agent_collector_api"` and only a subset of fields populated. They land with `registration_complete=False` (set in `crud.create_agent_from_collector` via `setdefault`). Owners are nudged via daily APScheduler email job (`notifications.send_completion_reminders`):
1. Job groups incomplete agents by lowercased `agent_contact_person` and resolves to active users (case-insensitive email match)
2. Per-user cooldown (default 7 days) + nudge cap (default 4) tracked in `completion_reminders` collection
3. Single digest email per user with one "Complete →" CTA per agent
4. Submitting the completion form flips `registration_complete=True` AND reassigns `created_by` from the marker to the submitting user's ID — they then own it normally for future edits
`agent_register.html` is parameterised: `mode="register"` (default) or `mode="complete"`. The completion-mode handler `_build_completion_context()` in `main.py` pre-computes capability sets, derived `client_scope` from legacy `client` field, and PII/safety pre-fills. Special case: `agent_purpose` renders blank if it equals `agent_description` (collector defaults purpose=description for ~94% of agents per prod data analysis).
### Gemini audit auto-classification (extended 2026-05)
`audit_analyzer.SYSTEM_INSTRUCTION` schema extended to also return:
- `agent_classification` (Utility/Functional/Supervisory/Guardian) — auto-applied to `agent_classification` field if empty
- `autonomy_level_hint` (Human-Led/Hybrid/Autopilot/null) — auto-applied to `autonomy_level` if empty
- `agent_classification_reasoning`, `autonomy_reasoning` — stored on agent doc as audit fields (not used as primary values)
Running `POST /api/admin/audit/run` after deploy auto-fills these on the ~225 agents with instructions, leaving only the truly non-inferable fields (business_entity, ip_ownership, declarations) for the human owner via the completion form.
### Filter dimensions
Both `agent_management.html` and `admin/dashboard.html` agent views support filtering by:
- Business Entity, Agent Type, Autonomy Level (new dropdowns)
- "Compliance risks" quick toggle — shows agents where `pii.handles_pii=true` OR `ip_ownership="Shared/TBD"` OR `autonomy_level="Autopilot"`
### CSV roundtrip for backfilling
`/api/admin/agents/export/csv` includes 21 new columns covering all governance fields. Import accepts them; nested objects (`safety`, `pii`, `declarations`) only build up if at least one cell is populated, so partially-filled CSVs don't clobber existing data with `False`. Defensive `_csv_bool()` helper returns `None` for empty cells.

View file

@ -37,17 +37,23 @@ system prompts/instructions and metadata. You must:
2. Assign a business discipline
3. Infer the department/team from the instructions
4. Detect whether the agent is used for client-specific work
5. Infer an Agent Type (single-task / workflow / orchestrator / guardrail)
6. Infer an autonomy hint (does the agent ask the user for sign-off, run partly automated, or fully automated?)
Respond ONLY with valid JSON matching this schema:
{
"category": "1" | "1B" | "2" | "3",
"category_reasoning": "string",
"discipline": "Strategy" | "Creative" | "Oversight including delivery" | "Optimization" | "Back Office including operations" | "Pencil Agents",
"discipline": "Strategy" | "Creative" | "Oversight including delivery" | "Optimisation" | "Back Office including operations" | "Pencil Agents",
"discipline_reasoning": "string - why this discipline was chosen",
"department": "string or null - inferred team/department from instructions (e.g. Project Management, Creative Services, Finance, Media, Strategy). null if not determinable",
"is_client_work": true | false,
"client_work_reasoning": "string - evidence from instructions that this agent handles client-specific work, references client names, brands, deliverables, or external-facing outputs. Empty string if not client work",
"client_name_detected": "string or null - specific client or brand name found in instructions, null if none",
"agent_classification": "Utility" | "Functional" | "Supervisory" | "Guardian",
"agent_classification_reasoning": "string - one short sentence on why this type was chosen",
"autonomy_level_hint": "Human-Led" | "Hybrid" | "Autopilot" | null,
"autonomy_reasoning": "string - one short sentence on autonomy signals in the prompt; empty string if no signal",
"flags": ["array", "of", "strings"],
"summary": "2-3 sentence analysis",
"recommendations": "which team(s) should review and why",
@ -64,7 +70,7 @@ DISCIPLINE DEFINITIONS (pick the best fit):
- Strategy: Agents focused on strategic planning, research, market analysis, insights
- Creative: Agents focused on creative work, content creation, design, copywriting
- Oversight including delivery: Agents focused on project management, delivery, QA, oversight, compliance
- Optimization: Agents focused on performance optimization, data analysis, efficiency, media planning
- Optimisation: Agents focused on performance optimisation, data analysis, efficiency, media planning
- Back Office including operations: Agents focused on internal operations, HR, finance, IT support, admin tasks
- Pencil Agents: Agents built on or for the Pencil platform specifically
@ -90,6 +96,20 @@ RISK LEVELS:
- high: Client-facing or accesses sensitive data
- critical: Client-sold or handles financial/legal/PII data
AGENT TYPE DEFINITIONS:
- Utility: Single-purpose tool that does one specific task summariser, formatter, tone adjuster, glossary lookup.
- Functional: Multi-step agent completing a defined workflow brief writer, campaign planner, document analyser.
- Supervisory: Orchestrates or oversees other agents coordinator, router, planner, multi-agent dispatcher.
- Guardian: Monitors, filters, and enforces safety/compliance guardrails on other agents or outputs.
AUTONOMY HINT:
Look in the instructions for explicit signals about how independently the agent acts.
- "Human-Led": prompt says "ask the user", "confirm before", "wait for approval", "always check with"
- "Hybrid": some steps automated, others gated "you may automatically X but must confirm Y"
- "Autopilot": runs end-to-end without asking "autonomously", "without confirmation", "do not ask"
- null: no clear signal in the prompt leave for the human to decide.
This is a hint only; the human can override on the registration form.
FLAGS TO CONSIDER:
internal_only, experimental, sandbox, client_facing, pencil_platform,
revenue_generating, not_for_sale, uses_external_tools, uses_code_interpreter,
@ -138,6 +158,10 @@ def _parse_json_response(text: str) -> dict:
"is_client_work": False,
"client_work_reasoning": "",
"client_name_detected": None,
"agent_classification": None,
"agent_classification_reasoning": "",
"autonomy_level_hint": None,
"autonomy_reasoning": "",
"flags": ["parse_error"],
"summary": text[:500],
"recommendations": "Manual review required — automated analysis failed to produce structured output",
@ -207,6 +231,10 @@ async def store_audit_result(agent_id: str, audit_data: dict):
"""Store audit results on the agent document and in audit_history."""
now = datetime.utcnow()
audit_discipline = audit_data.get("discipline")
if audit_discipline == "Optimization":
audit_discipline = "Optimisation"
# Update agent document with audit fields
update_fields = {
"audit_status": "flagged",
@ -217,12 +245,16 @@ async def store_audit_result(agent_id: str, audit_data: dict):
"audit_flags": audit_data.get("flags", []),
"audit_recommendations": audit_data.get("recommendations"),
"audit_category_reasoning": audit_data.get("category_reasoning"),
"audit_discipline": audit_data.get("discipline"),
"audit_discipline": audit_discipline,
"audit_discipline_reasoning": audit_data.get("discipline_reasoning"),
"audit_department": audit_data.get("department"),
"audit_is_client_work": audit_data.get("is_client_work", False),
"audit_client_work_reasoning": audit_data.get("client_work_reasoning", ""),
"audit_client_name_detected": audit_data.get("client_name_detected"),
"audit_agent_classification": audit_data.get("agent_classification"),
"audit_agent_classification_reasoning": audit_data.get("agent_classification_reasoning"),
"audit_autonomy_level_hint": audit_data.get("autonomy_level_hint"),
"audit_autonomy_reasoning": audit_data.get("autonomy_reasoning"),
}
await agents_collection.update_one(
@ -252,6 +284,8 @@ async def apply_classification_fields(agent_id: str, audit_data: dict):
# Auto-assign discipline if not already set
discipline = audit_data.get("discipline")
if discipline == "Optimization":
discipline = "Optimisation"
if discipline and not agent.get("discipline"):
update_fields["discipline"] = discipline
@ -269,6 +303,17 @@ async def apply_classification_fields(agent_id: str, audit_data: dict):
if detected_name and not agent.get("client_name"):
update_fields["client_name"] = detected_name
# Auto-assign agent_classification if Gemini gave a usable answer and the field is empty
classification = audit_data.get("agent_classification")
if classification in ("Utility", "Functional", "Supervisory", "Guardian") and not agent.get("agent_classification"):
update_fields["agent_classification"] = classification
# Autonomy is a hint — only fill if Gemini was confident enough to return non-null AND
# the field is empty. The user can override on the registration form.
autonomy_hint = audit_data.get("autonomy_level_hint")
if autonomy_hint in ("Human-Led", "Hybrid", "Autopilot") and not agent.get("autonomy_level"):
update_fields["autonomy_level"] = autonomy_hint
if update_fields:
await agents_collection.update_one(
{"_id": ObjectId(agent_id)},

160
crud.py
View file

@ -659,6 +659,12 @@ async def create_agent_from_collector(agent_data: dict):
"updated_at": now,
}
# Collector-sourced agents always start incomplete — they're missing the new
# governance fields (business_entity, autonomy_level, ip_ownership, declarations,
# etc.) that only the form / completion flow can populate. Owner gets nudged via
# the completion-reminder email until they finish registration.
agent_doc.setdefault("registration_complete", False)
try:
result = await agents_collection.insert_one(agent_doc)
agent_doc["_id"] = result.inserted_id
@ -721,6 +727,124 @@ async def update_agent_average_rating(agent_id: str):
)
return stats
async def get_incomplete_agents_for_user(user_email: str, user_id: str, is_admin: bool = False):
"""Return agents needing the new registration form completed.
Owner resolution mirrors the completion-flow design (PLAN §5b):
- direct ownership (`created_by == user_id`), or
- LibreChat-synced ownership (`created_by == "agent_collector_api"` AND
`agent_contact_person == user_email`).
Admins see all incomplete agents.
"""
incomplete_filter = {"registration_complete": {"$ne": True}}
if is_admin:
query = incomplete_filter
else:
ownership = {"$or": [
{"created_by": user_id},
{"$and": [
{"created_by": "agent_collector_api"},
{"agent_contact_person": user_email},
]},
]}
query = {"$and": [incomplete_filter, ownership]}
cursor = agents_collection.find(query).sort("created_at", -1)
return await cursor.to_list(length=None)
async def get_unresolved_owner_agents():
"""Return collector-created agents whose `agent_contact_person` doesn't match any active user.
These are the ~5% of LibreChat-synced agents that won't get a completion-reminder
email and need an admin to either reassign ownership or delete them.
"""
# All collector-created agents
cursor = agents_collection.find(
{"created_by": "agent_collector_api"},
{"agent_name": 1, "agent_contact_person": 1, "registration_complete": 1, "created_at": 1, "_id": 1}
)
candidates = await cursor.to_list(length=None)
if not candidates:
return []
# Resolve which contact emails belong to actual active users (case-insensitive).
contact_emails = {(a.get("agent_contact_person") or "").strip().lower() for a in candidates}
contact_emails.discard("")
user_cursor = users_collection.find(
{"is_active": True, "email": {"$ne": None}},
{"email": 1},
)
known_user_emails = set()
async for u in user_cursor:
e = (u.get("email") or "").strip().lower()
if e:
known_user_emails.add(e)
unresolved = []
for a in candidates:
email = (a.get("agent_contact_person") or "").strip().lower()
if not email or email not in known_user_emails:
unresolved.append(a)
return unresolved
async def reassign_agent_owner(agent_id: str, new_owner_user_id: str, new_contact_email: str):
"""Admin tool: reassign a collector-created agent to a real user.
Sets `created_by` to the user's ID and `agent_contact_person` to their email,
so they pick up the completion-reminder flow on the next run.
"""
update = {"updated_at": datetime.utcnow()}
if new_owner_user_id:
update["created_by"] = new_owner_user_id
if new_contact_email:
update["agent_contact_person"] = new_contact_email
await agents_collection.update_one(
{"_id": ObjectId(agent_id)},
{"$set": update},
)
return await agents_collection.find_one({"_id": ObjectId(agent_id)})
async def complete_agent_registration(
agent_id: str,
completion_data: dict,
completing_user_id: str,
completing_user_email: str,
):
"""Apply registration-form completion to an agent.
- Writes all completion fields onto the agent document.
- Sets `registration_complete = True`.
- If the agent was originally created by the collector marker, reassigns
`created_by` to the submitting user so they can manage it normally afterwards.
"""
existing = await agents_collection.find_one({"_id": ObjectId(agent_id)})
if not existing:
raise ValueError(f"Agent {agent_id} not found")
update_fields = {
**completion_data,
"registration_complete": True,
"last_edited_by": completing_user_email,
"updated_at": datetime.utcnow(),
}
# Reassign ownership from the collector marker to the user who finished registration.
if existing.get("created_by") == "agent_collector_api":
update_fields["created_by"] = completing_user_id
await agents_collection.update_one(
{"_id": ObjectId(agent_id)},
{"$set": update_fields},
)
return await agents_collection.find_one({"_id": ObjectId(agent_id)})
async def migrate_pencil_agents_discipline():
"""Update agents with 'pencil' in name that have no discipline set to 'Pencil Agents'"""
result = await agents_collection.update_many(
@ -735,3 +859,39 @@ async def migrate_pencil_agents_discipline():
{"$set": {"discipline": "Pencil Agents"}}
)
return result.modified_count
async def migrate_optimisation_spelling():
"""Rename discipline from 'Optimization' (US) to 'Optimisation' (UK)."""
result = await agents_collection.update_many(
{"discipline": "Optimization"},
{"$set": {"discipline": "Optimisation"}}
)
return result.modified_count
async def backfill_registration_complete():
"""One-time backfill of `registration_complete` for agents that pre-date the field.
Form-registered agents are grandfathered as complete (existing fields are valid under the
legacy schema). Collector-created agents are marked incomplete so they enter the
completion-reminder flow.
"""
form_result = await agents_collection.update_many(
{
"registration_complete": {"$exists": False},
"created_by": {"$ne": "agent_collector_api"},
},
{"$set": {"registration_complete": True}},
)
collector_result = await agents_collection.update_many(
{
"registration_complete": {"$exists": False},
"created_by": "agent_collector_api",
},
{"$set": {"registration_complete": False}},
)
return {
"form_complete": form_result.modified_count,
"collector_incomplete": collector_result.modified_count,
}

View file

@ -15,6 +15,7 @@ agent_usage_collection = db.get_collection("agent_usage")
notifications_collection = db.get_collection("token_notifications")
agent_ratings_collection = db.get_collection("agent_ratings")
audit_history_collection = db.get_collection("audit_history")
completion_reminders_collection = db.get_collection("completion_reminders")
async def ensure_indexes():
"""Create database indexes for performance"""
@ -25,7 +26,13 @@ async def ensure_indexes():
)
await agents_collection.create_index([("verification_status", 1)])
await agents_collection.create_index([("audit_status", 1)])
await agents_collection.create_index([("business_entity", 1)])
await agents_collection.create_index([("agent_classification", 1)])
await agents_collection.create_index([("autonomy_level", 1)])
await agents_collection.create_index([("ip_ownership", 1)])
await agents_collection.create_index([("registration_complete", 1)])
await audit_history_collection.create_index([("agent_id", 1), ("audit_date", -1)])
await completion_reminders_collection.create_index([("user_email", 1)], unique=True)
print("Database indexes ensured successfully")
except Exception as e:
print(f"Warning: Failed to create indexes: {e}")

598
main.py
View file

@ -5,6 +5,8 @@ from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, StreamingResponse
from starlette.middleware.sessions import SessionMiddleware
from typing import List, Optional
from pydantic import BaseModel
from database import users_collection
import crud
import models
import auth
@ -350,10 +352,24 @@ async def startup_event():
if count > 0:
print(f"Pencil Agents migration: updated {count} agent(s)")
# Start weekly digest scheduler (runs Monday mornings)
# Rename discipline 'Optimization' -> 'Optimisation' (UK spelling, 2026-05)
opt_count = await crud.migrate_optimisation_spelling()
if opt_count > 0:
print(f"Optimisation spelling migration: updated {opt_count} agent(s)")
# Backfill registration_complete for agents that pre-date the field (2026-05)
reg_counts = await crud.backfill_registration_complete()
if reg_counts["form_complete"] or reg_counts["collector_incomplete"]:
print(
f"registration_complete backfill: {reg_counts['form_complete']} form-registered marked complete, "
f"{reg_counts['collector_incomplete']} collector-created marked incomplete"
)
# Start schedulers (weekly digest + daily completion-reminder)
try:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
digest_hour = int(os.getenv("WEEKLY_DIGEST_HOUR", "7"))
completion_hour = int(os.getenv("COMPLETION_REMINDER_HOUR", "8"))
scheduler = AsyncIOScheduler()
scheduler.add_job(
notifications.send_weekly_agent_digest,
@ -363,10 +379,17 @@ async def startup_event():
minute=0,
id='weekly_agent_digest',
)
scheduler.add_job(
notifications.send_completion_reminders,
'cron',
hour=completion_hour,
minute=15,
id='completion_reminders',
)
scheduler.start()
print(f"Weekly digest scheduler started (runs Monday at {digest_hour}:00)")
print(f"Schedulers started: weekly digest Mondays {digest_hour}:00, completion reminders daily {completion_hour}:15")
except Exception as e:
print(f"Warning: Failed to start weekly digest scheduler: {e}")
print(f"Warning: Failed to start schedulers: {e}")
# HTML Routes
@app.get("/")
@ -629,40 +652,76 @@ async def agent_register_page(request: Request):
@app.post("/agent-register", response_class=HTMLResponse)
async def agent_register_form(
request: Request,
# Identity
agent_name: str = Form(...),
agent_tool: str = Form(...),
business_entity: str = Form(...),
agent_status: str = Form("Development"),
client_scope: str = Form(...),
client_name: str = Form(None),
studio_dept: str = Form(None),
agent_location: str = Form(None),
# Classification & Purpose
agent_classification: str = Form(...),
discipline: str = Form(...),
agent_description: str = Form(None),
agent_purpose: str = Form(None),
client: str = Form(...),
client_name: str = Form(None),
studio_name: str = Form(None),
agent_version: str = Form(None),
agent_status: str = Form("Development"),
agent_location: str = Form(None),
agent_department: str = Form(None),
agent_contact_person: str = Form(None),
discipline: str = Form(...),
agent_tags: str = Form(None),
agent_userbase: str = Form(None),
agent_capabilities: str = Form(None),
quality_audit_status: bool = Form(False),
risk_factor: int = Form(None)
# Autonomy & Safety
autonomy_level: str = Form(...),
off_switch_confirmed: bool = Form(False),
access_rights_confirmed: bool = Form(False),
# IP
ip_ownership: str = Form(...),
# Tech Stack
foundation_model: str = Form(None),
agent_tool: str = Form(...),
agent_version: str = Form(None),
capabilities: List[str] = Form(default=[]),
capabilities_other: str = Form(None),
# Data Safety
pii_check: str = Form("No"),
pii_legal_ref: str = Form(None),
pii_data_types: str = Form(None),
pii_consent: bool = Form(False),
# Performance & Testing
validated_by: str = Form(None),
validation_date: str = Form(None),
evals_method: str = Form(None),
# Declarations
decl_governance: bool = Form(False),
decl_accuracy: bool = Form(False),
decl_upkeep: bool = Form(False),
):
try:
# Get user from cookie - require authentication
current_user = await get_current_user_optional(request)
if not current_user:
return RedirectResponse(url=get_app_url("login"), status_code=303)
user_id = str(current_user["_id"])
# Validate client_name is provided when client is Yes
if client.lower() == "yes" and not client_name:
# Conditional: client_name required when scope is "specific"
if client_scope == "specific" and not (client_name and client_name.strip()):
context = get_template_context(request, current_user)
context["error"] = "Client Name is required when Client is set to Yes."
context["error"] = "Client Name is required when Client Scope is 'Built for a specific client'."
return templates.TemplateResponse("agent_register.html", context)
# Prepare agent data
# All three declarations are required (form enforces; defend at API too)
if not (decl_governance and decl_accuracy and decl_upkeep):
context = get_template_context(request, current_user)
context["error"] = "All three declarations must be agreed to before submitting."
return templates.TemplateResponse("agent_register.html", context)
# Map client_scope -> legacy `client` for compatibility with verification + email flows
legacy_client = "yes" if client_scope == "specific" else "no"
# Capabilities: 8 checkboxes + comma-separated free text "Other"
cap_list = list(capabilities or [])
if capabilities_other:
cap_list.extend([c.strip() for c in capabilities_other.split(",") if c.strip()])
handles_pii = pii_check == "Yes"
agent_data = {
"agent_name": agent_name,
"agent_tool": agent_tool,
@ -671,74 +730,73 @@ async def agent_register_form(
"agent_version": agent_version,
"agent_status": agent_status,
"agent_location": agent_location,
"agent_department": agent_department,
"agent_contact_person": agent_contact_person,
"agent_contact_person": current_user.get("email"),
"discipline": discipline,
"client": client.lower(),
"studio_name": studio_name,
"client": legacy_client,
"client_scope": client_scope,
"studio_name": studio_dept,
"business_entity": business_entity,
"agent_classification": agent_classification,
"autonomy_level": autonomy_level,
"ip_ownership": ip_ownership,
"foundation_model": foundation_model,
"validated_by": validated_by,
"validation_date": validation_date,
"evals_method": evals_method,
"registration_complete": True,
"safety": {
"off_switch_confirmed": off_switch_confirmed,
"access_rights_confirmed": access_rights_confirmed,
},
"pii": {
"handles_pii": handles_pii,
"legal_ref": pii_legal_ref if handles_pii else None,
"data_types": pii_data_types if handles_pii else None,
"consent_recorded": pii_consent if handles_pii else False,
},
"declarations": {
"governance": decl_governance,
"accuracy": decl_accuracy,
"upkeep": decl_upkeep,
},
}
# Handle client-specific fields
if client.lower() == "yes":
if cap_list:
agent_data["agent_capabilities"] = cap_list
# Client-specific fields
if legacy_client == "yes":
agent_data["client_name"] = client_name
agent_data["verification_status"] = "needs_verification"
# Process tags, userbase, and capabilities (convert comma-separated to lists)
# Tags / userbase parsing (existing pattern)
if agent_tags:
agent_data["agent_tags"] = [tag.strip() for tag in agent_tags.split(',') if tag.strip()]
agent_data["agent_tags"] = [t.strip() for t in agent_tags.split(",") if t.strip()]
if agent_userbase:
agent_data["agent_userbase"] = [user.strip() for user in agent_userbase.split(',') if user.strip()]
if agent_capabilities:
agent_data["agent_capabilities"] = [cap.strip() for cap in agent_capabilities.split(',') if cap.strip()]
agent_data["agent_userbase"] = [u.strip() for u in agent_userbase.split(",") if u.strip()]
# Handle Quality Audit - only admins can set it to True
if quality_audit_status and current_user.get("is_admin"):
# Admin is setting quality audit to true
from datetime import datetime
agent_data["quality_audit_status"] = True
agent_data["quality_audit_updated_by"] = user_id
agent_data["quality_audit_updated_at"] = datetime.utcnow().isoformat()
agent_data["quality_audit_updated_by_name"] = current_user.get("full_name", current_user.get("email"))
# Validate Risk Factor when Quality Audit is checked
if risk_factor is None or not (1 <= risk_factor <= 5):
context = get_template_context(request, current_user)
context["error"] = "Risk Factor (1-5) is required when Quality Audit is checked."
return templates.TemplateResponse("agent_register.html", context)
agent_data["risk_factor"] = risk_factor
else:
# Non-admin or quality audit not checked
agent_data["quality_audit_status"] = False
agent_data["risk_factor"] = None
# Remove None values
# Strip None top-level values (preserve nested objects + booleans)
agent_data = {k: v for k, v in agent_data.items() if v is not None}
# Create agent in database
created_agent = await crud.create_agent(agent_data, user_id)
# Send client agent notification if client is Yes (non-blocking)
if client.lower() == "yes":
# Client-agent email notification (non-blocking)
if legacy_client == "yes":
try:
notification_data = {
notifications.send_client_agent_notification({
**agent_data,
"created_by_email": current_user.get("email", "Unknown"),
}
notifications.send_client_agent_notification(notification_data)
})
except Exception as notify_err:
print(f"Client agent notification failed: {notify_err}")
# Redirect to agent management with success message
from urllib.parse import quote
success_msg = quote(f"Agent '{agent_name}' registered successfully!")
redirect_url = get_app_url(f"agent-management?success={success_msg}")
print(f"DEBUG: Redirecting to: {redirect_url}") # Debug line
return RedirectResponse(
url=redirect_url,
status_code=303
url=get_app_url(f"agent-management?success={success_msg}"),
status_code=303,
)
except ValueError as e:
# Handle duplicate agent name error
current_user = await get_current_user_optional(request)
@ -753,6 +811,233 @@ async def agent_register_form(
get_template_context(request, current_user, error=f"Agent registration failed: {str(e)}")
)
# ─── Completion flow for incomplete (e.g. LibreChat-synced) agents ──────────
KNOWN_CAPABILITIES = ["RAG", "Web", "API/MCP", "Image Gen", "Code Execution",
"File Operations", "Email/Calendar", "Multi-Agent"]
def _user_can_complete(agent: dict, current_user: dict) -> bool:
if current_user.get("is_admin") or current_user.get("role") == "admin":
return True
user_id = str(current_user["_id"])
if agent.get("created_by") == user_id:
return True
if agent.get("created_by") == "agent_collector_api":
agent_email = (agent.get("agent_contact_person") or "").lower()
user_email = (current_user.get("email") or "").lower()
return bool(agent_email) and agent_email == user_email
return False
def _build_completion_context(agent: dict) -> dict:
"""Compute pre-fill values for the completion form template."""
caps = agent.get("agent_capabilities") or []
known = [c for c in caps if c in KNOWN_CAPABILITIES]
other = ", ".join(c for c in caps if c not in KNOWN_CAPABILITIES)
# Derive client_scope: explicit field wins, else derive from legacy `client`
scope = agent.get("client_scope")
if not scope:
if agent.get("client") == "yes":
scope = "specific"
elif agent.get("client") == "no":
scope = "internal"
pii = agent.get("pii") or {}
safety = agent.get("safety") or {}
return {
"agent": agent,
"mode": "complete",
"prefilled_known_capabilities": known,
"prefilled_other_capabilities": other,
"prefilled_client_scope": scope or "",
"prefilled_pii_handles": bool(pii.get("handles_pii")),
"prefilled_off_switch": bool(safety.get("off_switch_confirmed")),
"prefilled_access_rights": bool(safety.get("access_rights_confirmed")),
}
@app.get("/api/agents/incomplete")
async def list_incomplete_agents(request: Request):
"""Return current user's incomplete agents (admins see all)."""
current_user = await get_current_user_from_cookie(request)
agents = await crud.get_incomplete_agents_for_user(
user_email=current_user.get("email") or "",
user_id=str(current_user["_id"]),
is_admin=bool(current_user.get("is_admin") or current_user.get("role") == "admin"),
)
# Convert ObjectIds to strings for JSON
for a in agents:
a["_id"] = str(a["_id"])
a["agent_id"] = a["_id"]
return agents
@app.get("/agent-complete/{agent_id}", response_class=HTMLResponse)
async def agent_complete_page(request: Request, agent_id: str):
current_user = await get_current_user_optional(request)
if not current_user:
return RedirectResponse(url=get_app_url("login"), status_code=303)
agent = await crud.get_agent_by_id(agent_id)
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
if not _user_can_complete(agent, current_user):
raise HTTPException(status_code=403, detail="Not authorised to complete this registration")
context = get_template_context(request, current_user)
context.update(_build_completion_context(agent))
return templates.TemplateResponse("agent_register.html", context)
@app.post("/agent-complete/{agent_id}", response_class=HTMLResponse)
async def agent_complete_submit(
agent_id: str,
request: Request,
# Identity
agent_name: str = Form(...),
business_entity: str = Form(...),
agent_status: str = Form("Development"),
client_scope: str = Form(...),
client_name: str = Form(None),
studio_dept: str = Form(None),
agent_location: str = Form(None),
# Classification & Purpose
agent_classification: str = Form(...),
discipline: str = Form(...),
agent_description: str = Form(None),
agent_purpose: str = Form(None),
agent_tags: str = Form(None),
agent_userbase: str = Form(None),
# Autonomy & Safety
autonomy_level: str = Form(...),
off_switch_confirmed: bool = Form(False),
access_rights_confirmed: bool = Form(False),
# IP
ip_ownership: str = Form(...),
# Tech Stack
foundation_model: str = Form(None),
agent_tool: str = Form(...),
agent_version: str = Form(None),
capabilities: List[str] = Form(default=[]),
capabilities_other: str = Form(None),
# Data Safety
pii_check: str = Form("No"),
pii_legal_ref: str = Form(None),
pii_data_types: str = Form(None),
pii_consent: bool = Form(False),
# Performance & Testing
validated_by: str = Form(None),
validation_date: str = Form(None),
evals_method: str = Form(None),
# Declarations
decl_governance: bool = Form(False),
decl_accuracy: bool = Form(False),
decl_upkeep: bool = Form(False),
):
current_user = await get_current_user_optional(request)
if not current_user:
return RedirectResponse(url=get_app_url("login"), status_code=303)
agent = await crud.get_agent_by_id(agent_id)
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
if not _user_can_complete(agent, current_user):
raise HTTPException(status_code=403, detail="Not authorised to complete this registration")
# Re-render the form on validation failure.
def _rerender_with_error(msg: str):
context = get_template_context(request, current_user, error=msg)
context.update(_build_completion_context(agent))
return templates.TemplateResponse("agent_register.html", context)
if client_scope == "specific" and not (client_name and client_name.strip()):
return _rerender_with_error("Client Name is required when Client Scope is 'Built for a specific client'.")
if not (decl_governance and decl_accuracy and decl_upkeep):
return _rerender_with_error("All three declarations must be agreed to before submitting.")
legacy_client = "yes" if client_scope == "specific" else "no"
cap_list = list(capabilities or [])
if capabilities_other:
cap_list.extend([c.strip() for c in capabilities_other.split(",") if c.strip()])
handles_pii = pii_check == "Yes"
completion_data = {
"agent_name": agent_name,
"agent_tool": agent_tool,
"agent_description": agent_description,
"agent_purpose": agent_purpose,
"agent_version": agent_version,
"agent_status": agent_status,
"agent_location": agent_location,
"discipline": discipline,
"client": legacy_client,
"client_scope": client_scope,
"studio_name": studio_dept,
"business_entity": business_entity,
"agent_classification": agent_classification,
"autonomy_level": autonomy_level,
"ip_ownership": ip_ownership,
"foundation_model": foundation_model,
"validated_by": validated_by,
"validation_date": validation_date,
"evals_method": evals_method,
"safety": {
"off_switch_confirmed": off_switch_confirmed,
"access_rights_confirmed": access_rights_confirmed,
},
"pii": {
"handles_pii": handles_pii,
"legal_ref": pii_legal_ref if handles_pii else None,
"data_types": pii_data_types if handles_pii else None,
"consent_recorded": pii_consent if handles_pii else False,
},
"declarations": {
"governance": decl_governance,
"accuracy": decl_accuracy,
"upkeep": decl_upkeep,
},
}
if cap_list:
completion_data["agent_capabilities"] = cap_list
if legacy_client == "yes":
completion_data["client_name"] = client_name
# Only set verification_status if not already verified (don't reset prior approvals)
if agent.get("verification_status") != "verified":
completion_data["verification_status"] = "needs_verification"
if agent_tags:
completion_data["agent_tags"] = [t.strip() for t in agent_tags.split(",") if t.strip()]
if agent_userbase:
completion_data["agent_userbase"] = [u.strip() for u in agent_userbase.split(",") if u.strip()]
completion_data = {k: v for k, v in completion_data.items() if v is not None}
try:
await crud.complete_agent_registration(
agent_id,
completion_data,
completing_user_id=str(current_user["_id"]),
completing_user_email=current_user.get("email", ""),
)
except Exception as e:
return _rerender_with_error(f"Failed to save: {str(e)}")
from urllib.parse import quote
success_msg = quote(f"Registration completed for '{agent_name}'.")
return RedirectResponse(
url=get_app_url(f"agent-management?view=my&success={success_msg}"),
status_code=303,
)
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request):
current_user = await get_current_user_optional(request)
@ -1268,6 +1553,84 @@ async def trigger_weekly_digest(current_user: dict = Depends(require_admin)):
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to send digest: {str(e)}")
@app.get("/api/admin/agents/unresolved-owner")
async def list_unresolved_owner_agents(current_user: dict = Depends(require_admin)):
"""List collector-created agents whose contact_person doesn't match any active user.
These agents won't be picked up by the completion-reminder flow until an admin
reassigns ownership via PUT /api/admin/agents/{id}/reassign-owner.
"""
agents = await crud.get_unresolved_owner_agents()
return [
{
"agent_id": str(a["_id"]),
"agent_name": a.get("agent_name"),
"agent_contact_person": a.get("agent_contact_person"),
"registration_complete": a.get("registration_complete", False),
"created_at": a["created_at"].isoformat() if a.get("created_at") else None,
}
for a in agents
]
class ReassignOwnerRequest(BaseModel):
new_contact_email: str
new_owner_user_id: Optional[str] = None # if omitted, looked up by email
@app.put("/api/admin/agents/{agent_id}/reassign-owner")
async def reassign_owner(
agent_id: str,
body: ReassignOwnerRequest,
current_user: dict = Depends(require_admin),
):
"""Admin: reassign a collector-created agent to a real user (by email).
Resolves the user by email if `new_owner_user_id` not supplied. After this,
the user will start receiving completion-reminder emails on the next daily run.
"""
target_email = body.new_contact_email.strip().lower()
if "@" not in target_email:
raise HTTPException(status_code=400, detail="new_contact_email must be a valid email")
new_owner_id = body.new_owner_user_id
if not new_owner_id:
# Look up by email (case-insensitive)
user = await users_collection.find_one(
{"email": {"$regex": f"^{re.escape(target_email)}$", "$options": "i"}}
)
if not user:
raise HTTPException(status_code=404, detail=f"No user found with email {target_email}")
new_owner_id = str(user["_id"])
updated = await crud.reassign_agent_owner(agent_id, new_owner_id, body.new_contact_email)
if not updated:
raise HTTPException(status_code=404, detail="Agent not found")
return {
"status": "ok",
"agent_id": agent_id,
"new_owner_user_id": new_owner_id,
"new_contact_email": body.new_contact_email,
}
@app.post("/api/admin/completion-reminders/send")
async def trigger_completion_reminders(
force: bool = Query(False, description="Bypass per-user cooldown and nudge cap"),
current_user: dict = Depends(require_admin),
):
"""Manually trigger the completion-reminder digest email run.
Use `?force=true` to ignore the cooldown / max-nudges cap (e.g., for testing or
after an outage where users haven't been emailed for a while).
"""
try:
summary = await notifications.send_completion_reminders(force=force)
return summary
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to send completion reminders: {str(e)}")
# Prompt Audit Endpoints
@app.post("/api/admin/audit/run")
async def run_audit(
@ -1432,7 +1795,30 @@ async def export_agents_csv(current_user: dict = Depends(require_admin)):
"discipline",
"rating",
"rating_count",
"instructions"
"instructions",
# Governance / registration fields (introduced 2026-05)
"business_entity",
"client",
"client_scope",
"client_name",
"studio_name",
"agent_classification",
"autonomy_level",
"ip_ownership",
"foundation_model",
"validated_by",
"validation_date",
"evals_method",
"registration_complete",
"safety_off_switch_confirmed",
"safety_access_rights_confirmed",
"pii_handles_pii",
"pii_legal_ref",
"pii_data_types",
"pii_consent_recorded",
"decl_governance",
"decl_accuracy",
"decl_upkeep",
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
@ -1473,7 +1859,29 @@ async def export_agents_csv(current_user: dict = Depends(require_admin)):
"discipline": agent.get("discipline", ""),
"rating": str(agent.get("rating", "")) if agent.get("rating") is not None else "",
"rating_count": str(agent.get("rating_count", "")) if agent.get("rating_count") is not None else "",
"instructions": agent.get("instructions", "")
"instructions": agent.get("instructions", ""),
"business_entity": agent.get("business_entity", ""),
"client": agent.get("client", ""),
"client_scope": agent.get("client_scope", ""),
"client_name": agent.get("client_name", ""),
"studio_name": agent.get("studio_name", ""),
"agent_classification": agent.get("agent_classification", ""),
"autonomy_level": agent.get("autonomy_level", ""),
"ip_ownership": agent.get("ip_ownership", ""),
"foundation_model": agent.get("foundation_model", ""),
"validated_by": agent.get("validated_by", ""),
"validation_date": agent.get("validation_date", ""),
"evals_method": agent.get("evals_method", ""),
"registration_complete": str(agent.get("registration_complete", "")) if agent.get("registration_complete") is not None else "",
"safety_off_switch_confirmed": str((agent.get("safety") or {}).get("off_switch_confirmed", "")) if agent.get("safety") else "",
"safety_access_rights_confirmed": str((agent.get("safety") or {}).get("access_rights_confirmed", "")) if agent.get("safety") else "",
"pii_handles_pii": str((agent.get("pii") or {}).get("handles_pii", "")) if agent.get("pii") else "",
"pii_legal_ref": (agent.get("pii") or {}).get("legal_ref", "") if agent.get("pii") else "",
"pii_data_types": (agent.get("pii") or {}).get("data_types", "") if agent.get("pii") else "",
"pii_consent_recorded": str((agent.get("pii") or {}).get("consent_recorded", "")) if agent.get("pii") else "",
"decl_governance": str((agent.get("declarations") or {}).get("governance", "")) if agent.get("declarations") else "",
"decl_accuracy": str((agent.get("declarations") or {}).get("accuracy", "")) if agent.get("declarations") else "",
"decl_upkeep": str((agent.get("declarations") or {}).get("upkeep", "")) if agent.get("declarations") else "",
}
writer.writerow(row)
@ -1519,6 +1927,12 @@ async def import_agents_csv(
skipped_count += 1
continue
def _csv_bool(val):
"""Parse CSV boolean cell; empty / unrecognised → None so we don't overwrite with False."""
if val is None or val == "":
return None
return val.strip().lower() in ("true", "1", "yes", "y")
# Parse fields into agent_data first
agent_data = {
"agent_name": agent_name_from_csv,
@ -1535,8 +1949,54 @@ async def import_agents_csv(
"risk_factor": int(row.get("risk_factor")) if row.get("risk_factor") else None,
"discipline": row.get("discipline") or None,
"rating": float(row.get("rating")) if row.get("rating") else None,
"total_tokens": int(row.get("total_tokens")) if row.get("total_tokens") else None
"total_tokens": int(row.get("total_tokens")) if row.get("total_tokens") else None,
# Governance / registration fields
"business_entity": row.get("business_entity") or None,
"client": row.get("client") or None,
"client_scope": row.get("client_scope") or None,
"client_name": row.get("client_name") or None,
"studio_name": row.get("studio_name") or None,
"agent_classification": row.get("agent_classification") or None,
"autonomy_level": row.get("autonomy_level") or None,
"ip_ownership": row.get("ip_ownership") or None,
"foundation_model": row.get("foundation_model") or None,
"validated_by": row.get("validated_by") or None,
"validation_date": row.get("validation_date") or None,
"evals_method": row.get("evals_method") or None,
"registration_complete": _csv_bool(row.get("registration_complete")),
}
# Build nested safety / pii / declarations objects only when at least one
# cell is populated, so empty CSV rows don't clobber existing nested data.
safety_off = _csv_bool(row.get("safety_off_switch_confirmed"))
safety_access = _csv_bool(row.get("safety_access_rights_confirmed"))
if safety_off is not None or safety_access is not None:
agent_data["safety"] = {
"off_switch_confirmed": safety_off,
"access_rights_confirmed": safety_access,
}
pii_handles = _csv_bool(row.get("pii_handles_pii"))
pii_legal = row.get("pii_legal_ref") or None
pii_data = row.get("pii_data_types") or None
pii_consent = _csv_bool(row.get("pii_consent_recorded"))
if any(v is not None for v in (pii_handles, pii_legal, pii_data, pii_consent)):
agent_data["pii"] = {
"handles_pii": pii_handles,
"legal_ref": pii_legal,
"data_types": pii_data,
"consent_recorded": pii_consent,
}
decl_gov = _csv_bool(row.get("decl_governance"))
decl_acc = _csv_bool(row.get("decl_accuracy"))
decl_upk = _csv_bool(row.get("decl_upkeep"))
if any(v is not None for v in (decl_gov, decl_acc, decl_upk)):
agent_data["declarations"] = {
"governance": decl_gov,
"accuracy": decl_acc,
"upkeep": decl_upk,
}
# Handle lists (pipe separated)
if row.get("agent_tags"):

View file

@ -8,6 +8,41 @@ class UsageTimelineEntry(BaseModel):
token_count: int = Field(0, ge=0, description="Number of tokens consumed on this date")
# Closed-list values for the new governance/registration fields. Kept as module-level
# constants so the form, API, and migrations all reference the same source of truth.
BUSINESS_ENTITIES = ["OLIVER", "DARE", "Brandtech Group", "Pencil", "Jellyfish", "Adjust", "Other"]
CLIENT_SCOPES = ["internal", "all", "specific"]
AGENT_CLASSIFICATIONS = ["Utility", "Functional", "Supervisory", "Guardian"]
AUTONOMY_LEVELS = ["Human-Led", "Hybrid", "Autopilot"]
IP_OWNERSHIPS = ["Brandtech IP", "Client IP", "Shared/TBD"]
DISCIPLINES = [
"Strategy",
"Creative",
"Oversight including delivery",
"Optimisation",
"Back Office including operations",
"Pencil Agents",
]
class AgentSafety(BaseModel):
off_switch_confirmed: Optional[bool] = None
access_rights_confirmed: Optional[bool] = None
class AgentPII(BaseModel):
handles_pii: Optional[bool] = None
legal_ref: Optional[str] = None
data_types: Optional[str] = None
consent_recorded: Optional[bool] = None
class AgentDeclarations(BaseModel):
governance: Optional[bool] = None
accuracy: Optional[bool] = None
upkeep: Optional[bool] = None
class AiAgent(BaseModel):
agent_id: int
agent_name: str
@ -36,6 +71,21 @@ class AiAgent(BaseModel):
rating: float | None = Field(default=None, title="Star rating (1-5)", ge=1, le=5)
instructions: str | None = Field(default=None, title="System prompt / instructions from LibreChat")
# Governance / registration fields (introduced 2026-05)
business_entity: Optional[str] = Field(default=None, title="Group company the agent sits under")
client_scope: Optional[str] = Field(default=None, title="internal / all / specific")
agent_classification: Optional[str] = Field(default=None, title="Utility / Functional / Supervisory / Guardian")
autonomy_level: Optional[str] = Field(default=None, title="Human-Led / Hybrid / Autopilot")
ip_ownership: Optional[str] = Field(default=None, title="Brandtech IP / Client IP / Shared/TBD")
foundation_model: Optional[str] = Field(default=None, title="Primary LLM provider/model")
safety: Optional[AgentSafety] = None
pii: Optional[AgentPII] = None
validated_by: Optional[str] = None
validation_date: Optional[str] = None
evals_method: Optional[str] = None
declarations: Optional[AgentDeclarations] = None
registration_complete: Optional[bool] = Field(default=None, title="Whether all required registration fields are populated")
@ -110,6 +160,21 @@ class AiAgentCreate(BaseModel):
studio_name: Optional[str] = None
instructions: Optional[str] = None
# Governance / registration fields (introduced 2026-05)
business_entity: Optional[str] = None
client_scope: Optional[str] = Field(default=None, pattern="^(internal|all|specific)$")
agent_classification: Optional[str] = Field(default=None, pattern="^(Utility|Functional|Supervisory|Guardian)$")
autonomy_level: Optional[str] = Field(default=None, pattern="^(Human-Led|Hybrid|Autopilot)$")
ip_ownership: Optional[str] = None
foundation_model: Optional[str] = None
safety: Optional[AgentSafety] = None
pii: Optional[AgentPII] = None
validated_by: Optional[str] = None
validation_date: Optional[str] = None
evals_method: Optional[str] = None
declarations: Optional[AgentDeclarations] = None
registration_complete: Optional[bool] = None
class AiAgentResponse(BaseModel):
agent_id: str
agent_name: str
@ -161,6 +226,21 @@ class AiAgentResponse(BaseModel):
prompt_tokens: Optional[int] = None
completion_tokens: Optional[int] = None
# Governance / registration fields (introduced 2026-05)
business_entity: Optional[str] = None
client_scope: Optional[str] = None
agent_classification: Optional[str] = None
autonomy_level: Optional[str] = None
ip_ownership: Optional[str] = None
foundation_model: Optional[str] = None
safety: Optional[AgentSafety] = None
pii: Optional[AgentPII] = None
validated_by: Optional[str] = None
validation_date: Optional[str] = None
evals_method: Optional[str] = None
declarations: Optional[AgentDeclarations] = None
registration_complete: Optional[bool] = None
# Agent Collector API Models (for compatibility with agent_collector app)
class AgentCollectorCreate(BaseModel):
name: str = Field(min_length=1)
@ -185,6 +265,21 @@ class AgentCollectorCreate(BaseModel):
studio_name: Optional[str] = None
instructions: Optional[str] = None
# Governance / registration fields — accepted optionally so the collector
# can opt into sending them later without an API change.
business_entity: Optional[str] = None
client_scope: Optional[str] = None
agent_classification: Optional[str] = None
autonomy_level: Optional[str] = None
ip_ownership: Optional[str] = None
foundation_model: Optional[str] = None
safety: Optional[AgentSafety] = None
pii: Optional[AgentPII] = None
validated_by: Optional[str] = None
validation_date: Optional[str] = None
evals_method: Optional[str] = None
declarations: Optional[AgentDeclarations] = None
# Usage tracking fields (new)
usage_timeline: Optional[List[UsageTimelineEntry]] = None
conversation_count: Optional[int] = Field(default=None, ge=0)

View file

@ -1,7 +1,12 @@
import os
import requests
from datetime import datetime, timedelta
from database import notifications_collection, users_collection, agents_collection
from database import (
notifications_collection,
users_collection,
agents_collection,
completion_reminders_collection,
)
def is_mailgun_configured() -> bool:
@ -232,6 +237,168 @@ def build_weekly_digest_email(agents: list) -> str:
"""
def build_completion_reminder_email(user_name: str, agents: list, public_url: str) -> str:
"""Build the HTML body for a completion-reminder digest sent to one owner."""
rows = ""
for a in agents:
agent_id = str(a.get("_id"))
link = f"{public_url.rstrip('/')}/agent-complete/{agent_id}" if public_url else f"/agent-complete/{agent_id}"
rows += f"""
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 12px; vertical-align: top;">
<strong style="color: #1e293b;">{a.get('agent_name', 'N/A')}</strong><br>
<span style="color: #64748b; font-size: 0.85em;">
{(a.get('agent_description') or 'No description')[:140]}
</span>
</td>
<td style="padding: 12px; vertical-align: middle; text-align: right; white-space: nowrap;">
<a href="{link}"
style="background: #f3ae3e; color: white; padding: 8px 16px; border-radius: 6px;
text-decoration: none; font-weight: 600; font-size: 0.85em;">Complete →</a>
</td>
</tr>"""
count = len(agents)
plural = "agent needs" if count == 1 else "agents need"
return f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 640px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #f3ae3e, #d4922a); padding: 22px; border-radius: 8px 8px 0 0;">
<h2 style="color: white; margin: 0; font-size: 1.3em;">Agent registration needs your attention</h2>
</div>
<div style="padding: 22px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
<p style="margin-top: 0;">Hi {user_name},</p>
<p>
<strong>{count}</strong> {plural} a few extra details before
{('it can' if count == 1 else 'they can')} be considered fully registered in AgentHub.
These are agents that came in from LibreChat please confirm the existing fields
and fill in the new governance fields (Business Entity, Agent Type, Autonomy Level,
IP Ownership, declarations).
</p>
<table style="width: 100%; border-collapse: collapse; margin: 18px 0;">
{rows}
</table>
<p style="color: #64748b; font-size: 0.85em; margin-bottom: 0;">
Click "Complete" on any agent above to fill in the missing fields.
You'll be assigned as the agent's owner once you save.
</p>
</div>
</body>
</html>
"""
async def send_completion_reminders(force: bool = False) -> dict:
"""Daily job: nudge owners whose LibreChat-synced agents need registration completed.
- Groups incomplete agents by their resolved owner email (via `agent_contact_person`).
- Per-user cooldown (default 7 days, COMPLETION_REMINDER_COOLDOWN_DAYS).
- Per-user nudge cap (default 4, COMPLETION_REMINDER_MAX_NUDGES); after the cap we
stop emailing the agents stay incomplete and admins can pick them up.
- Pass `force=True` to bypass cooldown + cap (used by the manual-trigger endpoint).
- Non-blocking failures are logged, never raise.
"""
if not is_mailgun_configured():
return {"status": "skipped", "reason": "mailgun_not_configured"}
cooldown_days = int(os.getenv("COMPLETION_REMINDER_COOLDOWN_DAYS", "7"))
max_nudges = int(os.getenv("COMPLETION_REMINDER_MAX_NUDGES", "4"))
public_url = os.getenv("AGENTHUB_PUBLIC_URL", "").rstrip("/")
# Gather all incomplete agents that have a contact email to nudge against.
cursor = agents_collection.find(
{
"registration_complete": {"$ne": True},
"agent_contact_person": {"$nin": [None, ""]},
},
{"agent_name": 1, "agent_contact_person": 1, "agent_description": 1, "_id": 1},
)
incomplete_agents = await cursor.to_list(length=None)
# Group by lowercased contact email.
by_email: dict[str, list] = {}
for agent in incomplete_agents:
email = (agent.get("agent_contact_person") or "").strip().lower()
if not email or "@" not in email:
continue
by_email.setdefault(email, []).append(agent)
if not by_email:
return {"status": "ok", "users_emailed": 0, "reason": "no_incomplete_agents_with_owners"}
# Resolve which owner emails actually map to active users (case-insensitive).
user_cursor = users_collection.find(
{"is_active": True, "email": {"$regex": "@", "$options": "i"}},
{"email": 1, "full_name": 1},
)
users_by_lower_email: dict[str, dict] = {}
async for u in user_cursor:
email = (u.get("email") or "").strip().lower()
if email:
users_by_lower_email[email] = u
cutoff = datetime.utcnow() - timedelta(days=cooldown_days)
summary = {
"status": "ok",
"users_emailed": 0,
"agents_total": 0,
"skipped_cooldown": 0,
"skipped_max_nudges": 0,
"skipped_unresolved": 0,
"failed": 0,
}
for email_lower, agents_for_user in by_email.items():
user = users_by_lower_email.get(email_lower)
if not user:
summary["skipped_unresolved"] += 1
continue
existing = await completion_reminders_collection.find_one({"user_email": email_lower})
if existing and not force:
if existing.get("nudge_count", 0) >= max_nudges:
summary["skipped_max_nudges"] += 1
continue
last_sent = existing.get("last_sent_at")
if last_sent and last_sent >= cutoff:
summary["skipped_cooldown"] += 1
continue
full_name = user.get("full_name") or user.get("email") or email_lower
recipient = user.get("email") # send to the canonically-cased email
subject = f"[AgentHub] {len(agents_for_user)} agent(s) need registration completed"
html_body = build_completion_reminder_email(full_name, agents_for_user, public_url)
try:
success = send_mailgun_email([recipient], subject, html_body)
except Exception as e:
print(f"Completion reminder failed for {email_lower}: {e}")
success = False
await completion_reminders_collection.update_one(
{"user_email": email_lower},
{
"$set": {
"last_sent_at": datetime.utcnow(),
"last_success": success,
"last_agent_count": len(agents_for_user),
},
"$inc": {"nudge_count": 1},
"$setOnInsert": {"first_sent_at": datetime.utcnow()},
},
upsert=True,
)
if success:
summary["users_emailed"] += 1
summary["agents_total"] += len(agents_for_user)
else:
summary["failed"] += 1
return summary
async def send_weekly_agent_digest():
"""Send weekly digest of agents created in the last 7 days to all admins."""
if not is_mailgun_configured():

View file

@ -24,6 +24,52 @@
</div>
</div>
<!-- Unresolved-owner banner (collector agents whose contact email doesn't match a user) -->
<div class="row mb-3" id="unresolvedOwnerRow" style="display:none;">
<div class="col-12">
<div class="alert alert-info d-flex align-items-center justify-content-between" role="alert" style="border-radius:10px;">
<div>
<i class="fas fa-user-slash me-2"></i>
<strong id="unresolvedOwnerCount"></strong>
<span class="text-muted small ms-2">— these won't receive completion-reminder emails until reassigned.</span>
</div>
<button type="button" class="btn btn-sm btn-info text-white" data-bs-toggle="modal" data-bs-target="#unresolvedOwnerModal">
<i class="fas fa-list me-1"></i>Review &amp; reassign
</button>
</div>
</div>
</div>
<!-- Unresolved-owner modal -->
<div class="modal fade" id="unresolvedOwnerModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-user-slash me-2"></i>Agents with unresolved owners</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small">
These agents were created via the LibreChat collector but their <code>agent_contact_person</code>
email doesn't match any active AgentHub user. Reassign each one to a real user — they'll
then start receiving completion-reminder emails on the next daily run.
</p>
<table class="table table-sm">
<thead>
<tr>
<th>Agent</th>
<th>Stored contact</th>
<th>Reassign to (email)</th>
<th></th>
</tr>
</thead>
<tbody id="unresolvedOwnerTableBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4 align-items-stretch">
<div class="col-lg-2 col-md-4 mb-3 d-flex">
@ -138,7 +184,7 @@
<option value="Strategy">Strategy</option>
<option value="Creative">Creative</option>
<option value="Oversight including delivery">Oversight including delivery</option>
<option value="Optimization">Optimization</option>
<option value="Optimisation">Optimisation</option>
<option value="Back Office including operations">Back Office including operations</option>
<option value="Pencil Agents">Pencil Agents</option>
</select>
@ -324,6 +370,33 @@
<option value="audited">Audited</option>
<option value="not_audited">Not Audited</option>
</select>
<select class="form-select" id="agentBusinessEntityFilter" style="width: auto;">
<option value="">All Entities</option>
<option value="OLIVER">OLIVER</option>
<option value="DARE">DARE</option>
<option value="Brandtech Group">Brandtech Group</option>
<option value="Pencil">Pencil</option>
<option value="Jellyfish">Jellyfish</option>
<option value="Adjust">Adjust</option>
<option value="Other">Other</option>
</select>
<select class="form-select" id="agentTypeFilter" style="width: auto;">
<option value="">All Types</option>
<option value="Utility">Utility</option>
<option value="Functional">Functional</option>
<option value="Supervisory">Supervisory</option>
<option value="Guardian">Guardian</option>
</select>
<select class="form-select" id="agentAutonomyFilter" style="width: auto;">
<option value="">All Autonomy</option>
<option value="Human-Led">Human-Led</option>
<option value="Hybrid">Hybrid</option>
<option value="Autopilot">Autopilot</option>
</select>
<button type="button" class="btn btn-outline-danger btn-sm" id="adminComplianceRiskBtn"
title="Show only PII=Yes, IP=Shared/TBD, or Autopilot agents">
<i class="fas fa-shield-alt me-1"></i>Risks
</button>
<div class="input-group" style="width: 300px;">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" id="agentSearch" placeholder="Search agents...">
@ -496,7 +569,7 @@
<option value="Strategy">Strategy</option>
<option value="Creative">Creative</option>
<option value="Oversight including delivery">Oversight incl. delivery</option>
<option value="Optimization">Optimization</option>
<option value="Optimisation">Optimisation</option>
<option value="Back Office including operations">Back Office incl. ops</option>
<option value="Pencil Agents">Pencil Agents</option>
</select>
@ -827,7 +900,7 @@
<option value="Strategy">Strategy</option>
<option value="Creative">Creative</option>
<option value="Oversight including delivery">Oversight including delivery</option>
<option value="Optimization">Optimization</option>
<option value="Optimisation">Optimisation</option>
<option value="Back Office including operations">Back Office including operations</option>
<option value="Pencil Agents">Pencil Agents</option>
</select>
@ -1210,6 +1283,7 @@ document.addEventListener('DOMContentLoaded', function() {
loadAnalytics();
loadVerificationData();
loadAuditResults();
loadUnresolvedOwners();
setupEventListeners();
// Hide write-action buttons for readonly admins
@ -1218,12 +1292,70 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
async function loadUnresolvedOwners() {
if (!isFullAdmin) return; // readonly admins can't reassign
try {
const r = await fetch('{{ base_path }}/api/admin/agents/unresolved-owner', { credentials: 'include' });
if (!r.ok) return;
const list = await r.json();
if (!list.length) return;
document.getElementById('unresolvedOwnerRow').style.display = '';
document.getElementById('unresolvedOwnerCount').textContent =
list.length + (list.length === 1 ? ' agent has' : ' agents have') + ' an unresolved owner';
const tbody = document.getElementById('unresolvedOwnerTableBody');
tbody.innerHTML = list.map(a => `
<tr data-agent-id="${a.agent_id}">
<td><strong>${a.agent_name || '(no name)'}</strong></td>
<td><code>${a.agent_contact_person || '(empty)'}</code></td>
<td><input type="email" class="form-control form-control-sm reassign-email" placeholder="user@oliver.agency"></td>
<td><button class="btn btn-sm btn-primary reassign-btn">Reassign</button></td>
</tr>
`).join('');
tbody.querySelectorAll('.reassign-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const tr = btn.closest('tr');
const agentId = tr.dataset.agentId;
const email = tr.querySelector('.reassign-email').value.trim();
if (!email) { alert('Enter an email'); return; }
btn.disabled = true; btn.textContent = '…';
try {
const r = await fetch(`{{ base_path }}/api/admin/agents/${agentId}/reassign-owner`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_contact_email: email }),
});
const data = await r.json();
if (!r.ok) { alert(data.detail || 'Failed'); btn.disabled = false; btn.textContent = 'Reassign'; return; }
tr.style.opacity = '0.5';
btn.textContent = '✓ Done';
} catch (e) {
alert('Error: ' + e.message);
btn.disabled = false; btn.textContent = 'Reassign';
}
});
});
} catch (e) { /* non-blocking */ }
}
function setupEventListeners() {
document.getElementById('refreshBtn').addEventListener('click', () => { loadAdminData(); loadAnalytics(); loadVerificationData(); loadAuditResults(); });
document.getElementById('userSearch').addEventListener('input', filterUsers);
document.getElementById('agentSearch').addEventListener('input', filterAgents);
document.getElementById('agentStatusFilter').addEventListener('change', filterAgents);
document.getElementById('agentAuditFilter').addEventListener('change', filterAgents);
document.getElementById('agentBusinessEntityFilter')?.addEventListener('change', filterAgents);
document.getElementById('agentTypeFilter')?.addEventListener('change', filterAgents);
document.getElementById('agentAutonomyFilter')?.addEventListener('change', filterAgents);
document.getElementById('adminComplianceRiskBtn')?.addEventListener('click', function() {
adminComplianceRiskOnly = !adminComplianceRiskOnly;
this.classList.toggle('btn-outline-danger', !adminComplianceRiskOnly);
this.classList.toggle('btn-danger', adminComplianceRiskOnly);
this.innerHTML = adminComplianceRiskOnly
? '<i class="fas fa-times me-1"></i>Clear risks'
: '<i class="fas fa-shield-alt me-1"></i>Risks';
filterAgents();
});
document.getElementById('editUserForm').addEventListener('submit', handleEditUserSubmit);
document.getElementById('editAgentForm').addEventListener('submit', handleEditAgentSubmit);
document.getElementById('createUserForm').addEventListener('submit', handleCreateUserSubmit);
@ -1422,10 +1554,15 @@ function filterUsers() {
displayUsers(filtered);
}
let adminComplianceRiskOnly = false;
function filterAgents() {
const searchTerm = document.getElementById('agentSearch').value.toLowerCase();
const statusFilter = document.getElementById('agentStatusFilter').value;
const auditFilter = document.getElementById('agentAuditFilter').value;
const entityFilter = document.getElementById('agentBusinessEntityFilter')?.value || '';
const typeFilter = document.getElementById('agentTypeFilter')?.value || '';
const autonomyFilter = document.getElementById('agentAutonomyFilter')?.value || '';
let filtered = allAgents.filter(agent => {
const matchesSearch = agent.agent_name.toLowerCase().includes(searchTerm) ||
@ -1434,7 +1571,16 @@ function filterAgents() {
const matchesAudit = !auditFilter ||
(auditFilter === 'audited' && agent.quality_audit_status) ||
(auditFilter === 'not_audited' && !agent.quality_audit_status);
return matchesSearch && matchesStatus && matchesAudit;
const matchesEntity = !entityFilter || agent.business_entity === entityFilter;
const matchesType = !typeFilter || agent.agent_classification === typeFilter;
const matchesAutonomy = !autonomyFilter || agent.autonomy_level === autonomyFilter;
const matchesCompliance = !adminComplianceRiskOnly || (
((agent.pii && agent.pii.handles_pii === true)) ||
(agent.ip_ownership === 'Shared/TBD') ||
(agent.autonomy_level === 'Autopilot')
);
return matchesSearch && matchesStatus && matchesAudit
&& matchesEntity && matchesType && matchesAutonomy && matchesCompliance;
});
displayAgents(filtered);

View file

@ -44,6 +44,21 @@
</div>
{% endif %}
<!-- Incomplete-registration banner (populated by JS) -->
<div class="row mb-3" id="incompleteBannerRow" style="display:none;">
<div class="col-12">
<div class="alert alert-warning d-flex align-items-center justify-content-between" role="alert" style="border-radius:10px;">
<div>
<i class="fas fa-exclamation-circle me-2"></i>
<strong id="incompleteBannerCount"></strong>
</div>
<button type="button" class="btn btn-sm btn-warning" id="filterIncompleteBtn">
<i class="fas fa-filter me-1"></i>Show only incomplete
</button>
</div>
</div>
</div>
<!-- View Toggle Tabs -->
<div class="row mb-4">
<div class="col-12">
@ -105,7 +120,7 @@
<option value="Strategy">Strategy</option>
<option value="Creative">Creative</option>
<option value="Oversight including delivery">Oversight including delivery</option>
<option value="Optimization">Optimization</option>
<option value="Optimisation">Optimisation</option>
<option value="Back Office including operations">Back Office including operations</option>
<option value="Pencil Agents">Pencil Agents</option>
</select>
@ -138,6 +153,44 @@
</button>
</div>
</div>
<!-- Second filter row: governance dimensions + compliance quick filter -->
<div class="row align-items-center mt-3">
<div class="col-md-3 mb-3 mb-md-0">
<select class="form-select" id="businessEntityFilter">
<option value="">All Business Entities</option>
<option value="OLIVER">OLIVER</option>
<option value="DARE">DARE</option>
<option value="Brandtech Group">Brandtech Group</option>
<option value="Pencil">Pencil</option>
<option value="Jellyfish">Jellyfish</option>
<option value="Adjust">Adjust</option>
<option value="Other">Other</option>
</select>
</div>
<div class="col-md-3 mb-3 mb-md-0">
<select class="form-select" id="agentTypeFilter">
<option value="">All Agent Types</option>
<option value="Utility">Utility</option>
<option value="Functional">Functional</option>
<option value="Supervisory">Supervisory</option>
<option value="Guardian">Guardian</option>
</select>
</div>
<div class="col-md-3 mb-3 mb-md-0">
<select class="form-select" id="autonomyFilter">
<option value="">All Autonomy Levels</option>
<option value="Human-Led">Human-Led</option>
<option value="Hybrid">Hybrid</option>
<option value="Autopilot">Autopilot</option>
</select>
</div>
<div class="col-md-3 mb-3 mb-md-0">
<button type="button" class="btn btn-outline-danger w-100" id="complianceRiskBtn"
title="Show only agents with PII=Yes, IP=Shared/TBD, or Autopilot autonomy">
<i class="fas fa-shield-alt me-1"></i>Compliance risks
</button>
</div>
</div>
</div>
</div>
</div>
@ -350,7 +403,7 @@
<option value="Strategy">Strategy</option>
<option value="Creative">Creative</option>
<option value="Oversight including delivery">Oversight including delivery</option>
<option value="Optimization">Optimization</option>
<option value="Optimisation">Optimisation</option>
<option value="Back Office including operations">Back Office including operations</option>
<option value="Pencil Agents">Pencil Agents</option>
</select>
@ -582,6 +635,18 @@ function setupEventListeners() {
document.getElementById('auditFilter').addEventListener('change', filterAgents);
document.getElementById('disciplineFilter').addEventListener('change', filterAgents);
document.getElementById('ratingFilter').addEventListener('change', filterAgents);
document.getElementById('businessEntityFilter').addEventListener('change', filterAgents);
document.getElementById('agentTypeFilter').addEventListener('change', filterAgents);
document.getElementById('autonomyFilter').addEventListener('change', filterAgents);
document.getElementById('complianceRiskBtn').addEventListener('click', function() {
complianceRiskOnly = !complianceRiskOnly;
this.classList.toggle('btn-outline-danger', !complianceRiskOnly);
this.classList.toggle('btn-danger', complianceRiskOnly);
this.innerHTML = complianceRiskOnly
? '<i class="fas fa-times me-1"></i>Showing risks — clear'
: '<i class="fas fa-shield-alt me-1"></i>Compliance risks';
filterAgents();
});
document.getElementById('sortBy').addEventListener('change', sortAndDisplayAgents);
document.getElementById('refreshBtn').addEventListener('click', function() {
// Reload both datasets to ensure counts are accurate
@ -600,6 +665,33 @@ function setupEventListeners() {
document.getElementById('myAgentsTab').addEventListener('change', function() {
if (this.checked) switchToView('my');
});
// Incomplete-registration banner: load count and wire the filter button
loadIncompleteCount();
document.getElementById('filterIncompleteBtn').addEventListener('click', function() {
incompleteOnly = !incompleteOnly;
this.classList.toggle('btn-warning', !incompleteOnly);
this.classList.toggle('btn-secondary', incompleteOnly);
this.innerHTML = incompleteOnly
? '<i class="fas fa-times me-1"></i>Showing incomplete only — clear'
: '<i class="fas fa-filter me-1"></i>Show only incomplete';
filterAgents();
});
}
let incompleteOnly = false;
let complianceRiskOnly = false;
async function loadIncompleteCount() {
try {
const r = await fetch('{{ base_path }}/api/agents/incomplete', { credentials: 'include' });
if (!r.ok) return;
const list = await r.json();
if (list.length > 0) {
document.getElementById('incompleteBannerRow').style.display = '';
document.getElementById('incompleteBannerCount').textContent =
list.length + (list.length === 1 ? ' agent needs' : ' agents need') + ' registration completed';
}
} catch (e) { /* non-blocking */ }
}
async function loadAgentsForCurrentView() {
@ -735,6 +827,7 @@ function displayAgents(agentsToShow) {
<span class="agent-status status-${agent.agent_status || 'Development'}">
${agent.agent_status || 'Development'}
</span>
${agent.registration_complete === false ? `<span class="badge bg-warning text-dark" title="This agent's registration form has not been completed"><i class="fas fa-exclamation-circle me-1"></i>Incomplete</span>` : ''}
${agent.agent_version ? `<span class="badge bg-light text-dark">v${agent.agent_version}</span>` : ''}
${agent.discipline ? `<span class="badge bg-purple" title="Discipline"><i class="fas fa-layer-group me-1"></i>${agent.discipline}</span>` : ''}
${agent.rating ? `<span class="card-star-rating" title="Rating: ${agent.rating}/5">${getCardStars(agent.rating)} <small class="text-muted">${agent.rating.toFixed(1)}${agent.rating_count ? ' (' + agent.rating_count + ')' : ''}</small></span>` : ''}
@ -754,6 +847,11 @@ function displayAgents(agentsToShow) {
<div class="text-end">
<small class="text-muted">${formatDate(agent.agent_created_at)}</small>
<div class="mt-1">
${(agent.registration_complete === false && canUserEditAgent(agent)) ? `
<a class="btn btn-warning btn-sm me-1" href="{{ base_path }}/agent-complete/${agent.agent_id}" onclick="event.stopPropagation();" title="Complete this agent's registration">
<i class="fas fa-check-circle me-1"></i>Complete
</a>
` : ''}
${canUserEditAgent(agent) ? `
<button class="btn btn-outline-primary btn-sm me-1" onclick="event.stopPropagation(); editAgent('${agent.agent_id}')">
<i class="fas fa-edit"></i>
@ -1229,7 +1327,24 @@ function filterAgents() {
const matchesRating = !ratingFilter ||
(ratingFilter === 'unrated' && !agent.rating) ||
(ratingFilter !== 'unrated' && agent.rating && agent.rating >= parseFloat(ratingFilter));
return matchesSearch && matchesStatus && matchesAudit && matchesDiscipline && matchesRating;
const matchesIncomplete = !incompleteOnly || agent.registration_complete === false;
const businessEntityFilter = document.getElementById('businessEntityFilter').value;
const agentTypeFilter = document.getElementById('agentTypeFilter').value;
const autonomyFilter = document.getElementById('autonomyFilter').value;
const matchesEntity = !businessEntityFilter || agent.business_entity === businessEntityFilter;
const matchesType = !agentTypeFilter || agent.agent_classification === agentTypeFilter;
const matchesAutonomy = !autonomyFilter || agent.autonomy_level === autonomyFilter;
// Compliance risks: any of PII=Yes, IP=Shared/TBD, Autopilot autonomy
const matchesCompliance = !complianceRiskOnly || (
((agent.pii && agent.pii.handles_pii === true)) ||
(agent.ip_ownership === 'Shared/TBD') ||
(agent.autonomy_level === 'Autopilot')
);
return matchesSearch && matchesStatus && matchesAudit && matchesDiscipline && matchesRating
&& matchesIncomplete && matchesEntity && matchesType && matchesAutonomy && matchesCompliance;
});
displayAgents(filtered);

File diff suppressed because it is too large Load diff