feat: reorder properties in various interfaces for improved consistency and readability
This commit is contained in:
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
37
backend/app/core/durations.py
Normal file
37
backend/app/core/durations.py
Normal 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
22
backend/scripts/export_openapi.py
Normal file
22
backend/scripts/export_openapi.py
Normal 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()
|
||||
Reference in New Issue
Block a user