fix lukess findings

This commit is contained in:
gsknnft
2026-04-09 15:42:36 -04:00
parent dadf747fba
commit b4086b506a
9 changed files with 436 additions and 143 deletions
@@ -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 floors 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
+74 -17
View File
@@ -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" } });
+101
View File
@@ -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();
}
}
+45 -12
View File
@@ -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 =
@@ -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();
+166 -108
View File
@@ -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<string, string>
>({});
const [activeFloorId, setActiveFloorId] = useState<FloorId>("lobby");
const [pendingFloorRuntimeSwitch, setPendingFloorRuntimeSwitch] =
useState<PendingFloorRuntimeSwitch | null>(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<string | null>(null);
+3
View File
@@ -19,6 +19,7 @@ type CustomRuntimeProxyInput = {
pathname: string;
method?: "GET" | "POST";
body?: unknown;
signal?: AbortSignal;
};
export async function requestCustomRuntime<T = unknown>({
@@ -26,6 +27,7 @@ export async function requestCustomRuntime<T = unknown>({
pathname,
method = "GET",
body,
signal,
}: CustomRuntimeProxyInput): Promise<T> {
const normalizedRuntimeUrl = normalizeCustomBaseUrl(runtimeUrl);
if (!normalizedRuntimeUrl) {
@@ -38,6 +40,7 @@ export async function requestCustomRuntime<T = unknown>({
Accept: "application/json",
},
cache: "no-store",
signal,
body: JSON.stringify({
runtimeUrl: normalizedRuntimeUrl,
pathname,
+14
View File
@@ -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(
+16 -2
View File
@@ -811,7 +811,14 @@ const normalizeGatewayProfiles = (
): Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> | undefined => {
if (!isRecord(value)) return undefined;
const profiles: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> = {};
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<Record<StudioGatewayAdapterType, StudioGatewayProfile>> = {
...(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) {