Compare commits
7 Commits
master
...
copilot/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9816a43f91 | ||
|
|
8c197ffbbd | ||
|
|
23fe9b869d | ||
|
|
d4f519c580 | ||
|
|
e99b41fa9a | ||
|
|
d72af04170 | ||
|
|
a8ab696a35 |
@@ -24,6 +24,7 @@ from app.models.boards import Board
|
|||||||
from app.models.tags import Tag
|
from app.models.tags import Tag
|
||||||
from app.models.task_dependencies import TaskDependency
|
from app.models.task_dependencies import TaskDependency
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
|
from app.schemas.agent_files import AgentFileUpdate
|
||||||
from app.schemas.agents import (
|
from app.schemas.agents import (
|
||||||
AgentCreate,
|
AgentCreate,
|
||||||
AgentHeartbeat,
|
AgentHeartbeat,
|
||||||
@@ -1538,6 +1539,190 @@ async def update_agent_soul(
|
|||||||
return OkResponse()
|
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. While this endpoint can "
|
||||||
|
"handle SOUL.md, prefer the dedicated SOUL endpoint for better semantic clarity "
|
||||||
|
"and specialized SOUL operations."
|
||||||
|
),
|
||||||
|
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, BOOTSTRAP.md, or AGENTS.md",
|
||||||
|
"Import existing agent files into mission control",
|
||||||
|
],
|
||||||
|
"x-when-not-to-use": [
|
||||||
|
"Prefer dedicated SOUL endpoint for SOUL.md (though this endpoint can handle it)",
|
||||||
|
"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: AgentFileUpdate,
|
||||||
|
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)
|
||||||
|
|
||||||
|
coordination = GatewayCoordinationService(session)
|
||||||
|
await coordination.update_agent_file(
|
||||||
|
board=board,
|
||||||
|
target_agent_id=agent_id,
|
||||||
|
filename=filename,
|
||||||
|
content=payload.content,
|
||||||
|
reason=payload.reason,
|
||||||
|
actor_agent_id=agent_ctx.agent.id,
|
||||||
|
correlation_id=f"file.write:{board.id}:{agent_id}:{filename}",
|
||||||
|
)
|
||||||
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/boards/{board_id}/agents/{agent_id}",
|
"/boards/{board_id}/agents/{agent_id}",
|
||||||
response_model=OkResponse,
|
response_model=OkResponse,
|
||||||
|
|||||||
73
backend/app/schemas/agent_files.py
Normal file
73
backend/app/schemas/agent_files.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""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 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],
|
||||||
|
)
|
||||||
@@ -49,6 +49,16 @@ from app.services.openclaw.shared import GatewayAgentIdentity
|
|||||||
|
|
||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
# Files that can be edited via the agent file management API
|
||||||
|
EDITABLE_AGENT_FILES = {
|
||||||
|
"IDENTITY.md",
|
||||||
|
"SOUL.md",
|
||||||
|
"BOOTSTRAP.md",
|
||||||
|
"AGENTS.md",
|
||||||
|
"TOOLS.md",
|
||||||
|
"HEARTBEAT.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AbstractGatewayMessagingService(OpenClawDBService, ABC):
|
class AbstractGatewayMessagingService(OpenClawDBService, ABC):
|
||||||
"""Shared gateway messaging primitives with retry semantics."""
|
"""Shared gateway messaging primitives with retry semantics."""
|
||||||
@@ -408,6 +418,275 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
|
|||||||
actor_agent_id,
|
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 = []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
if isinstance(files, list):
|
||||||
|
for file in files:
|
||||||
|
if isinstance(file, str):
|
||||||
|
result.append({"name": file, "editable": file in EDITABLE_AGENT_FILES})
|
||||||
|
elif isinstance(file, dict):
|
||||||
|
name = file.get("name", "")
|
||||||
|
if isinstance(name, str):
|
||||||
|
result.append(
|
||||||
|
{"name": name, "editable": name in EDITABLE_AGENT_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
|
||||||
|
db_updated = False
|
||||||
|
if filename == "SOUL.md":
|
||||||
|
target.soul_template = normalized_content
|
||||||
|
db_updated = True
|
||||||
|
elif filename == "IDENTITY.md":
|
||||||
|
target.identity_template = normalized_content
|
||||||
|
db_updated = True
|
||||||
|
|
||||||
|
if db_updated:
|
||||||
|
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(
|
async def ask_user_via_gateway_main(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ class GatewayOperation(str, Enum):
|
|||||||
NUDGE_AGENT = "nudge_agent"
|
NUDGE_AGENT = "nudge_agent"
|
||||||
SOUL_READ = "soul_read"
|
SOUL_READ = "soul_read"
|
||||||
SOUL_WRITE = "soul_write"
|
SOUL_WRITE = "soul_write"
|
||||||
|
FILES_LIST = "files_list"
|
||||||
|
FILE_READ = "file_read"
|
||||||
|
FILE_WRITE = "file_write"
|
||||||
ASK_USER_DISPATCH = "ask_user_dispatch"
|
ASK_USER_DISPATCH = "ask_user_dispatch"
|
||||||
LEAD_MESSAGE_DISPATCH = "lead_message_dispatch"
|
LEAD_MESSAGE_DISPATCH = "lead_message_dispatch"
|
||||||
LEAD_BROADCAST_DISPATCH = "lead_broadcast_dispatch"
|
LEAD_BROADCAST_DISPATCH = "lead_broadcast_dispatch"
|
||||||
@@ -42,6 +45,18 @@ _GATEWAY_ERROR_POLICIES: dict[GatewayOperation, GatewayErrorPolicy] = {
|
|||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail_template="Gateway SOUL update failed: {error}",
|
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(
|
GatewayOperation.ASK_USER_DISPATCH: GatewayErrorPolicy(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail_template="Gateway ask-user dispatch failed: {error}",
|
detail_template="Gateway ask-user dispatch failed: {error}",
|
||||||
|
|||||||
378
frontend/src/app/agents/[agentId]/files/page.tsx
Normal file
378
frontend/src/app/agents/[agentId]/files/page.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/clerk";
|
||||||
|
|
||||||
|
import { ApiError } from "@/api/mutator";
|
||||||
|
import {
|
||||||
|
type getAgentApiV1AgentsAgentIdGetResponse,
|
||||||
|
useGetAgentApiV1AgentsAgentIdGet,
|
||||||
|
} from "@/api/generated/agents/agents";
|
||||||
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||||
|
import type { AgentRead } from "@/api/generated/model";
|
||||||
|
|
||||||
|
type AgentFile = {
|
||||||
|
name: string;
|
||||||
|
editable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AgentFilesPage() {
|
||||||
|
const { isSignedIn } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const agentIdParam = params?.agentId;
|
||||||
|
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
|
||||||
|
|
||||||
|
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<AgentFile[]>([]);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
const [fileContent, setFileContent] = useState("");
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
|
const [importFileName, setImportFileName] = useState("");
|
||||||
|
const [importFileContent, setImportFileContent] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const agentQuery = useGetAgentApiV1AgentsAgentIdGet<
|
||||||
|
getAgentApiV1AgentsAgentIdGetResponse,
|
||||||
|
ApiError
|
||||||
|
>(agentId ?? "", {
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn && isAdmin && agentId),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const agent: AgentRead | null =
|
||||||
|
agentQuery.data?.status === 200 ? agentQuery.data.data : null;
|
||||||
|
|
||||||
|
const loadFiles = async () => {
|
||||||
|
if (!agentId || !agent?.board_id) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/agent/boards/${agent.board_id}/agents/${agentId}/files`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load files: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setFiles(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load files");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileClick = async (fileName: string) => {
|
||||||
|
if (!agentId || !agent?.board_id) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/agent/boards/${agent.board_id}/agents/${agentId}/files/${fileName}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: "text/plain",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load file: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const fileContent = await response.text();
|
||||||
|
setSelectedFile(fileName);
|
||||||
|
setFileContent(fileContent);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load file");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveFile = async () => {
|
||||||
|
if (!agentId || !agent?.board_id || !selectedFile) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/agent/boards/${agent.board_id}/agents/${agentId}/files/${selectedFile}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: fileContent,
|
||||||
|
reason: "Updated via Mission Control UI",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to save file: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setFileContent("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to save file");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportFile = async () => {
|
||||||
|
if (!agentId || !agent?.board_id || !importFileName) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/agent/boards/${agent.board_id}/agents/${agentId}/files/${importFileName}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: importFileContent,
|
||||||
|
reason: "Imported via Mission Control UI",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to import file: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
setImportDialogOpen(false);
|
||||||
|
setImportFileName("");
|
||||||
|
setImportFileContent("");
|
||||||
|
await loadFiles();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to import file");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load files when component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (agent?.board_id) {
|
||||||
|
void loadFiles();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [agent?.board_id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardPageLayout
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to manage agent files.",
|
||||||
|
forceRedirectUrl: `/agents/${agentId}/files`,
|
||||||
|
signUpForceRedirectUrl: `/agents/${agentId}/files`,
|
||||||
|
}}
|
||||||
|
title={agent?.name ? `${agent.name} - Files` : "Agent Files"}
|
||||||
|
description="Manage and edit agent markdown files"
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
adminOnlyMessage="Only organization owners and admins can manage agent files."
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">Agent Files</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
View and edit agent configuration files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button onClick={() => setImportDialogOpen(true)}>
|
||||||
|
Import File
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/agents/${agentId}`)}
|
||||||
|
>
|
||||||
|
Back to Agent
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-red-50 p-3 text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading && files.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-sm text-slate-600">
|
||||||
|
Loading files…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="divide-y divide-slate-200">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="text-sm text-slate-600">No files found</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Agent files will appear here once provisioned
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
files.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.name}
|
||||||
|
className="flex items-center justify-between p-4 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">{file.name}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{file.editable ? "Editable" : "Read-only"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleFileClick(file.name)}
|
||||||
|
>
|
||||||
|
{file.editable ? "Edit" : "View"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit/View Dialog */}
|
||||||
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{selectedFile &&
|
||||||
|
files.find((f) => f.name === selectedFile)?.editable
|
||||||
|
? "Edit"
|
||||||
|
: "View"}{" "}
|
||||||
|
{selectedFile}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectedFile &&
|
||||||
|
files.find((f) => f.name === selectedFile)?.editable
|
||||||
|
? "Make changes to the file content below"
|
||||||
|
: "This file is read-only"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Textarea
|
||||||
|
value={fileContent}
|
||||||
|
onChange={(e) => setFileContent(e.target.value)}
|
||||||
|
className="min-h-[400px] font-mono text-sm"
|
||||||
|
disabled={
|
||||||
|
!selectedFile ||
|
||||||
|
!files.find((f) => f.name === selectedFile)?.editable
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setFileContent("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{selectedFile &&
|
||||||
|
files.find((f) => f.name === selectedFile)?.editable ? (
|
||||||
|
<Button onClick={() => void handleSaveFile()} disabled={loading}>
|
||||||
|
{loading ? "Saving…" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Import Dialog */}
|
||||||
|
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Agent File</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload an existing agent markdown file to Mission Control
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
File name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={importFileName}
|
||||||
|
onChange={(e) => setImportFileName(e.target.value)}
|
||||||
|
placeholder="e.g., IDENTITY.md, SOUL.md, BOOTSTRAP.md"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Use standard OpenClaw file names (IDENTITY.md, SOUL.md, etc.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
File content <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={importFileContent}
|
||||||
|
onChange={(e) => setImportFileContent(e.target.value)}
|
||||||
|
className="min-h-[400px] font-mono text-sm"
|
||||||
|
placeholder="Paste your agent file content here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setImportDialogOpen(false);
|
||||||
|
setImportFileName("");
|
||||||
|
setImportFileContent("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleImportFile()}
|
||||||
|
disabled={loading || !importFileName || !importFileContent}
|
||||||
|
>
|
||||||
|
{loading ? "Importing…" : "Import File"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -196,6 +196,14 @@ export default function AgentDetailPage() {
|
|||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
{agent ? (
|
||||||
|
<Link
|
||||||
|
href={`/agents/${agent.id}/files`}
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-xl border border-[color:var(--border)] px-4 text-sm font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
|
||||||
|
>
|
||||||
|
Files
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
{agent ? (
|
{agent ? (
|
||||||
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
|
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
Reference in New Issue
Block a user