diff --git a/backend/apps/agents/COMMS_MANAGER/COMMS_MANAGER.py b/backend/apps/agents/COMMS_MANAGER/COMMS_MANAGER.py index cf49b3f9..098b5ad3 100644 --- a/backend/apps/agents/COMMS_MANAGER/COMMS_MANAGER.py +++ b/backend/apps/agents/COMMS_MANAGER/COMMS_MANAGER.py @@ -54,7 +54,11 @@ class CommsManager(BaseModel): if not event.future.done(): event.future.set_result(result) return - await self.broadcaster.send_to_session(session_id, event.event, event.model_dump(mode="json")) + # by_alias=True so nested AgentSnapshot serializes session_id as "id" + # (matching the REST API and the frontend's expected shape). + await self.broadcaster.send_to_session( + session_id, event.event, event.model_dump(mode="json", by_alias=True), + ) return emit @typechecked diff --git a/backend/apps/agents/agent_utils/create_sdk_hooks.py b/backend/apps/agents/agent_utils/create_sdk_hooks.py index dbf60cef..b260f36e 100644 --- a/backend/apps/agents/agent_utils/create_sdk_hooks.py +++ b/backend/apps/agents/agent_utils/create_sdk_hooks.py @@ -11,7 +11,7 @@ import json import time from typing import Any, Callable, Dict, Tuple, Optional, List -from claude_agent_sdk.types import PermissionResultAllow, PermissionResultDeny, PermissionResult +from claude_agent_sdk.types import PermissionResultAllow, PermissionResultDeny, PermissionResult, ToolPermissionContext from backend.core.shared_structs.agent.Message.Message import ToolResultMessage from backend.core.shared_structs.agent.Message.agent_outputs import ToolResultContent @@ -31,7 +31,7 @@ def create_sdk_hooks( tool_start_times: Dict[str, float] = {} @typechecked - async def can_use_tool(tool_name: str, input_data: Any) -> PermissionResult: + async def can_use_tool(tool_name: str, input_data: Any, context: ToolPermissionContext) -> PermissionResult: permission: Optional[TOOL_PERMISSIONS] = ( agent.toolkit.resolve_permission(tool_name) if agent.toolkit else None ) diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 67fbbcaf..efa3ee94 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -37,6 +37,26 @@ from claude_agent_sdk.types import HookMatcher, McpServerConfig from backend.core.tools.shared_structs.Toolkit import Toolkit from backend.apps.agents.agent_utils.build_agent_toolkit import build_agent_toolkit +NINE_ROUTER_MODEL_MAP: dict[str, str] = { + "sonnet": "cc/claude-sonnet-4-6", + "opus": "cc/claude-opus-4-6", + "haiku": "cc/claude-haiku-4-5-20251001", +} + +def _resolve_nine_router_model(model: str, has_api_key: bool) -> str: + """Resolve a model name for the active connection mode. + + When using a direct API key the model passes through unchanged. + When routing through 9Router, short aliases are mapped to their + ``cc/``-prefixed canonical IDs; models that already carry the prefix + are returned as-is to avoid double-prefixing. + """ + if has_api_key: + return model + if model.startswith("cc/"): + return model + return NINE_ROUTER_MODEL_MAP.get(model, f"cc/{model}") + AGENT_STORE: PydanticStore[Agent] = PydanticStore[Agent]( model_cls=Agent, data_dir=os.path.join(DB_ROOT, "sessions"), @@ -133,12 +153,13 @@ async def get_all_sessions(dashboard_id: str = "") -> dict: result: List[Agent] = list(SESSIONS.values()) if dashboard_id: result = [a for a in result if a.dashboard_id == dashboard_id] - return {"sessions": [a.model_dump(mode="json") for a in result]} + return {"sessions": [a.snapshot().model_dump(mode="json") for a in result]} @agents.router.get("/get_session") async def get_session(session_id: str) -> dict: - return get_agent(session_id).model_dump(mode="json") + debug("get_session id=%s", session_id) + return get_agent(session_id).snapshot().model_dump(mode="json") @@ -183,12 +204,7 @@ async def launch_agent( nine_router_port=NINE_ROUTER_PORT if not settings.anthropic_api_key else None, ) - NINE_ROUTER_MODEL_MAP = { - "sonnet": "cc/claude-sonnet-4-6", - "opus": "cc/claude-opus-4-6", - "haiku": "cc/claude-haiku-4-5-20251001", - } - resolved_model = NINE_ROUTER_MODEL_MAP.get(model, f"cc/{model}") if not settings.anthropic_api_key else model + resolved_model = _resolve_nine_router_model(model, bool(settings.anthropic_api_key)) agent.config = ClaudeAgentOptions( env=env, @@ -211,6 +227,7 @@ async def launch_agent( session_id=agent.session_id, status="stopped", session=agent.snapshot(), )) + debug("launch_agent id=%s model=%s mode=%s", agent.session_id, model, mode) return {"session_id": agent.session_id, "session": agent.snapshot().model_dump(mode="json")} @@ -231,6 +248,7 @@ async def update_system_prompt( @agents.router.delete("/delete_session") async def delete_session(session_id: str = Body()) -> dict: + debug("delete_session id=%s", session_id) agent: Optional[Agent] = SESSIONS.pop(session_id, None) if agent is not None: await agent.stop_agent() @@ -283,7 +301,11 @@ async def send_message( toolkit=agent.toolkit, ) agent.config.system_prompt = resolved_mode_config.system_prompt - agent.config.model = agent.model + if model_changed: + settings = load_settings() + agent.config.model = _resolve_nine_router_model( + agent.model, bool(settings.anthropic_api_key), + ) agent.config.allowed_tools = resolved_mode_config.allowed_tools agent.config.disallowed_tools = resolved_mode_config.disallowed_tools if resolved_mode_config.cwd: @@ -299,12 +321,14 @@ async def send_message( forced_tools=forced_tools or [], hidden=hidden, ) + debug("send_message id=%s prompt=%s", session_id, prompt[:80]) await agent.send_message(msg) return {"ok": True} @agents.router.post("/stop_agent") async def stop_agent(session_id: str = Body()) -> dict: + debug("stop_agent id=%s", session_id) agent: Agent = get_agent(session_id) await agent.stop_agent() return {"ok": True} @@ -366,6 +390,7 @@ async def switch_branch( @agents.router.post("/close_session") async def close_session(session_id: str = Body()) -> dict: + debug("close_session id=%s", session_id) agent: Optional[Agent] = SESSIONS.pop(session_id, None) if not agent: raise HTTPException(status_code=404, detail="Session not found") @@ -382,8 +407,9 @@ async def close_session(session_id: str = Body()) -> dict: @agents.router.post("/resume_session") async def resume_session(session_id: str = Body()) -> dict: + debug("resume_session id=%s", session_id) if session_id in SESSIONS: - return {"session": SESSIONS[session_id].model_dump(mode="json")} + return {"session": SESSIONS[session_id].snapshot().model_dump(mode="json")} agent: Optional[Agent] = AGENT_STORE.load_or_none(session_id) if not agent: raise HTTPException(status_code=404, detail="Session not found in history") @@ -439,8 +465,11 @@ async def get_history( q: str = "", limit: int = 20, offset: int = 0, + dashboard_id: str = "", ) -> dict: all_agents: List[Agent] = AGENT_STORE.load_all() + if dashboard_id: + all_agents = [a for a in all_agents if a.dashboard_id == dashboard_id] all_agents.sort( key=lambda a: a.messages.messages[-1].timestamp if a.messages.messages else datetime.min, reverse=True, diff --git a/backend/core/Agent/Agent.py b/backend/core/Agent/Agent.py index 850fe2b6..f05dd424 100644 --- a/backend/core/Agent/Agent.py +++ b/backend/core/Agent/Agent.py @@ -49,11 +49,22 @@ class Agent(BaseModel): @typechecked def snapshot(self) -> AgentSnapshot: + msgs = self.messages.messages + created_at = msgs[0].timestamp.isoformat() if msgs else "" + name = "New chat" + for m in msgs: + if m.role == "user" and isinstance(m.content, str): + name = m.content[:50] + ("..." if len(m.content) > 50 else "") + break + return AgentSnapshot( session_id=self.session_id, model=self.model, mode=self.mode, status=self.status, + name=name, + created_at=created_at, + cost_usd=0, dashboard_id=self.dashboard_id, branch_id=self.branch_id, parent_id=self.parent_id, @@ -133,6 +144,7 @@ class Agent(BaseModel): debug(f"[Agent.send_message] Agent {self.session_id} is already running") return + debug("Agent.send_message id=%s msg_count=%s", self.session_id, len(self.messages)) await self.emit(AgentMessageEvent( session_id=self.session_id, message=msg, @@ -164,6 +176,7 @@ class Agent(BaseModel): @typechecked async def stop_agent(self): + debug("Agent.stop_agent id=%s status=%s", self.session_id, self.status) for child in self.sub_agents: await child.stop_agent() diff --git a/backend/core/shared_structs/agent/AgentSnapshot.py b/backend/core/shared_structs/agent/AgentSnapshot.py index 28927532..fc730b0e 100644 --- a/backend/core/shared_structs/agent/AgentSnapshot.py +++ b/backend/core/shared_structs/agent/AgentSnapshot.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_serializer from typing import Any, Optional, List from backend.core.shared_structs.agent.MessageLog import MessageLog from backend.core.shared_structs.agent.ApprovalRequest import ApprovalRequest @@ -9,6 +9,9 @@ class AgentSnapshot(BaseModel): model: str mode: str status: str + name: str = "New chat" + created_at: str = "" + cost_usd: float = 0 dashboard_id: Optional[str] = None branch_id: str = "main" parent_id: Optional[str] = None @@ -17,6 +20,15 @@ class AgentSnapshot(BaseModel): sub_agents: list = Field(default_factory=list) sub_branches: list = Field(default_factory=list) + # Flatten MessageLog -> list[Message] on the wire so consumers (frontend, + # WS clients) get a plain array instead of {"messages": [...]}. Using a + # field_serializer ensures this works whether AgentSnapshot is dumped + # directly or as a nested field of a parent model (e.g. AgentStatusEvent), + # which a custom model_dump override does not handle. + @field_serializer("messages") + def _serialize_messages(self, messages: MessageLog, _info: Any) -> list[dict[str, Any]]: + return [m.model_dump(mode="json") for m in messages.messages] + def model_dump(self, **kwargs: Any) -> dict[str, Any]: kwargs.setdefault("by_alias", True) - return super().model_dump(**kwargs) \ No newline at end of file + return super().model_dump(**kwargs) diff --git a/electron/package-lock.json b/electron/package-lock.json index ffbdda44..243fb432 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1007,85 +1007,6 @@ "dev": true, "license": "ISC" }, - "node_modules/archiver": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", - "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "archiver-utils": "^2.1.0", - "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^2.2.0", - "zip-stream": "^4.1.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/archiver-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", - "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob": "^7.1.4", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^2.0.0" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/archiver-utils/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/are-we-there-yet": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", @@ -1703,23 +1624,6 @@ "node": ">=0.10.0" } }, - "node_modules/compress-commons": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", - "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-crc32": "^0.2.13", - "crc32-stream": "^4.0.2", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1815,7 +1719,8 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/crc": { "version": "3.8.0", @@ -1828,35 +1733,6 @@ "buffer": "^5.1.0" } }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", - "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2250,61 +2126,6 @@ "node": ">=14.0.0" } }, - "node_modules/electron-builder-squirrel-windows": { - "version": "25.1.8", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", - "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "app-builder-lib": "25.1.8", - "archiver": "^5.3.1", - "builder-util": "25.1.7", - "fs-extra": "^10.1.0" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/electron-builder/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -2749,14 +2570,6 @@ "node": ">= 6" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -3355,14 +3168,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -3490,56 +3295,6 @@ "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "license": "MIT" }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", @@ -3547,36 +3302,12 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.difference": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", - "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", "license": "MIT" }, - "node_modules/lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", @@ -3584,22 +3315,6 @@ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.union": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", - "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -4064,17 +3779,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -4313,14 +4017,6 @@ "node": ">=10.4.0" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -4414,50 +4110,6 @@ "node": ">= 6" } }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4946,24 +4598,6 @@ "node": ">=10" } }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tar/node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -5329,45 +4963,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zip-stream": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", - "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "archiver-utils": "^3.0.4", - "compress-commons": "^4.1.2", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/zip-stream/node_modules/archiver-utils": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", - "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob": "^7.2.3", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } } } } diff --git a/frontend/package.json b/frontend/package.json index 6fdc10a3..f0a1f503 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "ajv": "^8.12.0", "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 741dfc8a..5456cbf6 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -146,7 +146,13 @@ export const launchAgent = createAsyncThunk('agents/launchAgent', async (config: headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), }); + if (!res.ok) { + throw new Error(`Failed to launch agent (${res.status})`); + } const data = await res.json(); + if (!data?.session?.id) { + throw new Error('Launch agent response missing session.id'); + } return data.session as AgentSession; }); @@ -231,7 +237,13 @@ export const fetchSession = createAsyncThunk( 'agents/fetchSession', async (sessionId: string) => { const res = await fetch(`${AGENTS_API}/get_session?session_id=${encodeURIComponent(sessionId)}`); + if (!res.ok) { + throw new Error(`Failed to fetch session (${res.status})`); + } const session = await res.json(); + if (!session?.id) { + throw new Error('Fetch session response missing id'); + } return session as AgentSession; } ); @@ -244,7 +256,13 @@ export const launchAndSendFirstMessage = createAsyncThunk( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), }); + if (!launchRes.ok) { + throw new Error(`Failed to launch agent (${launchRes.status})`); + } const launchData = await launchRes.json(); + if (!launchData?.session?.id) { + throw new Error('Launch response missing session.id'); + } const session = launchData.session as AgentSession; await fetch(`${AGENTS_API}/send_message`, { @@ -254,7 +272,13 @@ export const launchAndSendFirstMessage = createAsyncThunk( }); const refreshRes = await fetch(`${AGENTS_API}/get_session?session_id=${encodeURIComponent(session.id)}`); + if (!refreshRes.ok) { + throw new Error(`Failed to refresh launched session (${refreshRes.status})`); + } const updatedSession = await refreshRes.json() as AgentSession; + if (!updatedSession?.id) { + throw new Error('Refreshed session response missing id'); + } return { draftId, session: updatedSession }; } @@ -353,6 +377,9 @@ export const duplicateSession = createAsyncThunk( }); if (!res.ok) throw new Error('Failed to duplicate session'); const data = await res.json(); + if (!data?.session?.id) { + throw new Error('Duplicate session response missing session.id'); + } return data.session as AgentSession; } ); @@ -412,7 +439,13 @@ export const resumeSession = createAsyncThunk( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId }), }); + if (!res.ok) { + throw new Error(`Failed to resume session (${res.status})`); + } const data = await res.json(); + if (!data?.session?.id) { + throw new Error('Resume session response missing session.id'); + } return data.session as AgentSession; } ); @@ -536,33 +569,39 @@ const agentsSlice = createSlice({ }, updateSession(state, action: PayloadAction) { - if (state.history[action.payload.id]) { - if (action.payload.status === 'running' || action.payload.mode === 'browser-agent') { - delete state.history[action.payload.id]; + const payload = action.payload as Partial; + const sessionId = payload.id; + if (!sessionId) { + return; + } + + if (state.history[sessionId]) { + if (payload.status === 'running' || payload.mode === 'browser-agent') { + delete state.history[sessionId]; } else { return; } } - const existing = state.sessions[action.payload.id]; + const existing = state.sessions[sessionId]; // Don't let a stale "running" message overwrite a terminal status const terminal = ['stopped', 'error'] as const; - if (existing && terminal.includes(existing.status as any) && action.payload.status === 'running') { + if (existing && terminal.includes(existing.status as any) && payload.status === 'running') { return; } // Preserve local pending_approvals if the server payload has none but // the frontend has some (avoids race where backend clears approvals // before the frontend processes the removal). - const mergedApprovals = existing?.pending_approvals?.length && !action.payload.pending_approvals?.length + const mergedApprovals = existing?.pending_approvals?.length && !payload.pending_approvals?.length ? existing.pending_approvals - : action.payload.pending_approvals ?? []; - state.sessions[action.payload.id] = { - ...action.payload, + : payload.pending_approvals ?? []; + state.sessions[sessionId] = { + ...(payload as AgentSession), pending_approvals: mergedApprovals, - streamingMessage: existing?.streamingMessage ?? action.payload.streamingMessage ?? null, - tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta }, + streamingMessage: existing?.streamingMessage ?? payload.streamingMessage ?? null, + tool_group_meta: { ...existing?.tool_group_meta, ...payload.tool_group_meta }, }; - if (action.payload.status === 'running' && !state.trackedNotificationIds.includes(action.payload.id)) { - state.trackedNotificationIds.push(action.payload.id); + if (payload.status === 'running' && !state.trackedNotificationIds.includes(sessionId)) { + state.trackedNotificationIds.push(sessionId); } }, @@ -816,6 +855,7 @@ const agentsSlice = createSlice({ state.loading = false; }) .addCase(launchAgent.fulfilled, (state, action) => { + if (!action.payload?.id) return; state.sessions[action.payload.id] = { ...action.payload, streamingMessage: null, tool_group_meta: action.payload.tool_group_meta ?? {} }; state.activeSessionId = action.payload.id; if (!state.expandedSessionIds.includes(action.payload.id)) { @@ -827,6 +867,7 @@ const agentsSlice = createSlice({ }) .addCase(launchAndSendFirstMessage.fulfilled, (state, action) => { const { draftId, session } = action.payload; + if (!session?.id) return; const shouldExpand = action.meta.arg.expand !== false; delete state.sessions[draftId]; state.sessions[session.id] = { ...session, streamingMessage: null, tool_group_meta: session.tool_group_meta ?? {} }; @@ -902,6 +943,7 @@ const agentsSlice = createSlice({ }) .addCase(duplicateSession.fulfilled, (state, action) => { const session = action.payload; + if (!session?.id) return; state.sessions[session.id] = session; }) .addCase(closeSession.fulfilled, (state, action) => { @@ -969,6 +1011,7 @@ const agentsSlice = createSlice({ }) .addCase(resumeSession.fulfilled, (state, action) => { const session = action.payload; + if (!session?.id) return; state.sessions[session.id] = { ...session, streamingMessage: null, tool_group_meta: session.tool_group_meta ?? {} }; delete state.history[session.id]; state.activeSessionId = session.id; diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index b0a31993..3d982a80 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -162,7 +162,7 @@ class WebSocketManager { switch (event) { case 'agent:status': - if (data.session) { + if (data.session && data.session.id) { store.dispatch(updateSession(data.session)); } else if (session_id) { store.dispatch(updateSessionStatus({ sessionId: session_id, status: data.status })); diff --git a/run/local.sh b/run/local.sh index 4baf465a..f8dd9381 100755 --- a/run/local.sh +++ b/run/local.sh @@ -161,6 +161,19 @@ if (( elapsed >= MAX_WAIT )); then fi # --- Start frontend --- +# Reject port conflicts early: a generic curl to :3000 is not enough — anything +# listening there makes curl succeed, then our new webpack fails with EADDRINUSE. +if HTTP_BODY=$(curl -fsS --connect-timeout 1 "http://127.0.0.1:${FRONTEND_PORT}/" 2>/dev/null); then + if echo "$HTTP_BODY" | grep -qF 'Open Swarm'; then + echo -e "${RED}${BOLD}Port ${FRONTEND_PORT} is already serving this app (likely a leftover dev server).${RESET}" + echo -e "${RED}Stop the old process (e.g. previous run/local.sh) or free the port, then try again.${RESET}" + exit 1 + fi + echo -e "${RED}${BOLD}Port ${FRONTEND_PORT} responds to HTTP but is not this project's dev server.${RESET}" + echo -e "${RED}Free the port or change frontend.dev in ports.config.json.${RESET}" + exit 1 +fi + echo -e "${GREEN}${BOLD}[frontend]${RESET} Starting frontend dev server..." bash "$PROJECT_ROOT/frontend/run.sh" > >( while IFS= read -r line; do @@ -175,7 +188,8 @@ echo -e "${YELLOW}${BOLD}Waiting for frontend (http://localhost:${FRONTEND_PORT} FRONTEND_MAX_WAIT=60 frontend_elapsed=0 while (( frontend_elapsed < FRONTEND_MAX_WAIT )); do - if curl -s -o /dev/null --connect-timeout 1 "http://localhost:${FRONTEND_PORT}/" 2>/dev/null; then + if HTTP_BODY=$(curl -fsS --connect-timeout 1 "http://127.0.0.1:${FRONTEND_PORT}/" 2>/dev/null) \ + && echo "$HTTP_BODY" | grep -qF 'Open Swarm'; then echo -e "${GREEN}${BOLD}Frontend is ready!${RESET}" break fi