Merge origin/master and fix CI lint

This commit is contained in:
Ishan (OpenClaw)
2026-02-07 11:28:58 +00:00
22 changed files with 3997 additions and 192 deletions

View File

@@ -15,6 +15,7 @@ from app.api import board_onboarding as onboarding_api
from app.api import tasks as tasks_api
from app.api.deps import ActorContext, get_board_or_404, get_task_or_404
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context
from app.core.config import settings
from app.db.pagination import paginate
from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
@@ -40,9 +41,19 @@ from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboardingRead
from app.schemas.boards import BoardRead
from app.schemas.common import OkResponse
from app.schemas.gateway_coordination import (
GatewayLeadBroadcastBoardResult,
GatewayLeadBroadcastRequest,
GatewayLeadBroadcastResponse,
GatewayLeadMessageRequest,
GatewayLeadMessageResponse,
GatewayMainAskUserRequest,
GatewayMainAskUserResponse,
)
from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
from app.services.board_leads import ensure_board_lead_agent
from app.services.task_dependencies import (
blocked_by_dependency_ids,
dependency_status_by_id,
@@ -70,6 +81,43 @@ async def _gateway_config(session: AsyncSession, board: Board) -> GatewayClientC
return GatewayClientConfig(url=gateway.url, token=gateway.token)
async def _require_gateway_main(
session: AsyncSession,
agent: Agent,
) -> tuple[Gateway, GatewayClientConfig]:
session_key = (agent.openclaw_session_id or "").strip()
if not session_key:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Agent missing session key")
gateway = (
await session.exec(select(Gateway).where(col(Gateway.main_session_key) == session_key))
).first()
if gateway is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the gateway main agent may call this endpoint.",
)
if not gateway.url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway url is required",
)
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
async def _require_gateway_board(
session: AsyncSession,
*,
gateway: Gateway,
board_id: UUID | str,
) -> Board:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
if board.gateway_id != gateway.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return board
@router.get("/boards", response_model=DefaultLimitOffsetPage[BoardRead])
async def list_boards(
session: AsyncSession = Depends(get_session),
@@ -440,3 +488,268 @@ async def agent_heartbeat(
session=session,
actor=_actor(agent_ctx),
)
@router.post(
"/boards/{board_id}/gateway/main/ask-user",
response_model=GatewayMainAskUserResponse,
)
async def ask_user_via_gateway_main(
payload: GatewayMainAskUserRequest,
board: Board = Depends(get_board_or_404),
session: AsyncSession = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> GatewayMainAskUserResponse:
import json
_guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not board.gateway_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board is not attached to a gateway",
)
gateway = await session.get(Gateway, board.gateway_id)
if gateway is None or not gateway.url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway is not configured for this board",
)
main_session_key = (gateway.main_session_key or "").strip()
if not main_session_key:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway main session key is required",
)
config = GatewayClientConfig(url=gateway.url, token=gateway.token)
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
preferred_channel = (payload.preferred_channel or "").strip()
channel_line = f"Preferred channel: {preferred_channel}\n" if preferred_channel else ""
tags = payload.reply_tags or ["gateway_main", "user_reply"]
tags_json = json.dumps(tags)
reply_source = payload.reply_source or "user_via_gateway_main"
base_url = settings.base_url or "http://localhost:8000"
message = (
"LEAD REQUEST: ASK USER\n"
f"Board: {board.name}\n"
f"Board ID: {board.id}\n"
f"From lead: {agent_ctx.agent.name}\n"
f"{correlation_line}"
f"{channel_line}\n"
f"{payload.content.strip()}\n\n"
"Please reach the user via your configured OpenClaw channel(s) (Slack/SMS/etc).\n"
"If you cannot reach them there, post the question in Mission Control board chat as a fallback.\n\n"
"When you receive the answer, reply in Mission Control by writing a NON-chat memory item on this board:\n"
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
f'Body: {{"content":"<answer>","tags":{tags_json},"source":"{reply_source}"}}\n'
"Do NOT reply in OpenClaw chat."
)
try:
await ensure_session(main_session_key, config=config, label="Main Agent")
await send_message(message, session_key=main_session_key, config=config, deliver=True)
except OpenClawGatewayError as exc:
record_activity(
session,
event_type="gateway.lead.ask_user.failed",
message=f"Lead user question failed for {board.name}: {exc}",
agent_id=agent_ctx.agent.id,
)
await session.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
record_activity(
session,
event_type="gateway.lead.ask_user.sent",
message=f"Lead requested user info via gateway main for board: {board.name}.",
agent_id=agent_ctx.agent.id,
)
main_agent = (
await session.exec(select(Agent).where(col(Agent.openclaw_session_id) == main_session_key))
).first()
await session.commit()
return GatewayMainAskUserResponse(
board_id=board.id,
main_agent_id=main_agent.id if main_agent else None,
main_agent_name=main_agent.name if main_agent else None,
)
@router.post(
"/gateway/boards/{board_id}/lead/message",
response_model=GatewayLeadMessageResponse,
)
async def message_gateway_board_lead(
board_id: UUID,
payload: GatewayLeadMessageRequest,
session: AsyncSession = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> GatewayLeadMessageResponse:
import json
gateway, config = await _require_gateway_main(session, agent_ctx.agent)
board = await _require_gateway_board(session, gateway=gateway, board_id=board_id)
lead, lead_created = await ensure_board_lead_agent(
session,
board=board,
gateway=gateway,
config=config,
user=None,
action="provision",
)
if not lead.openclaw_session_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Lead agent has no session key",
)
base_url = settings.base_url or "http://localhost:8000"
header = "GATEWAY MAIN QUESTION" if payload.kind == "question" else "GATEWAY MAIN HANDOFF"
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
tags = payload.reply_tags or ["gateway_main", "lead_reply"]
tags_json = json.dumps(tags)
reply_source = payload.reply_source or "lead_to_gateway_main"
message = (
f"{header}\n"
f"Board: {board.name}\n"
f"Board ID: {board.id}\n"
f"From agent: {agent_ctx.agent.name}\n"
f"{correlation_line}\n"
f"{payload.content.strip()}\n\n"
"Reply to the gateway main by writing a NON-chat memory item on this board:\n"
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
f'Body: {{"content":"...","tags":{tags_json},"source":"{reply_source}"}}\n'
"Do NOT reply in OpenClaw chat."
)
try:
await ensure_session(lead.openclaw_session_id, config=config, label=lead.name)
await send_message(message, session_key=lead.openclaw_session_id, config=config)
except OpenClawGatewayError as exc:
record_activity(
session,
event_type="gateway.main.lead_message.failed",
message=f"Lead message failed for {board.name}: {exc}",
agent_id=agent_ctx.agent.id,
)
await session.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
record_activity(
session,
event_type="gateway.main.lead_message.sent",
message=f"Sent {payload.kind} to lead for board: {board.name}.",
agent_id=agent_ctx.agent.id,
)
await session.commit()
return GatewayLeadMessageResponse(
board_id=board.id,
lead_agent_id=lead.id,
lead_agent_name=lead.name,
lead_created=lead_created,
)
@router.post(
"/gateway/leads/broadcast",
response_model=GatewayLeadBroadcastResponse,
)
async def broadcast_gateway_lead_message(
payload: GatewayLeadBroadcastRequest,
session: AsyncSession = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> GatewayLeadBroadcastResponse:
import json
gateway, config = await _require_gateway_main(session, agent_ctx.agent)
statement = select(Board).where(col(Board.gateway_id) == gateway.id).order_by(
col(Board.created_at).desc()
)
if payload.board_ids:
statement = statement.where(col(Board.id).in_(payload.board_ids))
boards = list(await session.exec(statement))
base_url = settings.base_url or "http://localhost:8000"
header = "GATEWAY MAIN QUESTION" if payload.kind == "question" else "GATEWAY MAIN HANDOFF"
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
tags = payload.reply_tags or ["gateway_main", "lead_reply"]
tags_json = json.dumps(tags)
reply_source = payload.reply_source or "lead_to_gateway_main"
results: list[GatewayLeadBroadcastBoardResult] = []
sent = 0
failed = 0
for board in boards:
try:
lead, _lead_created = await ensure_board_lead_agent(
session,
board=board,
gateway=gateway,
config=config,
user=None,
action="provision",
)
if not lead.openclaw_session_id:
raise ValueError("Lead agent has no session key")
message = (
f"{header}\n"
f"Board: {board.name}\n"
f"Board ID: {board.id}\n"
f"From agent: {agent_ctx.agent.name}\n"
f"{correlation_line}\n"
f"{payload.content.strip()}\n\n"
"Reply to the gateway main by writing a NON-chat memory item on this board:\n"
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
f'Body: {{"content":"...","tags":{tags_json},"source":"{reply_source}"}}\n'
"Do NOT reply in OpenClaw chat."
)
await ensure_session(lead.openclaw_session_id, config=config, label=lead.name)
await send_message(message, session_key=lead.openclaw_session_id, config=config)
results.append(
GatewayLeadBroadcastBoardResult(
board_id=board.id,
lead_agent_id=lead.id,
lead_agent_name=lead.name,
ok=True,
)
)
sent += 1
except Exception as exc:
results.append(
GatewayLeadBroadcastBoardResult(
board_id=board.id,
ok=False,
error=str(exc),
)
)
failed += 1
record_activity(
session,
event_type="gateway.main.lead_broadcast.sent",
message=f"Broadcast {payload.kind} to {sent} board leads (failed: {failed}).",
agent_id=agent_ctx.agent.id,
)
await session.commit()
return GatewayLeadBroadcastResponse(
ok=True,
sent=sent,
failed=failed,
results=results,
)

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from sqlmodel import Field, SQLModel
from app.schemas.common import NonEmptyStr
class GatewayLeadMessageRequest(SQLModel):
kind: Literal["question", "handoff"] = "question"
correlation_id: str | None = None
content: NonEmptyStr
# How the lead should reply (defaults are interpreted by templates).
reply_tags: list[str] = Field(default_factory=lambda: ["gateway_main", "lead_reply"])
reply_source: str | None = "lead_to_gateway_main"
class GatewayLeadMessageResponse(SQLModel):
ok: bool = True
board_id: UUID
lead_agent_id: UUID | None = None
lead_agent_name: str | None = None
lead_created: bool = False
class GatewayLeadBroadcastRequest(SQLModel):
kind: Literal["question", "handoff"] = "question"
correlation_id: str | None = None
content: NonEmptyStr
board_ids: list[UUID] | None = None
reply_tags: list[str] = Field(default_factory=lambda: ["gateway_main", "lead_reply"])
reply_source: str | None = "lead_to_gateway_main"
class GatewayLeadBroadcastBoardResult(SQLModel):
board_id: UUID
lead_agent_id: UUID | None = None
lead_agent_name: str | None = None
ok: bool = False
error: str | None = None
class GatewayLeadBroadcastResponse(SQLModel):
ok: bool = True
sent: int = 0
failed: int = 0
results: list[GatewayLeadBroadcastBoardResult] = Field(default_factory=list)
class GatewayMainAskUserRequest(SQLModel):
correlation_id: str | None = None
content: NonEmptyStr
preferred_channel: str | None = None
# How the main agent should reply back into Mission Control (defaults interpreted by templates).
reply_tags: list[str] = Field(default_factory=lambda: ["gateway_main", "user_reply"])
reply_source: str | None = "user_via_gateway_main"
class GatewayMainAskUserResponse(SQLModel):
ok: bool = True
board_id: UUID
main_agent_id: UUID | None = None
main_agent_name: str | None = None

View File

@@ -86,6 +86,49 @@ def _slugify(value: str) -> str:
return slug or uuid4().hex
def _agent_id_from_session_key(session_key: str | None) -> str | None:
value = (session_key or "").strip()
if not value:
return None
if not value.startswith("agent:"):
return None
parts = value.split(":")
if len(parts) < 2:
return None
agent_id = parts[1].strip()
return agent_id or None
def _extract_agent_id(payload: object) -> str | None:
def _from_list(items: object) -> str | None:
if not isinstance(items, list):
return None
for item in items:
if isinstance(item, str) and item.strip():
return item.strip()
if not isinstance(item, dict):
continue
for key in ("id", "agentId", "agent_id"):
raw = item.get(key)
if isinstance(raw, str) and raw.strip():
return raw.strip()
return None
if isinstance(payload, list):
return _from_list(payload)
if not isinstance(payload, dict):
return None
for key in ("defaultId", "default_id", "defaultAgentId", "default_agent_id"):
raw = payload.get(key)
if isinstance(raw, str) and raw.strip():
return raw.strip()
for key in ("agents", "items", "list", "data"):
agent_id = _from_list(payload.get(key))
if agent_id:
return agent_id
return None
def _agent_key(agent: Agent) -> str:
session_key = agent.openclaw_session_id or ""
if session_key.startswith("agent:"):
@@ -383,24 +426,18 @@ def _render_agent_files(
async def _gateway_default_agent_id(
config: GatewayClientConfig,
*,
fallback_session_key: str | None = None,
) -> str | None:
try:
payload = await openclaw_call("agents.list", config=config)
except OpenClawGatewayError:
return None
if not isinstance(payload, dict):
return None
default_id = payload.get("defaultId") or payload.get("default_id")
if isinstance(default_id, str) and default_id:
return default_id
agents = payload.get("agents") or []
if isinstance(agents, list) and agents:
first = agents[0]
if isinstance(first, dict):
agent_id = first.get("id")
if isinstance(agent_id, str) and agent_id:
return agent_id
return None
return _agent_id_from_session_key(fallback_session_key)
agent_id = _extract_agent_id(payload)
if agent_id:
return agent_id
return _agent_id_from_session_key(fallback_session_key)
async def _patch_gateway_agent_list(
@@ -585,7 +622,10 @@ async def provision_main_agent(
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
await ensure_session(gateway.main_session_key, config=client_config, label="Main Agent")
agent_id = await _gateway_default_agent_id(client_config)
agent_id = await _gateway_default_agent_id(
client_config,
fallback_session_key=gateway.main_session_key,
)
if not agent_id:
raise OpenClawGatewayError("Unable to resolve gateway main agent id")

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from typing import Any
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.time import utcnow
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.users import User
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_agent
def lead_session_key(board: Board) -> str:
return f"agent:lead-{board.id}:main"
def lead_agent_name(_: Board) -> str:
return "Lead Agent"
async def ensure_board_lead_agent(
session: AsyncSession,
*,
board: Board,
gateway: Gateway,
config: GatewayClientConfig,
user: User | None,
agent_name: str | None = None,
identity_profile: dict[str, str] | None = None,
action: str = "provision",
) -> tuple[Agent, bool]:
existing = (
await session.exec(
select(Agent)
.where(Agent.board_id == board.id)
.where(col(Agent.is_board_lead).is_(True))
)
).first()
if existing:
desired_name = agent_name or lead_agent_name(board)
changed = False
if existing.name != desired_name:
existing.name = desired_name
changed = True
desired_session_key = lead_session_key(board)
if not existing.openclaw_session_id:
existing.openclaw_session_id = desired_session_key
changed = True
if changed:
existing.updated_at = utcnow()
session.add(existing)
await session.commit()
await session.refresh(existing)
return existing, False
merged_identity_profile: dict[str, Any] = {
"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=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=merged_identity_profile,
openclaw_session_id=lead_session_key(board),
provision_requested_at=utcnow(),
provision_action=action,
)
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
session.add(agent)
await session.commit()
await session.refresh(agent)
try:
await provision_agent(agent, board, gateway, raw_token, user, action=action)
if agent.openclaw_session_id:
await ensure_session(agent.openclaw_session_id, config=config, label=agent.name)
await send_message(
(
f"Hello {agent.name}. Your workspace has been provisioned.\n\n"
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once "
"then delete it. Begin heartbeats after startup."
),
session_key=agent.openclaw_session_id,
config=config,
deliver=True,
)
except OpenClawGatewayError:
# Best-effort provisioning. The board/agent rows should still exist.
pass
return agent, True

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
import asyncio
import re
from collections.abc import Awaitable, Callable
from typing import TypeVar
from uuid import UUID, uuid4
from sqlmodel import col, select
@@ -19,12 +22,94 @@ from app.services.agent_provisioning import provision_agent, provision_main_agen
_TOOLS_KV_RE = re.compile(r"^(?P<key>[A-Z0-9_]+)=(?P<value>.*)$")
T = TypeVar("T")
def _slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or uuid4().hex
def _is_transient_gateway_error(exc: Exception) -> bool:
if not isinstance(exc, OpenClawGatewayError):
return False
message = str(exc).lower()
if not message:
return False
if "unsupported file" in message:
return False
if "received 1012" in message or "service restart" in message:
return True
if "http 503" in message or ("503" in message and "websocket" in message):
return True
if "temporar" in message:
return True
if "timeout" in message or "timed out" in message:
return True
if "connection closed" in message or "connection reset" in message:
return True
return False
async def _with_gateway_retry(
fn: Callable[[], Awaitable[T]],
*,
attempts: int = 3,
base_delay_s: float = 0.75,
) -> T:
for attempt in range(attempts):
try:
return await fn()
except Exception as exc:
if attempt >= attempts - 1 or not _is_transient_gateway_error(exc):
raise
await asyncio.sleep(base_delay_s * (2**attempt))
raise AssertionError("unreachable")
def _agent_id_from_session_key(session_key: str | None) -> str | None:
value = (session_key or "").strip()
if not value:
return None
if not value.startswith("agent:"):
return None
parts = value.split(":")
if len(parts) < 2:
return None
agent_id = parts[1].strip()
return agent_id or None
def _extract_agent_id(payload: object) -> str | None:
def _from_list(items: object) -> str | None:
if not isinstance(items, list):
return None
for item in items:
if isinstance(item, str) and item.strip():
return item.strip()
if not isinstance(item, dict):
continue
for key in ("id", "agentId", "agent_id"):
raw = item.get(key)
if isinstance(raw, str) and raw.strip():
return raw.strip()
return None
if isinstance(payload, list):
return _from_list(payload)
if not isinstance(payload, dict):
return None
for key in ("defaultId", "default_id", "defaultAgentId", "default_agent_id"):
raw = payload.get(key)
if isinstance(raw, str) and raw.strip():
return raw.strip()
for key in ("agents", "items", "list", "data"):
agent_id = _from_list(payload.get(key))
if agent_id:
return agent_id
return None
def _gateway_agent_id(agent: Agent) -> str:
session_key = agent.openclaw_session_id or ""
if session_key.startswith("agent:"):
@@ -94,24 +179,34 @@ async def _get_existing_auth_token(
return token or None
async def _gateway_default_agent_id(config: GatewayClientConfig) -> str | None:
try:
payload = await openclaw_call("agents.list", config=config)
except OpenClawGatewayError:
return None
if not isinstance(payload, dict):
return None
default_id = payload.get("defaultId") or payload.get("default_id")
if isinstance(default_id, str) and default_id:
return default_id
agents = payload.get("agents") or []
if isinstance(agents, list) and agents:
first = agents[0]
if isinstance(first, dict):
agent_id = first.get("id")
if isinstance(agent_id, str) and agent_id:
async def _gateway_default_agent_id(
config: GatewayClientConfig,
*,
fallback_session_key: str | None = None,
) -> str | None:
last_error: OpenClawGatewayError | None = None
# Gateways may reject WS connects transiently under load (HTTP 503).
for attempt in range(3):
try:
payload = await openclaw_call("agents.list", config=config)
agent_id = _extract_agent_id(payload)
if agent_id:
return agent_id
return None
break
except OpenClawGatewayError as exc:
last_error = exc
message = str(exc).lower()
if (
"503" not in message
and "temporar" not in message
and "rejected" not in message
and "timeout" not in message
):
break
await asyncio.sleep(0.5 * (2**attempt))
_ = last_error
return _agent_id_from_session_key(fallback_session_key)
async def sync_gateway_templates(
@@ -226,16 +321,19 @@ async def sync_gateway_templates(
)
try:
await provision_agent(
agent,
board,
gateway,
auth_token,
user,
action="update",
force_bootstrap=force_bootstrap,
reset_session=reset_sessions,
)
async def _do_provision() -> None:
await provision_agent(
agent,
board,
gateway,
auth_token,
user,
action="update",
force_bootstrap=force_bootstrap,
reset_session=reset_sessions,
)
await _with_gateway_retry(_do_provision)
result.agents_updated += 1
except Exception as exc: # pragma: no cover - gateway/network dependent
result.agents_skipped += 1
@@ -262,7 +360,10 @@ async def sync_gateway_templates(
)
return result
main_gateway_agent_id = await _gateway_default_agent_id(client_config)
main_gateway_agent_id = await _gateway_default_agent_id(
client_config,
fallback_session_key=gateway.main_session_key,
)
if not main_gateway_agent_id:
result.errors.append(
GatewayTemplatesSyncError(
@@ -277,25 +378,57 @@ async def sync_gateway_templates(
agent_gateway_id=main_gateway_agent_id, config=client_config
)
if not main_token:
result.errors.append(
GatewayTemplatesSyncError(
agent_id=main_agent.id,
agent_name=main_agent.name,
message="Skipping main agent: unable to read AUTH_TOKEN from TOOLS.md.",
if rotate_tokens:
raw_token = generate_agent_token()
main_agent.agent_token_hash = hash_agent_token(raw_token)
main_agent.updated_at = utcnow()
session.add(main_agent)
await session.commit()
await session.refresh(main_agent)
main_token = raw_token
else:
result.errors.append(
GatewayTemplatesSyncError(
agent_id=main_agent.id,
agent_name=main_agent.name,
message="Skipping main agent: unable to read AUTH_TOKEN from TOOLS.md.",
)
)
return result
if main_agent.agent_token_hash and not verify_agent_token(
main_token, main_agent.agent_token_hash
):
if rotate_tokens:
raw_token = generate_agent_token()
main_agent.agent_token_hash = hash_agent_token(raw_token)
main_agent.updated_at = utcnow()
session.add(main_agent)
await session.commit()
await session.refresh(main_agent)
main_token = raw_token
else:
result.errors.append(
GatewayTemplatesSyncError(
agent_id=main_agent.id,
agent_name=main_agent.name,
message="Warning: AUTH_TOKEN in TOOLS.md does not match backend token hash (main agent auth may be broken).",
)
)
)
return result
try:
await provision_main_agent(
main_agent,
gateway,
main_token,
user,
action="update",
force_bootstrap=force_bootstrap,
reset_session=reset_sessions,
)
async def _do_provision_main() -> None:
await provision_main_agent(
main_agent,
gateway,
main_token,
user,
action="update",
force_bootstrap=force_bootstrap,
reset_session=reset_sessions,
)
await _with_gateway_retry(_do_provision_main)
result.main_updated = True
except Exception as exc: # pragma: no cover - gateway/network dependent
result.errors.append(