feat: reorder properties in various interfaces for improved consistency and readability

This commit is contained in:
Abhimanyu Saharan
2026-02-06 21:56:16 +05:30
parent bc6345978d
commit 5611f8eb67
68 changed files with 3424 additions and 3014 deletions

3
backend/.gitignore vendored
View File

@@ -7,3 +7,6 @@ __pycache__/
# Generated on demand from uv.lock (single source of truth is pyproject.toml + uv.lock).
requirements.txt
requirements-dev.txt
# Generated for orval input (avoid needing a running backend/DB).
openapi.json

View File

@@ -227,12 +227,14 @@ async def create_task_comment(
@router.get("/boards/{board_id}/memory", response_model=DefaultLimitOffsetPage[BoardMemoryRead])
async def list_board_memory(
is_chat: bool | None = Query(default=None),
board: Board = Depends(get_board_or_404),
session: AsyncSession = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> DefaultLimitOffsetPage[BoardMemoryRead]:
_guard_board_access(agent_ctx, board)
return await board_memory_api.list_board_memory(
is_chat=is_chat,
board=board,
session=session,
actor=_actor(agent_ctx),

View File

@@ -5,6 +5,7 @@ import re
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import ValidationError
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -25,8 +26,10 @@ from app.schemas.board_onboarding import (
BoardOnboardingAgentComplete,
BoardOnboardingAgentUpdate,
BoardOnboardingConfirm,
BoardOnboardingLeadAgentDraft,
BoardOnboardingRead,
BoardOnboardingStart,
BoardOnboardingUserProfile,
)
from app.schemas.boards import BoardRead
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_agent
@@ -65,6 +68,9 @@ async def _ensure_lead_agent(
gateway: Gateway,
config: GatewayClientConfig,
auth: AuthContext,
*,
agent_name: str | None = None,
identity_profile: dict[str, str] | None = None,
) -> Agent:
existing = (
await session.exec(
@@ -74,24 +80,31 @@ async def _ensure_lead_agent(
)
).first()
if existing:
if existing.name != _lead_agent_name(board):
existing.name = _lead_agent_name(board)
desired_name = agent_name or _lead_agent_name(board)
if existing.name != desired_name:
existing.name = desired_name
session.add(existing)
await session.commit()
await session.refresh(existing)
return existing
merged_identity_profile = {
"role": "Board Lead",
"communication_style": "direct, concise, practical",
"emoji": ":gear:",
}
if identity_profile:
merged_identity_profile.update(
{key: value.strip() for key, value in identity_profile.items() if value.strip()}
)
agent = Agent(
name=_lead_agent_name(board),
name=agent_name or _lead_agent_name(board),
status="provisioning",
board_id=board.id,
is_board_lead=True,
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
identity_profile={
"role": "Board Lead",
"communication_style": "direct, concise, practical",
"emoji": ":gear:",
},
identity_profile=merged_identity_profile,
)
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
@@ -162,7 +175,11 @@ async def start_onboarding(
prompt = (
"BOARD ONBOARDING REQUEST\n\n"
f"Board Name: {board.name}\n"
"You are the main agent. Ask the user 3-6 focused questions to clarify their goal.\n"
"You are the main agent. Ask the user 6-10 focused questions total:\n"
"- 3-6 questions to clarify the board goal.\n"
"- 1 question to choose a unique name for the board lead agent (first-name style).\n"
"- 2-4 questions to capture the user's preferences for how the board lead should work\n"
" (communication style, autonomy, update cadence, and output formatting).\n"
"Do NOT respond in OpenClaw chat.\n"
"All onboarding responses MUST be sent to Mission Control via API.\n"
f"Mission Control base URL: {base_url}\n"
@@ -178,13 +195,19 @@ async def start_onboarding(
f'curl -s -X POST "{base_url}/api/v1/agent/boards/{board.id}/onboarding" '
'-H "X-Agent-Token: $AUTH_TOKEN" '
'-H "Content-Type: application/json" '
'-d \'{"status":"complete","board_type":"goal","objective":"...","success_metrics":{...},"target_date":"YYYY-MM-DD"}\'\n'
'-d \'{"status":"complete","board_type":"goal","objective":"...","success_metrics":{"metric":"...","target":"..."},"target_date":"YYYY-MM-DD","user_profile":{"preferred_name":"...","pronouns":"...","timezone":"...","notes":"...","context":"..."},"lead_agent":{"name":"Ava","identity_profile":{"role":"Board Lead","communication_style":"direct, concise, practical","emoji":":gear:"},"autonomy_level":"balanced","verbosity":"concise","output_format":"bullets","update_cadence":"daily","custom_instructions":"..."}}\'\n'
"ENUMS:\n"
"- board_type: goal | general\n"
"- lead_agent.autonomy_level: ask_first | balanced | autonomous\n"
"- lead_agent.verbosity: concise | balanced | detailed\n"
"- lead_agent.output_format: bullets | mixed | narrative\n"
"- lead_agent.update_cadence: asap | hourly | daily | weekly\n"
"QUESTION FORMAT (one question per response, no arrays, no markdown, no extra text):\n"
'{"question":"...","options":[{"id":"1","label":"..."},{"id":"2","label":"..."}]}\n'
"Do NOT wrap questions in a list. Do NOT add commentary.\n"
"When you have enough info, return JSON ONLY (via API):\n"
'{"status":"complete","board_type":"goal"|"general","objective":"...",'
'"success_metrics":{...},"target_date":"YYYY-MM-DD"}.'
"When you have enough info, send one final response with status=complete.\n"
"The completion payload must include board_type. If board_type=goal, include objective + success_metrics.\n"
"Also include user_profile + lead_agent to configure the board lead's working style.\n"
)
try:
@@ -337,10 +360,71 @@ async def confirm_onboarding(
onboarding.status = "confirmed"
onboarding.updated_at = utcnow()
user_profile: BoardOnboardingUserProfile | None = None
lead_agent: BoardOnboardingLeadAgentDraft | None = None
if isinstance(onboarding.draft_goal, dict):
raw_profile = onboarding.draft_goal.get("user_profile")
if raw_profile is not None:
try:
user_profile = BoardOnboardingUserProfile.model_validate(raw_profile)
except ValidationError:
user_profile = None
raw_lead = onboarding.draft_goal.get("lead_agent")
if raw_lead is not None:
try:
lead_agent = BoardOnboardingLeadAgentDraft.model_validate(raw_lead)
except ValidationError:
lead_agent = None
if auth.user and user_profile:
changed = False
if user_profile.preferred_name is not None:
auth.user.preferred_name = user_profile.preferred_name
changed = True
if user_profile.pronouns is not None:
auth.user.pronouns = user_profile.pronouns
changed = True
if user_profile.timezone is not None:
auth.user.timezone = user_profile.timezone
changed = True
if user_profile.notes is not None:
auth.user.notes = user_profile.notes
changed = True
if user_profile.context is not None:
auth.user.context = user_profile.context
changed = True
if changed:
session.add(auth.user)
lead_identity_profile: dict[str, str] = {}
lead_name: str | None = None
if lead_agent:
lead_name = lead_agent.name
if lead_agent.identity_profile:
lead_identity_profile.update(lead_agent.identity_profile)
if lead_agent.autonomy_level:
lead_identity_profile["autonomy_level"] = lead_agent.autonomy_level
if lead_agent.verbosity:
lead_identity_profile["verbosity"] = lead_agent.verbosity
if lead_agent.output_format:
lead_identity_profile["output_format"] = lead_agent.output_format
if lead_agent.update_cadence:
lead_identity_profile["update_cadence"] = lead_agent.update_cadence
if lead_agent.custom_instructions:
lead_identity_profile["custom_instructions"] = lead_agent.custom_instructions
gateway, config = await _gateway_config(session, board)
session.add(board)
session.add(onboarding)
await session.commit()
await session.refresh(board)
await _ensure_lead_agent(session, board, gateway, config, auth)
await _ensure_lead_agent(
session,
board,
gateway,
config,
auth,
agent_name=lead_name,
identity_profile=lead_identity_profile or None,
)
return board

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import re
_DURATION_RE = re.compile(r"^(?P<num>[1-9]\\d*)\\s*(?P<unit>[smhdw])$", flags=re.IGNORECASE)
_MULTIPLIERS: dict[str, int] = {
"s": 1,
"m": 60,
"h": 60 * 60,
"d": 60 * 60 * 24,
"w": 60 * 60 * 24 * 7,
}
def normalize_every(value: str) -> str:
normalized = value.strip().lower().replace(" ", "")
if not normalized:
raise ValueError("schedule is required")
return normalized
def parse_every_to_seconds(value: str) -> int:
normalized = normalize_every(value)
match = _DURATION_RE.match(normalized)
if not match:
raise ValueError('Invalid schedule. Expected format like "10m", "1h", "2d", "1w".')
num = int(match.group("num"))
unit = match.group("unit").lower()
seconds = num * _MULTIPLIERS[unit]
if seconds <= 0:
raise ValueError("Schedule must be greater than 0.")
# Prevent accidental absurd schedules (e.g. 999999999d).
if seconds > 60 * 60 * 24 * 365 * 10:
raise ValueError("Schedule is too large (max 10 years).")
return seconds

View File

@@ -1,10 +1,10 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal, Self
from typing import Any, Literal, Self
from uuid import UUID
from pydantic import Field, model_validator
from pydantic import Field, field_validator, model_validator
from sqlmodel import SQLModel
from app.schemas.common import NonEmptyStr
@@ -43,8 +43,87 @@ class BoardOnboardingAgentQuestion(SQLModel):
options: list[BoardOnboardingQuestionOption] = Field(min_length=1)
def _normalize_optional_text(value: Any) -> Any:
if value is None:
return None
if isinstance(value, str):
text = value.strip()
return text or None
return value
class BoardOnboardingUserProfile(SQLModel):
preferred_name: str | None = None
pronouns: str | None = None
timezone: str | None = None
notes: str | None = None
context: str | None = None
@field_validator(
"preferred_name",
"pronouns",
"timezone",
"notes",
"context",
mode="before",
)
@classmethod
def normalize_text(cls, value: Any) -> Any:
return _normalize_optional_text(value)
LeadAgentAutonomyLevel = Literal["ask_first", "balanced", "autonomous"]
LeadAgentVerbosity = Literal["concise", "balanced", "detailed"]
LeadAgentOutputFormat = Literal["bullets", "mixed", "narrative"]
LeadAgentUpdateCadence = Literal["asap", "hourly", "daily", "weekly"]
class BoardOnboardingLeadAgentDraft(SQLModel):
name: NonEmptyStr | None = None
# role, communication_style, emoji are expected keys.
identity_profile: dict[str, str] | None = None
autonomy_level: LeadAgentAutonomyLevel | None = None
verbosity: LeadAgentVerbosity | None = None
output_format: LeadAgentOutputFormat | None = None
update_cadence: LeadAgentUpdateCadence | None = None
custom_instructions: str | None = None
@field_validator(
"autonomy_level",
"verbosity",
"output_format",
"update_cadence",
"custom_instructions",
mode="before",
)
@classmethod
def normalize_text_fields(cls, value: Any) -> Any:
return _normalize_optional_text(value)
@field_validator("identity_profile", mode="before")
@classmethod
def normalize_identity_profile(cls, value: Any) -> Any:
if value is None:
return None
if not isinstance(value, dict):
return value
normalized: dict[str, str] = {}
for raw_key, raw_val in value.items():
if raw_val is None:
continue
key = str(raw_key).strip()
if not key:
continue
val = str(raw_val).strip()
if val:
normalized[key] = val
return normalized or None
class BoardOnboardingAgentComplete(BoardOnboardingConfirm):
status: Literal["complete"]
user_profile: BoardOnboardingUserProfile | None = None
lead_agent: BoardOnboardingLeadAgentDraft | None = None
BoardOnboardingAgentUpdate = BoardOnboardingAgentComplete | BoardOnboardingAgentQuestion
@@ -56,6 +135,6 @@ class BoardOnboardingRead(SQLModel):
session_key: str
status: str
messages: list[dict[str, object]] | None = None
draft_goal: dict[str, object] | None = None
draft_goal: BoardOnboardingAgentComplete | None = None
created_at: datetime
updated_at: datetime

View File

@@ -29,6 +29,14 @@ IDENTITY_PROFILE_FIELDS = {
"emoji": "identity_emoji",
}
EXTRA_IDENTITY_PROFILE_FIELDS = {
"autonomy_level": "identity_autonomy_level",
"verbosity": "identity_verbosity",
"output_format": "identity_output_format",
"update_cadence": "identity_update_cadence",
"custom_instructions": "identity_custom_instructions",
}
DEFAULT_GATEWAY_FILES = frozenset(
{
"AGENTS.md",
@@ -85,7 +93,8 @@ def _heartbeat_config(agent: Agent) -> dict[str, Any]:
def _template_env() -> Environment:
return Environment(
loader=FileSystemLoader(_templates_root()),
autoescape=select_autoescape(default=True),
# Render markdown verbatim (HTML escaping makes it harder for agents to read).
autoescape=select_autoescape(default=False),
undefined=StrictUndefined,
keep_trailing_newline=True,
)
@@ -95,12 +104,15 @@ def _heartbeat_template_name(agent: Agent) -> str:
return HEARTBEAT_LEAD_TEMPLATE if agent.is_board_lead else HEARTBEAT_AGENT_TEMPLATE
def _workspace_path(agent_name: str, workspace_root: str) -> str:
def _workspace_path(agent: Agent, workspace_root: str) -> str:
if not workspace_root:
raise ValueError("gateway_workspace_root is required")
root = workspace_root
root = root.rstrip("/")
return f"{root}/workspace-{_slugify(agent_name)}"
root = workspace_root.rstrip("/")
# Use agent key derived from session key when possible. This prevents collisions for
# lead agents (session key includes board id) even if multiple boards share the same
# display name (e.g. "Lead Agent").
key = _agent_key(agent)
return f"{root}/workspace-{_slugify(key)}"
def _build_context(
@@ -116,7 +128,7 @@ def _build_context(
raise ValueError("gateway_main_session_key is required")
agent_id = str(agent.id)
workspace_root = gateway.workspace_root
workspace_path = _workspace_path(agent.name, workspace_root)
workspace_path = _workspace_path(agent, workspace_root)
session_key = agent.openclaw_session_id or ""
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
main_session_key = gateway.main_session_key
@@ -140,6 +152,10 @@ def _build_context(
context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field])
for field, context_key in IDENTITY_PROFILE_FIELDS.items()
}
extra_identity_context = {
context_key: normalized_identity.get(field, "")
for field, context_key in EXTRA_IDENTITY_PROFILE_FIELDS.items()
}
preferred_name = (user.preferred_name or "") if user else ""
if preferred_name:
preferred_name = preferred_name.strip().split()[0]
@@ -167,6 +183,7 @@ def _build_context(
"user_notes": (user.notes or "") if user else "",
"user_context": (user.context or "") if user else "",
**identity_context,
**extra_identity_context,
}
@@ -197,6 +214,10 @@ def _build_main_context(
context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field])
for field, context_key in IDENTITY_PROFILE_FIELDS.items()
}
extra_identity_context = {
context_key: normalized_identity.get(field, "")
for field, context_key in EXTRA_IDENTITY_PROFILE_FIELDS.items()
}
preferred_name = (user.preferred_name or "") if user else ""
if preferred_name:
preferred_name = preferred_name.strip().split()[0]
@@ -215,6 +236,7 @@ def _build_main_context(
"user_notes": (user.notes or "") if user else "",
"user_context": (user.context or "") if user else "",
**identity_context,
**extra_identity_context,
}
@@ -457,7 +479,7 @@ async def provision_agent(
await ensure_session(session_key, config=client_config, label=agent.name)
agent_id = _agent_key(agent)
workspace_path = _workspace_path(agent.name, gateway.workspace_root)
workspace_path = _workspace_path(agent, gateway.workspace_root)
heartbeat = _heartbeat_config(agent)
await _patch_gateway_agent_list(agent_id, workspace_path, heartbeat, client_config)
@@ -564,5 +586,5 @@ async def cleanup_agent(
workspace_path = entry.get("workspace") if entry else None
if not workspace_path:
workspace_path = _workspace_path(agent.name, gateway.workspace_root)
workspace_path = _workspace_path(agent, gateway.workspace_root)
return workspace_path

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
import json
from pathlib import Path
import sys
BACKEND_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND_ROOT))
from app.main import app # noqa: E402
def main() -> None:
# Importing the FastAPI app does not run lifespan hooks, so this does not require a DB.
out_path = BACKEND_ROOT / "openapi.json"
payload = app.openapi()
out_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
print(str(out_path))
if __name__ == "__main__":
main()