From d72af041709b7b660b4a5424137ce82863616a42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 02:53:55 +0000 Subject: [PATCH] feat: add backend API endpoints for agent file import and editing Co-authored-by: abhi1693 <5083532+abhi1693@users.noreply.github.com> --- backend/app/api/agent.py | 185 ++++++++++++ backend/app/schemas/agent_files.py | 101 +++++++ .../services/openclaw/coordination_service.py | 275 ++++++++++++++++++ backend/app/services/openclaw/exceptions.py | 15 + 4 files changed, 576 insertions(+) create mode 100644 backend/app/schemas/agent_files.py diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index a290420b..a4610f57 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -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, diff --git a/backend/app/schemas/agent_files.py b/backend/app/schemas/agent_files.py new file mode 100644 index 00000000..438b65f8 --- /dev/null +++ b/backend/app/schemas/agent_files.py @@ -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], + ) diff --git a/backend/app/services/openclaw/coordination_service.py b/backend/app/services/openclaw/coordination_service.py index 8c9817c5..20bec296 100644 --- a/backend/app/services/openclaw/coordination_service.py +++ b/backend/app/services/openclaw/coordination_service.py @@ -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, *, diff --git a/backend/app/services/openclaw/exceptions.py b/backend/app/services/openclaw/exceptions.py index b1dfe95e..a2ce0e22 100644 --- a/backend/app/services/openclaw/exceptions.py +++ b/backend/app/services/openclaw/exceptions.py @@ -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}",