feat: add backend API endpoints for agent file import and editing
Co-authored-by: abhi1693 <5083532+abhi1693@users.noreply.github.com>
This commit is contained in:
@@ -1538,6 +1538,191 @@ async def update_agent_soul(
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/boards/{board_id}/agents/{agent_id}/files",
|
||||
response_model=list[dict[str, object]],
|
||||
tags=AGENT_BOARD_TAGS,
|
||||
summary="List agent files",
|
||||
description="List available agent markdown files from the gateway workspace.",
|
||||
operation_id="agent_board_list_files",
|
||||
openapi_extra=_agent_board_openapi_hints(
|
||||
intent="agent_files_list",
|
||||
when_to_use=[
|
||||
"Discover available agent files before reading",
|
||||
"Check which files can be edited",
|
||||
],
|
||||
routing_examples=[
|
||||
{
|
||||
"input": {
|
||||
"intent": "list agent configuration files",
|
||||
"required_privilege": "board_lead_or_same_actor",
|
||||
},
|
||||
"decision": "agent_board_list_files",
|
||||
}
|
||||
],
|
||||
side_effects=["No persisted side effects"],
|
||||
),
|
||||
)
|
||||
async def list_agent_files(
|
||||
agent_id: str,
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> list[dict[str, object]]:
|
||||
"""List available agent files.
|
||||
|
||||
Allowed for board lead or for an agent listing its own files.
|
||||
"""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
OpenClawAuthorizationPolicy.require_board_lead_or_same_actor(
|
||||
actor_agent=agent_ctx.agent,
|
||||
target_agent_id=agent_id,
|
||||
)
|
||||
coordination = GatewayCoordinationService(session)
|
||||
return await coordination.list_agent_files(
|
||||
board=board,
|
||||
target_agent_id=agent_id,
|
||||
correlation_id=f"files.list:{board.id}:{agent_id}",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/boards/{board_id}/agents/{agent_id}/files/{filename}",
|
||||
response_model=str,
|
||||
tags=AGENT_BOARD_TAGS,
|
||||
summary="Read agent file",
|
||||
description="Fetch content of a specific agent markdown file.",
|
||||
operation_id="agent_board_read_file",
|
||||
openapi_extra=_agent_board_openapi_hints(
|
||||
intent="agent_file_read",
|
||||
when_to_use=[
|
||||
"Read IDENTITY.md, BOOTSTRAP.md, or other agent configuration files",
|
||||
"Review current agent file content before editing",
|
||||
],
|
||||
routing_examples=[
|
||||
{
|
||||
"input": {
|
||||
"intent": "read agent configuration file",
|
||||
"required_privilege": "board_lead_or_same_actor",
|
||||
},
|
||||
"decision": "agent_board_read_file",
|
||||
}
|
||||
],
|
||||
side_effects=["No persisted side effects"],
|
||||
),
|
||||
)
|
||||
async def read_agent_file(
|
||||
agent_id: str,
|
||||
filename: str,
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> str:
|
||||
"""Read content of an agent file.
|
||||
|
||||
Allowed for board lead or for an agent reading its own files.
|
||||
"""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
OpenClawAuthorizationPolicy.require_board_lead_or_same_actor(
|
||||
actor_agent=agent_ctx.agent,
|
||||
target_agent_id=agent_id,
|
||||
)
|
||||
coordination = GatewayCoordinationService(session)
|
||||
return await coordination.get_agent_file(
|
||||
board=board,
|
||||
target_agent_id=agent_id,
|
||||
filename=filename,
|
||||
correlation_id=f"file.read:{board.id}:{agent_id}:{filename}",
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/boards/{board_id}/agents/{agent_id}/files/{filename}",
|
||||
response_model=OkResponse,
|
||||
tags=AGENT_LEAD_TAGS,
|
||||
summary="Update agent file",
|
||||
description=(
|
||||
"Write content to an agent markdown file and persist it for reprovisioning.\n\n"
|
||||
"Use this when agent configuration files need updates."
|
||||
),
|
||||
operation_id="agent_lead_update_file",
|
||||
responses={
|
||||
200: {"description": "File updated"},
|
||||
403: {
|
||||
"model": LLMErrorResponse,
|
||||
"description": "Caller is not board lead",
|
||||
},
|
||||
404: {
|
||||
"model": LLMErrorResponse,
|
||||
"description": "Board or target agent not found",
|
||||
},
|
||||
422: {
|
||||
"model": LLMErrorResponse,
|
||||
"description": "File content is invalid or empty",
|
||||
},
|
||||
502: {
|
||||
"model": LLMErrorResponse,
|
||||
"description": "Gateway sync failed",
|
||||
},
|
||||
},
|
||||
openapi_extra={
|
||||
"x-llm-intent": "agent_file_authoring",
|
||||
"x-when-to-use": [
|
||||
"Update agent configuration files like IDENTITY.md or BOOTSTRAP.md",
|
||||
"Import existing agent files into mission control",
|
||||
],
|
||||
"x-when-not-to-use": [
|
||||
"Use dedicated SOUL endpoint for SOUL.md updates",
|
||||
"Use task comments for transient guidance",
|
||||
],
|
||||
"x-required-actor": "board_lead",
|
||||
"x-prerequisites": [
|
||||
"Authenticated board lead",
|
||||
"Non-empty file content",
|
||||
"Target agent scoped to board",
|
||||
],
|
||||
"x-side-effects": [
|
||||
"Updates file content in gateway workspace",
|
||||
"Persists to database for certain files (IDENTITY.md, SOUL.md)",
|
||||
"Creates activity log entry",
|
||||
],
|
||||
"x-routing-policy": [
|
||||
"Use when updating agent configuration files",
|
||||
"Prefer dedicated SOUL endpoint for SOUL.md",
|
||||
],
|
||||
},
|
||||
)
|
||||
async def update_agent_file(
|
||||
agent_id: str,
|
||||
filename: str,
|
||||
payload: dict[str, str],
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> OkResponse:
|
||||
"""Update an agent file in gateway and optionally persist to DB.
|
||||
|
||||
Lead-only endpoint. Persists IDENTITY.md and SOUL.md for reprovisioning.
|
||||
"""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
_require_board_lead(agent_ctx)
|
||||
|
||||
content = payload.get("content", "")
|
||||
reason = payload.get("reason")
|
||||
|
||||
coordination = GatewayCoordinationService(session)
|
||||
await coordination.update_agent_file(
|
||||
board=board,
|
||||
target_agent_id=agent_id,
|
||||
filename=filename,
|
||||
content=content,
|
||||
reason=reason,
|
||||
actor_agent_id=agent_ctx.agent.id,
|
||||
correlation_id=f"file.write:{board.id}:{agent_id}:{filename}",
|
||||
)
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/boards/{board_id}/agents/{agent_id}",
|
||||
response_model=OkResponse,
|
||||
|
||||
101
backend/app/schemas/agent_files.py
Normal file
101
backend/app/schemas/agent_files.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Pydantic schemas for agent file operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import Field
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel._compat import SQLModelConfig
|
||||
|
||||
|
||||
class AgentFileRead(SQLModel):
|
||||
"""Response model for reading an agent file."""
|
||||
|
||||
model_config = SQLModelConfig(
|
||||
json_schema_extra={
|
||||
"x-llm-intent": "agent_file_content",
|
||||
"x-when-to-use": [
|
||||
"Retrieve content of an agent markdown file",
|
||||
"Read IDENTITY.md, SOUL.md, BOOTSTRAP.md, or other agent files",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
name: str = Field(
|
||||
description="File name (e.g., IDENTITY.md, SOUL.md, BOOTSTRAP.md)",
|
||||
examples=["IDENTITY.md", "SOUL.md"],
|
||||
)
|
||||
content: str = Field(
|
||||
description="File content",
|
||||
examples=["# IDENTITY.md\n\n## Core\n- Name: Agent Name"],
|
||||
)
|
||||
|
||||
|
||||
class AgentFileUpdate(SQLModel):
|
||||
"""Request model for updating an agent file."""
|
||||
|
||||
model_config = SQLModelConfig(
|
||||
json_schema_extra={
|
||||
"x-llm-intent": "agent_file_update",
|
||||
"x-when-to-use": [
|
||||
"Update an agent markdown file",
|
||||
"Modify IDENTITY.md or other editable agent files",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
content: str = Field(
|
||||
description="New file content",
|
||||
examples=["# IDENTITY.md\n\n## Core\n- Name: Updated Agent Name"],
|
||||
)
|
||||
reason: str | None = Field(
|
||||
default=None,
|
||||
description="Optional reason for the update",
|
||||
examples=["Updated agent role and communication style"],
|
||||
)
|
||||
|
||||
|
||||
class AgentFileImport(SQLModel):
|
||||
"""Request model for importing an agent file."""
|
||||
|
||||
model_config = SQLModelConfig(
|
||||
json_schema_extra={
|
||||
"x-llm-intent": "agent_file_import",
|
||||
"x-when-to-use": [
|
||||
"Import existing agent markdown file into mission control",
|
||||
"Upload IDENTITY.md, SOUL.md, or other agent files",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
name: str = Field(
|
||||
description="File name (e.g., IDENTITY.md, SOUL.md, BOOTSTRAP.md)",
|
||||
examples=["IDENTITY.md", "SOUL.md"],
|
||||
)
|
||||
content: str = Field(
|
||||
description="File content to import",
|
||||
examples=["# IDENTITY.md\n\n## Core\n- Name: Agent Name"],
|
||||
)
|
||||
reason: str | None = Field(
|
||||
default=None,
|
||||
description="Optional reason for the import",
|
||||
examples=["Importing existing agent configuration"],
|
||||
)
|
||||
|
||||
|
||||
class AgentFileListItem(SQLModel):
|
||||
"""Agent file list item."""
|
||||
|
||||
model_config = SQLModelConfig(
|
||||
json_schema_extra={
|
||||
"x-llm-intent": "agent_file_list_item",
|
||||
},
|
||||
)
|
||||
|
||||
name: str = Field(
|
||||
description="File name",
|
||||
examples=["IDENTITY.md", "SOUL.md", "BOOTSTRAP.md"],
|
||||
)
|
||||
editable: bool = Field(
|
||||
description="Whether the file can be edited via the API",
|
||||
examples=[True, False],
|
||||
)
|
||||
@@ -408,6 +408,281 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
|
||||
actor_agent_id,
|
||||
)
|
||||
|
||||
async def list_agent_files(
|
||||
self,
|
||||
*,
|
||||
board: Board,
|
||||
target_agent_id: str,
|
||||
correlation_id: str | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
"""List available agent files from the gateway."""
|
||||
trace_id = GatewayDispatchService.resolve_trace_id(
|
||||
correlation_id, prefix="coord.files.list"
|
||||
)
|
||||
self.logger.log(
|
||||
TRACE_LEVEL,
|
||||
"gateway.coordination.files_list.start trace_id=%s board_id=%s target_agent_id=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
)
|
||||
target = await self._board_agent_or_404(board=board, agent_id=target_agent_id)
|
||||
_gateway, config = await GatewayDispatchService(
|
||||
self.session
|
||||
).require_gateway_config_for_board(board)
|
||||
try:
|
||||
|
||||
async def _do_list() -> object:
|
||||
return await openclaw_call(
|
||||
"agents.files.list",
|
||||
{"agentId": agent_key(target)},
|
||||
config=config,
|
||||
)
|
||||
|
||||
payload = await self._with_gateway_retry(_do_list)
|
||||
except (OpenClawGatewayError, TimeoutError) as exc:
|
||||
self.logger.error(
|
||||
"gateway.coordination.files_list.failed trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
str(exc),
|
||||
)
|
||||
raise map_gateway_error_to_http_exception(GatewayOperation.FILES_LIST, exc) from exc
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
self.logger.critical(
|
||||
"gateway.coordination.files_list.failed_unexpected trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s error_type=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
exc.__class__.__name__,
|
||||
str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
# Parse the file list from gateway response
|
||||
if isinstance(payload, dict):
|
||||
files = payload.get("files", [])
|
||||
elif isinstance(payload, list):
|
||||
files = payload
|
||||
else:
|
||||
files = []
|
||||
|
||||
# Define editable files
|
||||
editable_files = {
|
||||
"IDENTITY.md",
|
||||
"SOUL.md",
|
||||
"BOOTSTRAP.md",
|
||||
"AGENTS.md",
|
||||
"TOOLS.md",
|
||||
"HEARTBEAT.md",
|
||||
}
|
||||
|
||||
result = []
|
||||
if isinstance(files, list):
|
||||
for file in files:
|
||||
if isinstance(file, str):
|
||||
result.append({"name": file, "editable": file in editable_files})
|
||||
elif isinstance(file, dict):
|
||||
name = file.get("name", "")
|
||||
if isinstance(name, str):
|
||||
result.append({"name": name, "editable": name in editable_files})
|
||||
|
||||
self.logger.info(
|
||||
"gateway.coordination.files_list.success trace_id=%s board_id=%s target_agent_id=%s "
|
||||
"file_count=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
len(result),
|
||||
)
|
||||
return result
|
||||
|
||||
async def get_agent_file(
|
||||
self,
|
||||
*,
|
||||
board: Board,
|
||||
target_agent_id: str,
|
||||
filename: str,
|
||||
correlation_id: str | None = None,
|
||||
) -> str:
|
||||
"""Get content of an agent file from the gateway."""
|
||||
trace_id = GatewayDispatchService.resolve_trace_id(
|
||||
correlation_id, prefix="coord.file.read"
|
||||
)
|
||||
self.logger.log(
|
||||
TRACE_LEVEL,
|
||||
"gateway.coordination.file_read.start trace_id=%s board_id=%s target_agent_id=%s "
|
||||
"filename=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
)
|
||||
target = await self._board_agent_or_404(board=board, agent_id=target_agent_id)
|
||||
_gateway, config = await GatewayDispatchService(
|
||||
self.session
|
||||
).require_gateway_config_for_board(board)
|
||||
try:
|
||||
|
||||
async def _do_get() -> object:
|
||||
return await openclaw_call(
|
||||
"agents.files.get",
|
||||
{"agentId": agent_key(target), "name": filename},
|
||||
config=config,
|
||||
)
|
||||
|
||||
payload = await self._with_gateway_retry(_do_get)
|
||||
except (OpenClawGatewayError, TimeoutError) as exc:
|
||||
self.logger.error(
|
||||
"gateway.coordination.file_read.failed trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s filename=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
str(exc),
|
||||
)
|
||||
raise map_gateway_error_to_http_exception(GatewayOperation.FILE_READ, exc) from exc
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
self.logger.critical(
|
||||
"gateway.coordination.file_read.failed_unexpected trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s filename=%s error_type=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
exc.__class__.__name__,
|
||||
str(exc),
|
||||
)
|
||||
raise
|
||||
content = self._gateway_file_content(payload)
|
||||
if content is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Invalid gateway response",
|
||||
)
|
||||
self.logger.info(
|
||||
"gateway.coordination.file_read.success trace_id=%s board_id=%s target_agent_id=%s "
|
||||
"filename=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
)
|
||||
return content
|
||||
|
||||
async def update_agent_file(
|
||||
self,
|
||||
*,
|
||||
board: Board,
|
||||
target_agent_id: str,
|
||||
filename: str,
|
||||
content: str,
|
||||
reason: str | None,
|
||||
actor_agent_id: UUID,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
"""Update an agent file in the gateway and optionally persist to DB."""
|
||||
trace_id = GatewayDispatchService.resolve_trace_id(
|
||||
correlation_id, prefix="coord.file.write"
|
||||
)
|
||||
self.logger.log(
|
||||
TRACE_LEVEL,
|
||||
"gateway.coordination.file_write.start trace_id=%s board_id=%s target_agent_id=%s "
|
||||
"filename=%s actor_agent_id=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
actor_agent_id,
|
||||
)
|
||||
target = await self._board_agent_or_404(board=board, agent_id=target_agent_id)
|
||||
normalized_content = content.strip()
|
||||
if not normalized_content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="content is required",
|
||||
)
|
||||
|
||||
# Update database fields for specific files
|
||||
if filename == "SOUL.md":
|
||||
target.soul_template = normalized_content
|
||||
target.updated_at = utcnow()
|
||||
self.session.add(target)
|
||||
await self.session.commit()
|
||||
elif filename == "IDENTITY.md":
|
||||
target.identity_template = normalized_content
|
||||
target.updated_at = utcnow()
|
||||
self.session.add(target)
|
||||
await self.session.commit()
|
||||
|
||||
_gateway, config = await GatewayDispatchService(
|
||||
self.session
|
||||
).require_gateway_config_for_board(board)
|
||||
try:
|
||||
|
||||
async def _do_set() -> object:
|
||||
return await openclaw_call(
|
||||
"agents.files.set",
|
||||
{
|
||||
"agentId": agent_key(target),
|
||||
"name": filename,
|
||||
"content": normalized_content,
|
||||
},
|
||||
config=config,
|
||||
)
|
||||
|
||||
await self._with_gateway_retry(_do_set)
|
||||
except (OpenClawGatewayError, TimeoutError) as exc:
|
||||
self.logger.error(
|
||||
"gateway.coordination.file_write.failed trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s filename=%s actor_agent_id=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
actor_agent_id,
|
||||
str(exc),
|
||||
)
|
||||
raise map_gateway_error_to_http_exception(GatewayOperation.FILE_WRITE, exc) from exc
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
self.logger.critical(
|
||||
"gateway.coordination.file_write.failed_unexpected trace_id=%s board_id=%s "
|
||||
"target_agent_id=%s filename=%s actor_agent_id=%s error_type=%s error=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
actor_agent_id,
|
||||
exc.__class__.__name__,
|
||||
str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
reason_text = (reason or "").strip()
|
||||
note = f"{filename} updated for {target.name}."
|
||||
if reason_text:
|
||||
note = f"{note} Reason: {reason_text}"
|
||||
record_activity(
|
||||
self.session,
|
||||
event_type="agent.file.updated",
|
||||
message=note,
|
||||
agent_id=actor_agent_id,
|
||||
)
|
||||
await self.session.commit()
|
||||
self.logger.info(
|
||||
"gateway.coordination.file_write.success trace_id=%s board_id=%s target_agent_id=%s "
|
||||
"filename=%s actor_agent_id=%s",
|
||||
trace_id,
|
||||
board.id,
|
||||
target_agent_id,
|
||||
filename,
|
||||
actor_agent_id,
|
||||
)
|
||||
|
||||
async def ask_user_via_gateway_main(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -14,6 +14,9 @@ class GatewayOperation(str, Enum):
|
||||
NUDGE_AGENT = "nudge_agent"
|
||||
SOUL_READ = "soul_read"
|
||||
SOUL_WRITE = "soul_write"
|
||||
FILES_LIST = "files_list"
|
||||
FILE_READ = "file_read"
|
||||
FILE_WRITE = "file_write"
|
||||
ASK_USER_DISPATCH = "ask_user_dispatch"
|
||||
LEAD_MESSAGE_DISPATCH = "lead_message_dispatch"
|
||||
LEAD_BROADCAST_DISPATCH = "lead_broadcast_dispatch"
|
||||
@@ -42,6 +45,18 @@ _GATEWAY_ERROR_POLICIES: dict[GatewayOperation, GatewayErrorPolicy] = {
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail_template="Gateway SOUL update failed: {error}",
|
||||
),
|
||||
GatewayOperation.FILES_LIST: GatewayErrorPolicy(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail_template="Gateway files list failed: {error}",
|
||||
),
|
||||
GatewayOperation.FILE_READ: GatewayErrorPolicy(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail_template="Gateway file read failed: {error}",
|
||||
),
|
||||
GatewayOperation.FILE_WRITE: GatewayErrorPolicy(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail_template="Gateway file update failed: {error}",
|
||||
),
|
||||
GatewayOperation.ASK_USER_DISPATCH: GatewayErrorPolicy(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail_template="Gateway ask-user dispatch failed: {error}",
|
||||
|
||||
Reference in New Issue
Block a user