working chat mode

This commit is contained in:
Arnav
2026-04-25 11:02:22 -05:00
parent 11308e4833
commit b2d2b1ca87
10 changed files with 148 additions and 437 deletions
@@ -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
@@ -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
)
+39 -10
View File
@@ -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,
+13
View File
@@ -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()
@@ -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)
return super().model_dump(**kwargs)
+2 -407
View File
@@ -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"
}
}
}
}
+1
View File
@@ -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",
+56 -13
View File
@@ -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<AgentSession>) {
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<AgentSession>;
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;
+1 -1
View File
@@ -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 }));
+15 -1
View File
@@ -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