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:
copilot-swe-agent[bot]
2026-02-21 02:53:55 +00:00
parent a8ab696a35
commit d72af04170
4 changed files with 576 additions and 0 deletions

View File

@@ -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,

View 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],
)

View File

@@ -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,
*,

View File

@@ -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}",