feat(agents): Add identity and soul template fields to board creation

This commit is contained in:
Abhimanyu Saharan
2026-02-04 20:21:33 +05:30
parent 1c972edb46
commit c3357f92d9
117 changed files with 7899 additions and 1339 deletions

View File

@@ -208,7 +208,9 @@ async def create_agent(
)
session.commit()
try:
await send_provisioning_message(agent, board, raw_token, provision_token)
await send_provisioning_message(
agent, board, raw_token, provision_token, auth.user
)
record_activity(
session,
event_type="agent.provision.requested",
@@ -288,7 +290,9 @@ async def update_agent(
session.commit()
session.refresh(agent)
try:
await send_update_message(agent, board, raw_token, provision_token)
await send_update_message(
agent, board, raw_token, provision_token, auth.user
)
record_activity(
session,
event_type="agent.update.requested",
@@ -375,7 +379,9 @@ async def heartbeat_or_create_agent(
)
session.commit()
try:
await send_provisioning_message(agent, board, raw_token, provision_token)
await send_provisioning_message(
agent, board, raw_token, provision_token, actor.user
)
record_activity(
session,
event_type="agent.provision.requested",
@@ -405,7 +411,9 @@ async def heartbeat_or_create_agent(
try:
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
config = _require_gateway_config(board)
await send_provisioning_message(agent, board, raw_token, provision_token)
await send_provisioning_message(
agent, board, raw_token, provision_token, actor.user
)
record_activity(
session,
event_type="agent.provision.requested",

View File

@@ -101,6 +101,10 @@ def create_board(
data = payload.model_dump()
if data.get("gateway_token") == "":
data["gateway_token"] = None
if data.get("identity_template") == "":
data["identity_template"] = None
if data.get("soul_template") == "":
data["soul_template"] = None
if data.get("gateway_url"):
if not data.get("gateway_main_session_key"):
raise HTTPException(
@@ -137,6 +141,10 @@ def update_board(
updates = payload.model_dump(exclude_unset=True)
if updates.get("gateway_token") == "":
updates["gateway_token"] = None
if updates.get("identity_template") == "":
updates["identity_template"] = None
if updates.get("soul_template") == "":
updates["soul_template"] = None
for key, value in updates.items():
setattr(board, key, value)
if board.gateway_url:

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import asc, desc
from sqlmodel import Session, col, select
@@ -70,11 +70,26 @@ def has_valid_recent_comment(
@router.get("", response_model=list[TaskRead])
def list_tasks(
status_filter: str | None = Query(default=None, alias="status"),
assigned_agent_id: UUID | None = None,
unassigned: bool | None = None,
limit: int | None = Query(default=None, ge=1, le=200),
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> list[Task]:
return list(session.exec(select(Task).where(Task.board_id == board.id)))
statement = select(Task).where(Task.board_id == board.id)
if status_filter:
statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
if statuses:
statement = statement.where(col(Task.status).in_(statuses))
if assigned_agent_id is not None:
statement = statement.where(col(Task.assigned_agent_id) == assigned_agent_id)
if unassigned:
statement = statement.where(col(Task.assigned_agent_id).is_(None))
if limit is not None:
statement = statement.limit(limit)
return list(session.exec(statement))
@router.post("", response_model=TaskRead)

36
backend/app/api/users.py Normal file
View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session
from app.core.auth import AuthContext, get_auth_context
from app.db.session import get_session
from app.models.users import User
from app.schemas.users import UserRead, UserUpdate
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserRead)
async def get_me(auth: AuthContext = Depends(get_auth_context)) -> UserRead:
if auth.actor_type != "user" or auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return UserRead.model_validate(auth.user)
@router.patch("/me", response_model=UserRead)
async def update_me(
payload: UserUpdate,
session: Session = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
) -> UserRead:
if auth.actor_type != "user" or auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
updates = payload.model_dump(exclude_unset=True)
user: User = auth.user
for key, value in updates.items():
setattr(user, key, value)
session.add(user)
session.commit()
session.refresh(user)
return UserRead.model_validate(user)

View File

@@ -25,5 +25,10 @@ class Settings(BaseSettings):
# Database lifecycle
db_auto_migrate: bool = False
# Logging
log_level: str = "INFO"
log_format: str = "text"
log_use_utc: bool = False
settings = Settings()

View File

@@ -1,14 +1,171 @@
from __future__ import annotations
import json
import logging
import os
import sys
import time
from datetime import datetime, timezone
from typing import Any
from app.core.config import settings
from app.core.version import APP_NAME, APP_VERSION
TRACE_LEVEL = 5
logging.addLevelName(TRACE_LEVEL, "TRACE")
def _trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
if self.isEnabledFor(TRACE_LEVEL):
self._log(TRACE_LEVEL, message, args, **kwargs)
logging.Logger.trace = _trace # type: ignore[attr-defined]
_STANDARD_LOG_RECORD_ATTRS = {
"args",
"asctime",
"created",
"exc_info",
"exc_text",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"module",
"msecs",
"message",
"msg",
"name",
"pathname",
"process",
"processName",
"relativeCreated",
"stack_info",
"thread",
"threadName",
"taskName",
"app",
"version",
}
class AppLogFilter(logging.Filter):
def __init__(self, app_name: str, version: str) -> None:
super().__init__()
self._app_name = app_name
self._version = version
def filter(self, record: logging.LogRecord) -> bool:
record.app = self._app_name
record.version = self._version
return True
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"timestamp": datetime.fromtimestamp(
record.created, tz=timezone.utc
).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"app": getattr(record, "app", APP_NAME),
"version": getattr(record, "version", APP_VERSION),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
if record.stack_info:
payload["stack"] = self.formatStack(record.stack_info)
for key, value in record.__dict__.items():
if key in _STANDARD_LOG_RECORD_ATTRS or key in payload:
continue
payload[key] = value
return json.dumps(payload, separators=(",", ":"), default=str)
class KeyValueFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
base = super().format(record)
extras = {
key: value
for key, value in record.__dict__.items()
if key not in _STANDARD_LOG_RECORD_ATTRS
}
if not extras:
return base
extra_bits = " ".join(f"{key}={value}" for key, value in extras.items())
return f"{base} {extra_bits}"
class AppLogger:
_configured = False
@classmethod
def _resolve_level(cls) -> tuple[str, int]:
level_name = (settings.log_level or os.getenv("LOG_LEVEL", "INFO")).upper()
if level_name == "TRACE":
return level_name, TRACE_LEVEL
if level_name.isdigit():
return level_name, int(level_name)
return level_name, logging._nameToLevel.get(level_name, logging.INFO)
@classmethod
def configure(cls, *, force: bool = False) -> None:
if cls._configured and not force:
return
level_name, level = cls._resolve_level()
handler = logging.StreamHandler(sys.stdout)
handler.addFilter(AppLogFilter(APP_NAME, APP_VERSION))
format_name = (settings.log_format or "text").lower()
if format_name == "json":
formatter: logging.Formatter = JsonFormatter()
else:
formatter = KeyValueFormatter(
"%(asctime)s %(levelname)s %(name)s %(message)s app=%(app)s version=%(version)s"
)
if settings.log_use_utc:
formatter.converter = time.gmtime
handler.setFormatter(formatter)
root = logging.getLogger()
root.setLevel(level)
root.handlers.clear()
root.addHandler(handler)
# Uvicorn & HTTP clients
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
logging.getLogger(logger_name).setLevel(level)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
# SQL logs only at TRACE
sql_loggers = ("sqlalchemy", "sqlalchemy.engine", "sqlalchemy.pool")
if level_name == "TRACE":
for name in sql_loggers:
logger = logging.getLogger(name)
logger.disabled = False
logger.setLevel(logging.INFO)
else:
for name in sql_loggers:
logger = logging.getLogger(name)
logger.disabled = True
cls._configured = True
@classmethod
def get_logger(cls, name: str | None = None) -> logging.Logger:
if not cls._configured:
cls.configure()
return logging.getLogger(name)
def configure_logging() -> None:
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
level = logging._nameToLevel.get(level_name, logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
force=True,
)
AppLogger.configure()

View File

@@ -0,0 +1,2 @@
APP_NAME = "mission-control"
APP_VERSION = "0.1.0"

View File

@@ -9,6 +9,7 @@ from app.api.auth import router as auth_router
from app.api.boards import router as boards_router
from app.api.gateway import router as gateway_router
from app.api.tasks import router as tasks_router
from app.api.users import router as users_router
from app.core.config import settings
from app.core.logging import configure_logging
from app.db.session import init_db
@@ -38,6 +39,16 @@ def health() -> dict[str, bool]:
return {"ok": True}
@app.get("/healthz")
def healthz() -> dict[str, bool]:
return {"ok": True}
@app.get("/readyz")
def readyz() -> dict[str, bool]:
return {"ok": True}
api_v1 = APIRouter(prefix="/api/v1")
api_v1.include_router(auth_router)
api_v1.include_router(agents_router)
@@ -45,4 +56,5 @@ api_v1.include_router(activity_router)
api_v1.include_router(gateway_router)
api_v1.include_router(boards_router)
api_v1.include_router(tasks_router)
api_v1.include_router(users_router)
app.include_router(api_v1)

View File

@@ -18,5 +18,7 @@ class Board(TenantScoped, table=True):
gateway_token: str | None = Field(default=None)
gateway_main_session_key: str | None = Field(default=None)
gateway_workspace_root: str | None = Field(default=None)
identity_template: str | None = Field(default=None)
soul_template: str | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -12,4 +12,9 @@ class User(SQLModel, table=True):
clerk_user_id: str = Field(index=True, unique=True)
email: str | None = Field(default=None, index=True)
name: str | None = None
preferred_name: str | None = None
pronouns: str | None = None
timezone: str | None = None
notes: str | None = None
context: str | None = None
is_super_admin: bool = Field(default=False)

View File

@@ -2,7 +2,7 @@ from app.schemas.activity_events import ActivityEventRead
from app.schemas.agents import AgentCreate, AgentRead, AgentUpdate
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate
from app.schemas.users import UserCreate, UserRead
from app.schemas.users import UserCreate, UserRead, UserUpdate
__all__ = [
"ActivityEventRead",
@@ -17,4 +17,5 @@ __all__ = [
"TaskUpdate",
"UserCreate",
"UserRead",
"UserUpdate",
]

View File

@@ -12,6 +12,8 @@ class BoardBase(SQLModel):
gateway_url: str | None = None
gateway_main_session_key: str | None = None
gateway_workspace_root: str | None = None
identity_template: str | None = None
soul_template: str | None = None
class BoardCreate(BoardBase):
@@ -25,6 +27,8 @@ class BoardUpdate(SQLModel):
gateway_token: str | None = None
gateway_main_session_key: str | None = None
gateway_workspace_root: str | None = None
identity_template: str | None = None
soul_template: str | None = None
class BoardRead(BoardBase):

View File

@@ -9,12 +9,26 @@ class UserBase(SQLModel):
clerk_user_id: str
email: str | None = None
name: str | None = None
preferred_name: str | None = None
pronouns: str | None = None
timezone: str | None = None
notes: str | None = None
context: str | None = None
class UserCreate(UserBase):
pass
class UserUpdate(SQLModel):
name: str | None = None
preferred_name: str | None = None
pronouns: str | None = None
timezone: str | None = None
notes: str | None = None
context: str | None = None
class UserRead(UserBase):
id: UUID
is_super_admin: bool

View File

@@ -12,6 +12,7 @@ from app.core.config import settings
from app.integrations.openclaw_gateway import GatewayConfig, ensure_session, send_message
from app.models.agents import Agent
from app.models.boards import Board
from app.models.users import User
TEMPLATE_FILES = [
"AGENTS.md",
@@ -64,11 +65,18 @@ def _template_env() -> Environment:
)
def _read_templates(context: dict[str, str]) -> dict[str, str]:
def _read_templates(
context: dict[str, str], overrides: dict[str, str] | None = None
) -> dict[str, str]:
env = _template_env()
templates: dict[str, str] = {}
override_map = overrides or {}
for name in TEMPLATE_FILES:
path = _templates_root() / name
override = override_map.get(name)
if override:
templates[name] = env.from_string(override).render(**context).strip()
continue
if not path.exists():
templates[name] = ""
continue
@@ -90,7 +98,9 @@ def _workspace_path(agent_name: str, workspace_root: str) -> str:
return f"{root}/workspace-{_slugify(agent_name)}"
def _build_context(agent: Agent, board: Board, auth_token: str) -> dict[str, str]:
def _build_context(
agent: Agent, board: Board, auth_token: str, user: User | None
) -> dict[str, str]:
if not board.gateway_workspace_root:
raise ValueError("gateway_workspace_root is required")
if not board.gateway_main_session_key:
@@ -111,25 +121,32 @@ def _build_context(agent: Agent, board: Board, auth_token: str) -> dict[str, str
"auth_token": auth_token,
"main_session_key": main_session_key,
"workspace_root": workspace_root,
"user_name": "Unset",
"user_preferred_name": "Unset",
"user_timezone": "Unset",
"user_notes": "Fill in user context.",
"user_name": user.name if user else "",
"user_preferred_name": user.preferred_name if user else "",
"user_pronouns": user.pronouns if user else "",
"user_timezone": user.timezone if user else "",
"user_notes": user.notes if user else "",
"user_context": user.context if user else "",
}
def _build_file_blocks(context: dict[str, str]) -> str:
templates = _read_templates(context)
def _build_file_blocks(context: dict[str, str], board: Board) -> str:
overrides: dict[str, str] = {}
if board.identity_template:
overrides["IDENTITY.md"] = board.identity_template
if board.soul_template:
overrides["SOUL.md"] = board.soul_template
templates = _read_templates(context, overrides=overrides)
return "".join(
_render_file_block(name, templates.get(name, "")) for name in TEMPLATE_FILES
)
def build_provisioning_message(
agent: Agent, board: Board, auth_token: str, confirm_token: str
agent: Agent, board: Board, auth_token: str, confirm_token: str, user: User | None
) -> str:
context = _build_context(agent, board, auth_token)
file_blocks = _build_file_blocks(context)
context = _build_context(agent, board, auth_token, user)
file_blocks = _build_file_blocks(context, board)
heartbeat_snippet = json.dumps(
{
"id": _agent_key(agent),
@@ -173,10 +190,10 @@ def build_provisioning_message(
def build_update_message(
agent: Agent, board: Board, auth_token: str, confirm_token: str
agent: Agent, board: Board, auth_token: str, confirm_token: str, user: User | None
) -> str:
context = _build_context(agent, board, auth_token)
file_blocks = _build_file_blocks(context)
context = _build_context(agent, board, auth_token, user)
file_blocks = _build_file_blocks(context, board)
heartbeat_snippet = json.dumps(
{
"id": _agent_key(agent),
@@ -223,6 +240,7 @@ async def send_provisioning_message(
board: Board,
auth_token: str,
confirm_token: str,
user: User | None,
) -> None:
if not board.gateway_url:
return
@@ -231,7 +249,7 @@ async def send_provisioning_message(
main_session = board.gateway_main_session_key
config = GatewayConfig(url=board.gateway_url, token=board.gateway_token)
await ensure_session(main_session, config=config, label="Main Agent")
message = build_provisioning_message(agent, board, auth_token, confirm_token)
message = build_provisioning_message(agent, board, auth_token, confirm_token, user)
await send_message(message, session_key=main_session, config=config, deliver=False)
@@ -240,6 +258,7 @@ async def send_update_message(
board: Board,
auth_token: str,
confirm_token: str,
user: User | None,
) -> None:
if not board.gateway_url:
return
@@ -248,5 +267,5 @@ async def send_update_message(
main_session = board.gateway_main_session_key
config = GatewayConfig(url=board.gateway_url, token=board.gateway_token)
await ensure_session(main_session, config=config, label="Main Agent")
message = build_update_message(agent, board, auth_token, confirm_token)
message = build_update_message(agent, board, auth_token, confirm_token, user)
await send_message(message, session_key=main_session, config=config, deliver=False)