diff --git a/docs/office_sys/multi-floor-runtime-architecture.md b/docs/office_sys/multi-floor-runtime-architecture.md index 7fde098..97db5af 100644 --- a/docs/office_sys/multi-floor-runtime-architecture.md +++ b/docs/office_sys/multi-floor-runtime-architecture.md @@ -61,6 +61,7 @@ Additional future departments: - Building systems are shared and runtime-neutral. - Cross-floor coordination is explicit, not accidental. - The gateway/runtime remains the source of truth for runtime-owned data. +- Floor switching owns the connection lifecycle for that floor. ## Why Floors @@ -152,6 +153,14 @@ A floor can be: Multiple floors may be loaded in the same session, but they should not share runtime connection state. +When the user switches to another runtime-backed floor: + +- the shell should keep the building mounted +- the current runtime should disconnect if the target floor uses a different transport +- the next floor should connect using that floor's saved runtime profile +- the floor label should not get ahead of the actual runtime handoff +- reconnect churn should collapse into one transition state instead of flashing through multiple disconnected/connecting states + ## State Ownership ### Runtime-owned @@ -244,6 +253,7 @@ type FloorRuntimeState = { Important rule: - floor-local runtime state should not be overwritten by switching to another floor +- switching floors should not leave the previous runtime active under the next floor's label ## PR Breakdown @@ -495,6 +505,7 @@ Acceptance criteria: - switching floors does not wipe another floor’s runtime state - reconnecting one floor does not reset another floor - Claw3D can show disconnected/configured/connected/errored state per floor +- moving from one runtime floor to another reconnects against the target runtime before the floor is treated as live ### Phase 3: Per-Floor Roster Hydration @@ -525,6 +536,7 @@ Acceptance criteria: - floor switching is UI-stateful, not route-destructive - the shell remains stable while floor scenes swap - disconnected floors remain visible as places, not absent data +- runtime-backed floors enter through a transition/arrival flow, not by silently reusing the previous floor's live runtime ### Phase 5: Cross-Floor Coordination Primitives diff --git a/src/app/api/office/presence/route.ts b/src/app/api/office/presence/route.ts index acd3ccb..21bbdb3 100644 --- a/src/app/api/office/presence/route.ts +++ b/src/app/api/office/presence/route.ts @@ -1,9 +1,15 @@ import { NextResponse } from "next/server"; +import type { + SummaryPreviewSnapshot, + SummaryStatusSnapshot, +} from "@/features/agents/state/runtimeEventBridge"; import { fetchRemoteOfficePresenceSnapshot, loadOfficePresenceSnapshot, } from "@/lib/office/presence"; +import { buildOfficePresenceSnapshotFromGateway } from "@/lib/office/gatewayPresence"; +import { NodeGatewayClient, buildAgentMainSessionKey } from "@/lib/gateway/nodeGatewayClient"; import { loadStudioSettings } from "@/lib/studio/settings-store"; import { resolveOfficePreference } from "@/lib/studio/settings"; @@ -14,13 +20,15 @@ export async function GET(request: Request) { const url = new URL(request.url); const source = url.searchParams.get("source")?.trim() || "local"; const workspaceId = url.searchParams.get("workspaceId")?.trim() || "default"; - if (source === "remote") { + if (source === "remote" || source === "remote_gateway") { const settings = loadStudioSettings(); const gatewayUrl = settings.gateway?.url?.trim() || ""; const officePreference = resolveOfficePreference(settings, gatewayUrl); if ( !officePreference.remoteOfficeEnabled || - !officePreference.remoteOfficePresenceUrl.trim() + (source === "remote" + ? !officePreference.remoteOfficePresenceUrl.trim() + : !officePreference.remoteOfficeGatewayUrl.trim()) ) { return NextResponse.json( { @@ -31,22 +39,71 @@ export async function GET(request: Request) { { headers: { "Cache-Control": "no-store" } } ); } + if (source === "remote") { + const startedAt = Date.now(); + console.info("[office-presence] Fetching remote office presence.", { + presenceUrl: officePreference.remoteOfficePresenceUrl, + tokenConfigured: Boolean(officePreference.remoteOfficeToken?.trim()), + }); + const snapshot = await fetchRemoteOfficePresenceSnapshot({ + presenceUrl: officePreference.remoteOfficePresenceUrl, + token: officePreference.remoteOfficeToken, + timeoutMs: 15_000, + }); + console.info("[office-presence] Remote office presence loaded.", { + presenceUrl: officePreference.remoteOfficePresenceUrl, + elapsedMs: Date.now() - startedAt, + agentCount: snapshot.agents.length, + }); + return NextResponse.json(snapshot, { headers: { "Cache-Control": "no-store" } }); + } + const startedAt = Date.now(); - console.info("[office-presence] Fetching remote office presence.", { - presenceUrl: officePreference.remoteOfficePresenceUrl, - tokenConfigured: Boolean(officePreference.remoteOfficeToken?.trim()), - }); - const snapshot = await fetchRemoteOfficePresenceSnapshot({ - presenceUrl: officePreference.remoteOfficePresenceUrl, - token: officePreference.remoteOfficeToken, - timeoutMs: 15_000, - }); - console.info("[office-presence] Remote office presence loaded.", { - presenceUrl: officePreference.remoteOfficePresenceUrl, - elapsedMs: Date.now() - startedAt, - agentCount: snapshot.agents.length, - }); - return NextResponse.json(snapshot, { headers: { "Cache-Control": "no-store" } }); + const gatewayClient = new NodeGatewayClient(); + try { + await gatewayClient.connect({ + gatewayUrl: officePreference.remoteOfficeGatewayUrl, + token: officePreference.remoteOfficeToken, + }); + const agentsResult = (await gatewayClient.request("agents.list", {})) as { + mainKey?: string; + agents?: Array<{ id?: string; name?: string; identity?: { name?: string } }>; + }; + const statusSummary = (await gatewayClient.request( + "status", + {}, + )) as SummaryStatusSnapshot; + const remoteAgentIds = Array.isArray(agentsResult.agents) + ? agentsResult.agents + .map((agent) => (typeof agent.id === "string" ? agent.id.trim() : "")) + .filter((agentId) => agentId.length > 0) + : []; + const sessionKeys = remoteAgentIds.map((agentId) => + buildAgentMainSessionKey(agentId, agentsResult.mainKey?.trim() || "main"), + ); + const previewSnapshot: SummaryPreviewSnapshot | null = + sessionKeys.length > 0 + ? ((await gatewayClient.request("sessions.preview", { + keys: sessionKeys, + limit: 8, + maxChars: 240, + })) as SummaryPreviewSnapshot) + : null; + const snapshot = buildOfficePresenceSnapshotFromGateway({ + agentsResult, + statusSummary, + previewSnapshot, + workspaceId: "remote-gateway", + }); + console.info("[office-presence] Remote gateway presence loaded.", { + gatewayUrl: officePreference.remoteOfficeGatewayUrl, + elapsedMs: Date.now() - startedAt, + agentCount: snapshot.agents.length, + }); + return NextResponse.json(snapshot, { headers: { "Cache-Control": "no-store" } }); + } finally { + gatewayClient.close(); + } } const snapshot = loadOfficePresenceSnapshot(workspaceId); return NextResponse.json(snapshot, { headers: { "Cache-Control": "no-store" } }); diff --git a/src/app/api/office/remote-handoff/route.ts b/src/app/api/office/remote-handoff/route.ts new file mode 100644 index 0000000..9a5b3a7 --- /dev/null +++ b/src/app/api/office/remote-handoff/route.ts @@ -0,0 +1,101 @@ +import { randomUUID } from "node:crypto"; +import { NextResponse } from "next/server"; +import { NodeGatewayClient } from "@/lib/gateway/nodeGatewayClient"; +import { sendAgentHandoffViaRuntime } from "@/lib/runtime/agentMessaging"; +import { loadStudioSettings } from "@/lib/studio/settings-store"; +import { resolveOfficePreference } from "@/lib/studio/settings"; + +export const runtime = "nodejs"; +const MAX_REMOTE_MESSAGE_CHARS = 2_000; + +const stripRemoteAgentPrefix = (agentId: string) => + agentId.startsWith("remote:") ? agentId.slice("remote:".length) : agentId; + +export async function POST(request: Request) { + const gatewayClient = new NodeGatewayClient(); + try { + const body = (await request.json()) as { + agentId?: unknown; + task?: unknown; + context?: unknown; + deliverables?: unknown; + acceptanceCriteria?: unknown; + }; + const requestedAgentId = + typeof body.agentId === "string" ? stripRemoteAgentPrefix(body.agentId.trim()) : ""; + const task = typeof body.task === "string" ? body.task.trim() : ""; + const context = typeof body.context === "string" ? body.context.trim() : ""; + const acceptanceCriteria = + typeof body.acceptanceCriteria === "string" ? body.acceptanceCriteria.trim() : ""; + const deliverables = Array.isArray(body.deliverables) + ? body.deliverables.filter((entry): entry is string => typeof entry === "string") + : []; + + if (!requestedAgentId) { + return NextResponse.json({ error: "Remote agent ID is required." }, { status: 400 }); + } + if (!task) { + return NextResponse.json({ error: "Remote handoff task is required." }, { status: 400 }); + } + if (task.length > MAX_REMOTE_MESSAGE_CHARS) { + return NextResponse.json( + { error: `Remote handoff must be ${MAX_REMOTE_MESSAGE_CHARS} characters or fewer.` }, + { status: 400 }, + ); + } + + const settings = loadStudioSettings(); + const gatewayUrl = settings.gateway?.url?.trim() || ""; + const officePreference = resolveOfficePreference(settings, gatewayUrl); + if (!officePreference.remoteOfficeEnabled) { + return NextResponse.json({ error: "Remote office is disabled." }, { status: 400 }); + } + if (officePreference.remoteOfficeSourceKind !== "openclaw_gateway") { + return NextResponse.json( + { error: "Remote handoffs currently work only with the remote gateway source." }, + { status: 400 }, + ); + } + const remoteGatewayUrl = officePreference.remoteOfficeGatewayUrl.trim(); + if (!remoteGatewayUrl) { + return NextResponse.json( + { error: "Remote office gateway URL is not configured." }, + { status: 400 }, + ); + } + + await gatewayClient.connect({ + gatewayUrl: remoteGatewayUrl, + token: officePreference.remoteOfficeToken, + }); + + const handoffResult = (await sendAgentHandoffViaRuntime( + { call: gatewayClient.request.bind(gatewayClient) }, + { + targetAgentId: requestedAgentId, + task, + sourceLabel: "another office user", + context: context || undefined, + acceptanceCriteria: acceptanceCriteria || undefined, + deliverables, + idempotencyKey: randomUUID(), + }, + )) as { runId?: string; status?: string }; + + return NextResponse.json({ + ok: true, + agentId: requestedAgentId, + runId: + typeof handoffResult?.runId === "string" && handoffResult.runId.trim() + ? handoffResult.runId.trim() + : null, + status: typeof handoffResult?.status === "string" ? handoffResult.status : null, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to send remote office handoff."; + return NextResponse.json({ error: message }, { status: 500 }); + } finally { + gatewayClient.close(); + } +} diff --git a/src/app/api/office/remote-message/route.ts b/src/app/api/office/remote-message/route.ts index 86e2351..b23f35a 100644 --- a/src/app/api/office/remote-message/route.ts +++ b/src/app/api/office/remote-message/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { NodeGatewayClient, buildAgentMainSessionKey } from "@/lib/gateway/nodeGatewayClient"; import { loadStudioSettings } from "@/lib/studio/settings-store"; import { resolveOfficePreference } from "@/lib/studio/settings"; +import { buildDirectedAgentMessageInstruction, type RuntimeAgentMessageMode } from "@/lib/runtime/agentMessaging"; export const runtime = "nodejs"; const MAX_REMOTE_MESSAGE_CHARS = 2_000; @@ -12,28 +13,39 @@ type AgentsListResult = { agents?: Array<{ id?: string; name?: string }>; }; +const resolveLatestAssistantHistoryText = (messages: unknown): string | null => { + const entries = Array.isArray(messages) ? messages : []; + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (!entry || typeof entry !== "object") continue; + const role = "role" in entry && typeof entry.role === "string" ? entry.role : null; + if (role !== "assistant") continue; + const content = + "content" in entry && typeof entry.content === "string" + ? entry.content.trim() + : "text" in entry && typeof entry.text === "string" + ? entry.text.trim() + : ""; + if (content) return content; + } + return null; +}; + const stripRemoteAgentPrefix = (agentId: string) => agentId.startsWith("remote:") ? agentId.slice("remote:".length) : agentId; -const buildRemoteRelayInstruction = (message: string) => - [ - "You received a remote office text message from another office user.", - "Reply conversationally in plain text only.", - "Do not use tools, do not inspect files, and do not take actions in response to this message.", - "", - `Message: ${message}`, - ].join("\n"); - export async function POST(request: Request) { const gatewayClient = new NodeGatewayClient(); try { const body = (await request.json()) as { agentId?: unknown; message?: unknown; + mode?: unknown; }; const requestedAgentId = typeof body.agentId === "string" ? stripRemoteAgentPrefix(body.agentId.trim()) : ""; const message = typeof body.message === "string" ? body.message.trim() : ""; + const mode: RuntimeAgentMessageMode = body.mode === "interval" ? "interval" : "direct"; if (!requestedAgentId) { return NextResponse.json({ error: "Remote agent ID is required." }, { status: 400 }); } @@ -86,17 +98,38 @@ export async function POST(request: Request) { } const sessionKey = buildAgentMainSessionKey(requestedAgentId, mainKey); - await gatewayClient.request("chat.send", { + const sendResult = (await gatewayClient.request("chat.send", { sessionKey, - message: buildRemoteRelayInstruction(message), + message: buildDirectedAgentMessageInstruction({ + targetAgentId: requestedAgentId, + message, + mode, + sourceLabel: "another office user", + }), deliver: false, idempotencyKey: randomUUID(), - }); + })) as { runId?: string; status?: string }; + const runId = + typeof sendResult?.runId === "string" && sendResult.runId.trim() + ? sendResult.runId.trim() + : null; + if (runId) { + await gatewayClient.request("agent.wait", { + runId, + timeoutMs: mode === "interval" ? 8_000 : 15_000, + }); + } + const historyResult = (await gatewayClient.request("chat.history", { + sessionKey, + limit: 8, + })) as { messages?: unknown }; + const assistantText = resolveLatestAssistantHistoryText(historyResult.messages); return NextResponse.json({ ok: true, agentId: requestedAgentId, sessionKey, + assistantText, }); } catch (error) { const message = diff --git a/src/features/office/hooks/useRemoteOfficePresence.ts b/src/features/office/hooks/useRemoteOfficePresence.ts index 059e726..a8b90ac 100644 --- a/src/features/office/hooks/useRemoteOfficePresence.ts +++ b/src/features/office/hooks/useRemoteOfficePresence.ts @@ -66,8 +66,10 @@ export const useRemoteOfficePresence = ({ ? presenceUrl.trim().length > 0 : normalizedGatewayUrl.length > 0); const requestUrl = useMemo(() => { - if (!active || sourceKind !== "presence_endpoint") return ""; - const searchParams = new URLSearchParams({ source: "remote" }); + if (!active) return ""; + const searchParams = new URLSearchParams({ + source: sourceKind === "presence_endpoint" ? "remote" : "remote_gateway", + }); return `/api/office/presence?${searchParams.toString()}`; }, [active, sourceKind]); @@ -250,8 +252,7 @@ export const useRemoteOfficePresence = ({ } } }; - const loadSnapshot = - sourceKind === "presence_endpoint" ? loadFromPresenceEndpoint : loadFromGateway; + const loadSnapshot = requestUrl ? loadFromPresenceEndpoint : loadFromGateway; void loadSnapshot(); intervalId = window.setInterval(() => { void loadSnapshot(); diff --git a/src/features/office/screens/OfficeScreen.tsx b/src/features/office/screens/OfficeScreen.tsx index f998549..ada7294 100644 --- a/src/features/office/screens/OfficeScreen.tsx +++ b/src/features/office/screens/OfficeScreen.tsx @@ -31,7 +31,9 @@ import { resolveDeskAssignments, resolveOfficePreferencePublic, resolveStudioActiveFloorId, + resolveStudioGatewayProfiles, type StudioGatewayAdapterType, + type StudioGatewaySettings, } from "@/lib/studio/settings"; import { createGatewayAgent, @@ -67,8 +69,6 @@ import { } from "@/features/office/components/RemoteAgentChatPanel"; import { useOfficeFloorRuntimePersistence } from "@/features/office/hooks/useOfficeFloorRuntimePersistence"; import { - buildDirectedAgentMessageInstruction, - sendAgentHandoffViaRuntime, type RuntimeAgentMessageMode, } from "@/lib/runtime/agentMessaging"; import { @@ -324,6 +324,13 @@ type OfficeDeleteMutationBlockState = { sawDisconnect: boolean; }; +type PendingFloorRuntimeSwitch = { + floorId: FloorId; + adapterType: StudioGatewayAdapterType; + gatewayUrl: string; + token: string; +}; + type PhoneCallSpeakPayload = { agentId: string; requestKey: string; @@ -1099,6 +1106,8 @@ export function OfficeScreen({ Record >({}); const [activeFloorId, setActiveFloorId] = useState("lobby"); + const [pendingFloorRuntimeSwitch, setPendingFloorRuntimeSwitch] = + useState(null); const previousGatewayStatusRef = useRef<"disconnected" | "connecting" | "connected">( "disconnected", ); @@ -1208,6 +1217,52 @@ export function OfficeScreen({ status, ]); + useEffect(() => { + if (!pendingFloorRuntimeSwitch) return; + const targetSelectedAdapter = selectedAdapterType === pendingFloorRuntimeSwitch.adapterType; + const targetGatewayUrl = gatewayUrl.trim() === pendingFloorRuntimeSwitch.gatewayUrl; + const targetToken = token === pendingFloorRuntimeSwitch.token; + if (!targetSelectedAdapter || !targetGatewayUrl || !targetToken) { + return; + } + if (status === "connected" || status === "connecting") { + const runtimeMatchesTarget = + activeAdapterType === pendingFloorRuntimeSwitch.adapterType && + gatewayUrl.trim() === pendingFloorRuntimeSwitch.gatewayUrl && + token === pendingFloorRuntimeSwitch.token; + if (runtimeMatchesTarget) { + setPendingFloorRuntimeSwitch(null); + return; + } + disconnect(); + return; + } + void connect() + .catch((error) => { + console.error("Failed to connect floor runtime.", error); + }) + .finally(() => { + setPendingFloorRuntimeSwitch((current) => + current && + current.floorId === pendingFloorRuntimeSwitch.floorId && + current.adapterType === pendingFloorRuntimeSwitch.adapterType && + current.gatewayUrl === pendingFloorRuntimeSwitch.gatewayUrl && + current.token === pendingFloorRuntimeSwitch.token + ? null + : current, + ); + }); + }, [ + activeAdapterType, + connect, + disconnect, + gatewayUrl, + pendingFloorRuntimeSwitch, + selectedAdapterType, + status, + token, + ]); + useEffect(() => { initJukeboxStore(); }, [initJukeboxStore]); @@ -1372,19 +1427,74 @@ export function OfficeScreen({ setOfficeCameraCenterSignal((current) => current + 1); const adapterType = floor.provider as StudioGatewayAdapterType; - setSelectedAdapterType(adapterType); + let nextGatewayUrl = gatewayUrl.trim(); + let nextToken = token; try { - const settings = await settingsCoordinator.loadSettings({ maxAgeMs: 30_000 }); + const envelope = + typeof settingsCoordinator.loadSettingsEnvelope === "function" + ? await settingsCoordinator.loadSettingsEnvelope({ maxAgeMs: 30_000 }) + : { + settings: await settingsCoordinator.loadSettings({ maxAgeMs: 30_000 }), + }; + const settings = envelope.settings ?? null; + const gateway = settings?.gateway ?? null; + const gatewaySettings: StudioGatewaySettings | null = gateway + ? { + url: typeof gateway.url === "string" ? gateway.url.trim() : "", + token: token, + adapterType: + gateway.adapterType === "demo" || + gateway.adapterType === "hermes" || + gateway.adapterType === "openclaw" || + gateway.adapterType === "local" || + gateway.adapterType === "claw3d" || + gateway.adapterType === "custom" + ? gateway.adapterType + : "openclaw", + ...(gateway.profiles + ? { + profiles: Object.fromEntries( + Object.entries(gateway.profiles).flatMap(([profileAdapterType, profile]) => + profile?.url + ? [ + [ + profileAdapterType, + { + url: profile.url.trim(), + token: "", + }, + ], + ] + : [], + ), + ) as StudioGatewaySettings["profiles"], + } + : {}), + } + : null; + const { profiles } = resolveStudioGatewayProfiles({ + gateway: gatewaySettings, + localDefaults: localGatewayDefaults, + }); const floorRuntime = settings?.officeFloors?.[resolved]; - const nextGatewayUrl = floorRuntime?.gatewayUrl?.trim() || ""; - if (nextGatewayUrl) { - setGatewayUrl(nextGatewayUrl); - } + nextGatewayUrl = + floorRuntime?.gatewayUrl?.trim() || profiles[adapterType]?.url?.trim() || nextGatewayUrl; + nextToken = profiles[adapterType]?.token ?? nextToken; } catch (error) { console.error("Failed to resolve floor runtime profile.", error); } + setSelectedAdapterType(adapterType); + setGatewayUrl(nextGatewayUrl); + setToken(nextToken); + setPendingFloorRuntimeSwitch({ + floorId: resolved, + adapterType, + gatewayUrl: nextGatewayUrl, + token: nextToken, + }); + const preferredAgentId = floorRosterCache[resolved]?.selectedAgentId ?? floorRosterCache[resolved]?.entries[0]?.agentId ?? @@ -1399,10 +1509,13 @@ export function OfficeScreen({ [ floorRosterCache, focusLocalAgent, + gatewayUrl, localGatewayDefaults, setGatewayUrl, + setToken, setSelectedAdapterType, settingsCoordinator, + token, ], ); const focusChatTarget = useCallback( @@ -3582,58 +3695,31 @@ export function OfficeScreen({ }, ], })); - const remoteClient = new GatewayClient(); try { - await remoteClient.connect({ - gatewayUrl: remoteOfficeGatewayUrl, - }); - const agentsResult = (await remoteClient.call("agents.list", {})) as { - mainKey?: string; - agents?: Array<{ id?: string; name?: string }>; - }; - const remoteAgents = Array.isArray(agentsResult.agents) - ? agentsResult.agents - : []; - if (remoteAgents.length === 0) { - throw new Error("Remote agent list is unavailable right now."); - } - if (!remoteAgents.some((entry) => (entry.id?.trim() ?? "") === remoteAgentId)) { - throw new Error("Remote agent is no longer available."); - } - const sessionKey = buildAgentMainSessionKey( - remoteAgentId, - agentsResult.mainKey?.trim() || "main", - ); const deliveryMode = (remoteChatByAgentId[agentId]?.mode ?? EMPTY_REMOTE_CHAT_SESSION.mode) === "interval" ? "interval" : "direct"; - const sendResult = (await remoteClient.call("chat.send", { - sessionKey, - message: buildDirectedAgentMessageInstruction({ - targetAgentId: remoteAgentId, + const response = await fetch("/api/office/remote-message", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + agentId: remoteAgentId, message: trimmed, mode: deliveryMode, - sourceLabel: "another office user", }), - deliver: false, - idempotencyKey: randomUUID(), - })) as { runId?: string; status?: string; text?: string; content?: string; message?: string }; - const runId = - typeof sendResult?.runId === "string" && sendResult.runId.trim() - ? sendResult.runId.trim() - : null; - if (runId) { - await remoteClient.call("agent.wait", { - runId, - timeoutMs: deliveryMode === "interval" ? 8_000 : 15_000, - }); + }); + const payload = (await response.json()) as { + error?: string; + assistantText?: string | null; + }; + if (!response.ok) { + throw new Error(payload.error || "Failed to deliver the remote office message."); } - const historyResult = (await remoteClient.call("chat.history", { - sessionKey, - limit: 8, - })) as { messages?: unknown }; - const assistantText = resolveLatestAssistantHistoryText(historyResult?.messages); + const assistantText = + typeof payload.assistantText === "string" ? payload.assistantText.trim() : ""; updateRemoteChatSession(agentId, (session) => ({ ...session, sending: false, @@ -3677,11 +3763,9 @@ export function OfficeScreen({ }, ], })); - } finally { - remoteClient.disconnect(); } }, - [remoteChatByAgentId, remoteOfficeGatewayUrl, updateRemoteChatSession], + [remoteChatByAgentId, updateRemoteChatSession], ); const handleRemoteAgentHandoff = useCallback( @@ -3717,55 +3801,41 @@ export function OfficeScreen({ }, ], })); - const remoteClient = new GatewayClient(); try { - await remoteClient.connect({ - gatewayUrl: remoteOfficeGatewayUrl, - }); const historyContext = ( sessionSnapshot.messages ?? [] ) .slice(-6) .map((entry) => `${entry.role}: ${entry.text}`) .join("\n"); - const agentsResult = (await remoteClient.call("agents.list", {})) as { - mainKey?: string; - agents?: Array<{ id?: string; name?: string }>; - }; - const handoffResult = (await sendAgentHandoffViaRuntime(remoteClient, { - targetAgentId: remoteAgentId, - task: trimmed, - sourceLabel: "another office user", - context: sessionSnapshot.handoffContext.trim() || historyContext || undefined, - deliverables: - sessionSnapshot.handoffDeliverables - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean).length > 0 - ? sessionSnapshot.handoffDeliverables - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean) - : ["Acknowledge ownership", "Send the next checkpoint or blocking question"], - acceptanceCriteria: - sessionSnapshot.handoffAcceptance.trim() || - "Respond with an acknowledgement or the next concrete update.", - idempotencyKey: randomUUID(), - })) as { runId?: string }; - const runId = - typeof handoffResult?.runId === "string" ? handoffResult.runId.trim() : ""; - if (runId) { - await remoteClient.call("agent.wait", { runId, timeoutMs: 15_000 }); + const response = await fetch("/api/office/remote-handoff", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + agentId: remoteAgentId, + task: trimmed, + context: sessionSnapshot.handoffContext.trim() || historyContext || undefined, + deliverables: + sessionSnapshot.handoffDeliverables + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean).length > 0 + ? sessionSnapshot.handoffDeliverables + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + : ["Acknowledge ownership", "Send the next checkpoint or blocking question"], + acceptanceCriteria: + sessionSnapshot.handoffAcceptance.trim() || + "Respond with an acknowledgement or the next concrete update.", + }), + }); + const payload = (await response.json()) as { error?: string }; + if (!response.ok) { + throw new Error(payload.error || "Failed to deliver the remote handoff."); } - const sessionKey = buildAgentMainSessionKey( - remoteAgentId, - agentsResult.mainKey?.trim() || "main", - ); - const historyResult = (await remoteClient.call("chat.history", { - sessionKey, - limit: 8, - })) as { messages?: unknown }; - const assistantText = resolveLatestAssistantHistoryText(historyResult?.messages); updateRemoteChatSession(agentId, (session) => ({ ...session, handoffing: false, @@ -3778,16 +3848,6 @@ export function OfficeScreen({ text: "Handoff delivered to the remote agent.", timestampMs: Date.now(), }, - ...(assistantText - ? [ - { - id: randomUUID(), - role: "assistant" as const, - text: assistantText, - timestampMs: Date.now(), - }, - ] - : []), ], })); } catch (error) { @@ -3807,11 +3867,9 @@ export function OfficeScreen({ }, ], })); - } finally { - remoteClient.disconnect(); } }, - [remoteChatByAgentId, remoteOfficeGatewayUrl, updateRemoteChatSession], + [remoteChatByAgentId, updateRemoteChatSession], ); const lastStandupTriggerKeyRef = useRef(null); diff --git a/src/lib/runtime/custom/http.ts b/src/lib/runtime/custom/http.ts index 13dae30..1969687 100644 --- a/src/lib/runtime/custom/http.ts +++ b/src/lib/runtime/custom/http.ts @@ -19,6 +19,7 @@ type CustomRuntimeProxyInput = { pathname: string; method?: "GET" | "POST"; body?: unknown; + signal?: AbortSignal; }; export async function requestCustomRuntime({ @@ -26,6 +27,7 @@ export async function requestCustomRuntime({ pathname, method = "GET", body, + signal, }: CustomRuntimeProxyInput): Promise { const normalizedRuntimeUrl = normalizeCustomBaseUrl(runtimeUrl); if (!normalizedRuntimeUrl) { @@ -38,6 +40,7 @@ export async function requestCustomRuntime({ Accept: "application/json", }, cache: "no-store", + signal, body: JSON.stringify({ runtimeUrl: normalizedRuntimeUrl, pathname, diff --git a/src/lib/runtime/custom/provider.ts b/src/lib/runtime/custom/provider.ts index 86eb402..ed28d34 100644 --- a/src/lib/runtime/custom/provider.ts +++ b/src/lib/runtime/custom/provider.ts @@ -142,6 +142,13 @@ const resolveAssistantTextFromResponse = (payload: unknown): string | null => { return text || null; }; +const isAbortLikeError = (error: unknown, controller?: AbortController | null): boolean => { + if (controller?.signal.aborted) return true; + return error instanceof DOMException + ? error.name === "AbortError" + : error instanceof Error && error.name === "AbortError"; +}; + const normalizeModelChoices = (registry: CustomRuntimeRegistryResponse | null): string[] => { if (!registry || !isRecord(registry.models)) return []; return Object.keys(registry.models).map((value) => value.trim()).filter(Boolean); @@ -525,6 +532,7 @@ export class CustomRuntimeProvider implements RuntimeProvider { runtimeUrl: this.baseUrl, pathname: "/v1/chat/completions", method: "POST", + signal: controller.signal, body: { model: session.model ?? undefined, stream: false, @@ -555,6 +563,12 @@ export class CustomRuntimeProvider implements RuntimeProvider { text: assistantText, }; } catch (error) { + if (isAbortLikeError(error, controller)) { + return { + status: "aborted", + runId: runId || null, + }; + } const health = await this.fetchHealth().catch(() => null); throw new Error( buildChatFailureMessage( diff --git a/src/lib/studio/settings.ts b/src/lib/studio/settings.ts index d91bdc4..fdff480 100644 --- a/src/lib/studio/settings.ts +++ b/src/lib/studio/settings.ts @@ -811,7 +811,14 @@ const normalizeGatewayProfiles = ( ): Partial> | undefined => { if (!isRecord(value)) return undefined; const profiles: Partial> = {}; - for (const adapterType of ["openclaw", "hermes", "demo", "custom"] as const) { + for (const adapterType of [ + "openclaw", + "hermes", + "demo", + "local", + "claw3d", + "custom", + ] as const) { const normalized = normalizeGatewayProfile(value[adapterType]); if (normalized) { profiles[adapterType] = normalized; @@ -871,7 +878,14 @@ const mergeGatewayProfiles = ( const next: Partial> = { ...(current ?? {}), }; - for (const adapterType of ["openclaw", "hermes", "demo", "custom"] as const) { + for (const adapterType of [ + "openclaw", + "hermes", + "demo", + "local", + "claw3d", + "custom", + ] as const) { const profilePatch = patch[adapterType]; if (profilePatch === undefined) continue; if (profilePatch === null) {