feat(platform-agent): openclaw/hermes agent creation UI, device guard, and remote dispatch backend (#15065)

* ♻️ refactor(agent-invocation): add AgentInvocationIntent + unified non-hetero dispatcher (LOBE-8927/8928)

Introduce a shared invocation contract and unified dispatcher for the
non-hetero, non-group agent call paths (callAgent speak mode and @agent
direct mentions). Removes the implicit client-only fallback that existed
in both entry points.

Changes:
- agentDispatcher.ts: add AgentInvocationIntent interface as the unified
  intent type for callSubAgent / callAgent / @agent invocations
- nonHeteroSubAgentDispatcher.ts (new): dispatchNonHeteroSubAgent()
  resolves child runtime via selectRuntimeType and routes to
  executeClientAgent (client) or executeGatewayAgent (gateway);
  throws for hetero (out of scope per LOBE-8926)
- conversationLifecycle.ts #executeDirectMentionRoute: replace hardcoded
  executeClientAgent + TODO fallback with dispatchNonHeteroSubAgent call
- builtin-tool-agent-management executor.ts callAgent speak mode:
  replace hardcoded executeClientAgent + TODO fallback with
  dispatchNonHeteroSubAgent call

Fixes LOBE-8927
Fixes LOBE-8928

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

*  feat(platform-agent): openclaw/hermes agent creation UI, device guard, and remote dispatch backend

- Add CreatePlatformAgent 3-step creation modal (type select → config → bind device)
- Add RemoteAgentConfigCard to agent profile editor for openclaw/hermes config
- Add device guard banner in HeterogeneousChatInput for offline/unavailable devices
- Add useRemoteAgentDeviceGuard hook for real-time device status polling
- Fix backend dispatch: openclaw/hermes now use executeToolCall(runHeteroTask) instead of dispatchAgentRun (lh connect only handles tool_call_request)
- Add agentNotify router for lh notify → DB write + gateway stream event
- Add device.checkCapability endpoint for platform availability probe
- Add notify_update event type to gateway stream and event handler
- Add sendDoneSignal in heteroTask.ts for clean openclaw exit signaling
- Unify non-hetero sub-agent dispatch via dispatchNonHeteroSubAgent (LOBE-8927)
- Route openclaw/hermes to gateway runtime; keep claude-code/codex on hetero/client paths
- Add i18n keys for platform agent UI and device guard banners

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(agentNotify): reuse execAgent placeholder message on first lh notify call

Instead of creating a second empty bubble, the first assistant notify
without a messageId now updates the placeholder assistantMessageId that
execAgent already seeded in runningOperation.assistantMessageId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

*  feat(agentNotify): cancel openclaw/hermes process on interruptTask

- Store deviceId + heteroType in topic.metadata.runningOperation at dispatch time
- interruptTask now dispatches cancelHeteroTask tool call to the bound device
  when topicId reveals a remote hetero operation, sending SIGINT to the process
- Pass topicId from gateway cancel callback to interruptTask
- Add topicId to InterruptTaskSchema and InterruptTaskParams

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ♻️ refactor(hetero-agent): consolidate remote/local type classification into heterogeneous-agents package

- Add RemoteHeterogeneousAgentConfig, REMOTE_HETEROGENEOUS_AGENT_CONFIGS, isRemoteHeterogeneousType, and derived type aliases (HeterogeneousAgentType, LocalHeterogeneousAgentType, RemoteHeterogeneousAgentType) to packages/heterogeneous-agents/src/config.ts
- Extend HETEROGENEOUS_TYPE_LABELS to cover remote platform types (openclaw, hermes) via REMOTE_HETEROGENEOUS_AGENT_CONFIGS
- Replace all inline `=== 'openclaw' || === 'hermes'` checks and local Sets/type aliases across aiAgent service, ProfileEditor, HeterogeneousChatInput, useRemoteAgentDeviceGuard, CreatePlatformAgent, RemoteAgentConfigCard, and deviceProxy with the shared utility
- Show OpenClaw/Hermes display name in assistant message model tag (Usage component) by setting provider=heteroType on placeholder message and using HETEROGENEOUS_TYPE_LABELS for rendering
- Fix ReferenceError: move remoteDeviceId declaration before updateMetadata call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add the platform agents get profiles

* 🐛 fix(platform-agent): routing, security, and i18n issues from review

- Route openclaw/hermes to gateway on desktop (P1): add isRemoteHeterogeneousType
  check in selectRuntimeType before desktop hetero branch — remote agents never
  use local desktop IPC, no special-casing needed
- Fix race in heteroTask: sendAutoNotify → sendDoneSignal now sequential via
  .finally() so error message is written before agent_runtime_end is published
- Security: validate messageId belongs to topicId in agentNotify before
  MessageModel.update to prevent cross-conversation data corruption
- Clear capability/device/profile state on platform change in creation modal (P2)
- Derive PLATFORM_DEFS from REMOTE_HETEROGENEOUS_AGENT_CONFIGS — new platforms
  automatically appear in the modal without code changes
- Use HETEROGENEOUS_TYPE_LABELS for platform names in HeterogeneousChatInput
  and RemoteAgentConfigCard (remove hardcoded PLATFORM_NAMES map)
- i18n: platform card descs, 'online'/'offline' tags, 'Select a device'
  placeholder, checkFailed error — all now use i18n keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ♻️ refactor(platform-agent): derive remote platform enum from config + fix test

- device.ts: replace hardcoded z.enum(['hermes','openclaw']) with a
  zod enum derived from REMOTE_HETEROGENEOUS_AGENT_CONFIGS so new
  platforms are automatically covered without touching this file
- heteroTask.ts / getAgentProfile.ts: use RemoteHeterogeneousAgentType
  instead of literal 'hermes' | 'openclaw' union for the same reason
- gateway.test.ts: update cancel-handler assertion to include topicId
  which was added to the interruptTask call in the previous commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

*  feat(platform-agent): gate creation entry behind labs flag + expand dispatcher tests

- Add enablePlatformAgent lab preference (default false) — the
  "Add Platform Agent" menu item is hidden until the user opts in
  via Settings → Advanced → Labs
- Wire toggle in settings/advanced with labs i18n key (en/zh)
- createPlatformAgentMenuItem returns null when flag is off
- agentDispatcher.test: add remote hetero cases (openclaw/hermes →
  gateway on both web and desktop) to cover the routing fix added earlier

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(lint): merge duplicate import + sort interface props in nonHeteroSubAgentDispatcher

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 💄 feat(platform-agent): disable Hermes option in creation modal (coming soon)

Hermes is not yet ready for production. Mark it as coming-soon in the
platform selection step: grayed-out card, not clickable, "Coming Soon"
tag next to the name.

To enable Hermes when ready: remove 'hermes' from COMING_SOON_PLATFORMS
in CreatePlatformAgent/index.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

*  fix(test): mock CreatePlatformAgentModal in ModalProvider.test

The modal always mounts (open=false) and calls lambdaQuery.useQuery
which requires a tRPC context not present in the test environment.
Mock it out the same way as ChatGroupWizard and EditingPopover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

*  fix(test): mock useUserStore + labPreferSelectors in useCreateMenuItems.test

Adding useUserStore to useCreateMenuItems triggered user store
initialization in tests, which pulled in @lobechat/const and failed
because the existing mock only exports isDesktop. Mock the store and
selectors directly instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(platform-agent): hide divider when platform agent entry is disabled

The divider before 'Add Platform Agent' was unconditional — it showed
even when the labs flag was off. Conditionally include both the divider
and the menu item together so no orphaned separator appears.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LiJian
2026-05-22 14:04:08 +08:00
committed by GitHub
parent 063c0b7a21
commit 6953f188c1
58 changed files with 2222 additions and 130 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.15" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.17" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.15",
"version": "0.0.17",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -0,0 +1,70 @@
import { execFileSync } from 'node:child_process';
import { getHermesPort } from './heteroTask';
export interface CheckPlatformCapabilityParams {
platform: 'hermes' | 'openclaw';
}
export interface CheckPlatformCapabilityResult {
available: boolean;
reason?: string;
version?: string;
}
/**
* Probe whether a specific agent platform is available on this device.
* Dispatched by the server via `device.checkCapability` tRPC procedure.
*
* - openclaw: runs `openclaw --version` and parses the output
* - hermes: hits the gateway health endpoint on the configured port
*/
export async function checkPlatformCapability(
params: CheckPlatformCapabilityParams,
): Promise<CheckPlatformCapabilityResult> {
const { platform } = params;
if (platform === 'openclaw') {
try {
const output = execFileSync('openclaw', ['--version'], {
encoding: 'utf8',
timeout: 5000,
}).trim();
// output is typically "openclaw x.y.z"
const version = output.split(/\s+/).at(-1);
return { available: true, version };
} catch (err) {
return {
available: false,
reason: err instanceof Error ? err.message : 'openclaw not found or failed to run',
};
}
}
if (platform === 'hermes') {
const port = getHermesPort();
try {
const res = await fetch(`http://localhost:${port}/health`, {
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
let version: string | undefined;
try {
const body = (await res.json()) as { version?: string };
version = body.version;
} catch {
/* ignore parse errors */
}
return { available: true, version };
}
return { available: false, reason: `Hermes gateway returned HTTP ${res.status}` };
} catch (err) {
return {
available: false,
reason: err instanceof Error ? err.message : `Hermes gateway not reachable on port ${port}`,
};
}
}
return { available: false, reason: `Unknown platform: ${platform as string}` };
}
+104
View File
@@ -0,0 +1,104 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
export interface GetAgentProfileParams {
/** Agent ID to query (openclaw only). Defaults to the default agent. */
agentId?: string;
platform: RemoteHeterogeneousAgentType;
}
export interface AgentProfileResult {
avatar?: string;
description?: string;
title?: string;
}
// Files to look for a description (tried in order)
const IDENTITY_FILES = ['IDENTITY.md', 'SOUL.md'];
/**
* Try to extract a description from the workspace identity file.
* Looks for Creature / Vibe / Description fields in IDENTITY.md or SOUL.md.
*/
function readDescriptionFromWorkspace(workspacePath: string): string | undefined {
for (const filename of IDENTITY_FILES) {
const filePath = path.join(workspacePath, filename);
if (!fs.existsSync(filePath)) continue;
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
if (!match) continue;
const value = match[1].trim();
// Skip unfilled template placeholders like _(pick something)_ or (TBD)
if (/^[_*(].*[)*_]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(value)) continue;
return value;
}
}
interface OpenClawAgentEntry {
id: string;
identityEmoji?: string;
identityName?: string;
isDefault?: boolean;
workspace?: string;
}
function getOpenClawProfile(agentId?: string): AgentProfileResult {
let output: string;
try {
output = execFileSync('openclaw', ['agents', 'list', '--json'], {
encoding: 'utf8',
timeout: 5000,
});
} catch {
return {};
}
let agents: OpenClawAgentEntry[];
try {
agents = JSON.parse(output) as OpenClawAgentEntry[];
} catch {
return {};
}
const agent = agentId
? agents.find((a) => a.id === agentId)
: (agents.find((a) => a.isDefault) ?? agents[0]);
if (!agent) return {};
const title = agent.identityName || undefined;
const avatar = agent.identityEmoji || '🦞'; // OpenClaw brand mascot as default
// Description is not exposed by the CLI — read from the workspace IDENTITY.md
const description = agent.workspace ? readDescriptionFromWorkspace(agent.workspace) : undefined;
return { avatar, description, title };
}
/**
* Fetch the agent profile (title, avatar, description) from the platform
* installed on this device. Dispatched by the server via `device.getAgentProfile`.
*
* - openclaw: `openclaw agents list --json` for name + emoji, workspace
* IDENTITY.md for description fallback
* - hermes: not yet implemented — returns empty profile
*/
export async function getAgentProfile(params: GetAgentProfileParams): Promise<AgentProfileResult> {
const { platform, agentId } = params;
if (platform === 'openclaw') {
return getOpenClawProfile(agentId);
}
if (platform === 'hermes') {
// Profile fetch not yet implemented for Hermes — return empty
return {};
}
return {};
}
+38 -5
View File
@@ -1,5 +1,7 @@
import { execFileSync, spawn } from 'node:child_process';
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
import { getTrpcClient } from '../api/client';
import { getTask, removeTask, saveTask } from '../daemon/taskRegistry';
import { log } from '../utils/logger';
@@ -35,7 +37,7 @@ function openclawSessionExists(agentId: string, topicId: string): boolean {
export interface RunHeteroTaskParams {
agentId?: string;
agentType: 'hermes' | 'openclaw';
agentType: RemoteHeterogeneousAgentType;
cwd?: string;
operationId: string;
prompt: string;
@@ -48,7 +50,7 @@ export interface CancelHeteroTaskParams {
taskId: string;
}
function getHermesPort(): number {
export function getHermesPort(): number {
const env = process.env.HERMES_GATEWAY_PORT;
if (env) {
const parsed = Number.parseInt(env, 10);
@@ -101,6 +103,28 @@ async function sendAutoNotify(
}
}
/**
* Signal remote hetero task completion to the server so it can publish
* `agent_runtime_end` to the gateway WS and close the frontend subscription.
* Called on clean process exit (code=0, no signal) — error exits go through
* `sendAutoNotify` which writes an error message AND triggers completion via
* the `done` flag.
*/
async function sendDoneSignal(topicId: string, agentId?: string): Promise<void> {
try {
const client = await getTrpcClient();
await client.agentNotify.notify.mutate({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
});
} catch (err) {
log.error('Failed to send done signal:', err instanceof Error ? err.message : String(err));
}
}
/**
* Build the notify protocol injected into the first message of a new hetero-agent session.
* Tells the agent how to push updates back to the LobeHub user via `lh notify`.
@@ -187,15 +211,24 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
});
log.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
// Auto-notify on abnormal exit (signal or non-zero code); normal completion
// is reported by openclaw itself via `lh notify` (injected into the prompt).
// On exit: notify the server so it can close the frontend gateway WS subscription.
// - Abnormal exit (signal or non-zero code): write an error message bubble.
// - Clean exit (code=0, no signal): openclaw already sent its final message via
// `lh notify`; just send a done signal to publish `agent_runtime_end`.
child.on('close', (code, signal) => {
removeTask(taskId);
if (code !== 0 || signal !== null) {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void sendAutoNotify(topicId, taskId, text, agentId);
// Send error message first, THEN signal done (sequential).
// Fire-and-forget both, but ensure done is always sent even if notify fails.
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
);
} else {
// Clean exit — openclaw already sent its final message; just signal done.
void sendDoneSignal(topicId, agentId);
}
});
+4
View File
@@ -1,4 +1,5 @@
import { log } from '../utils/logger';
import { checkPlatformCapability } from './checkPlatformCapability';
import {
editLocalFile,
globLocalFiles,
@@ -8,11 +9,14 @@ import {
searchLocalFiles,
writeLocalFile,
} from './file';
import { getAgentProfile } from './getAgentProfile';
import { cancelHeteroTask, runHeteroTask } from './heteroTask';
import { getCommandOutput, killCommand, runCommand } from './shell';
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
cancelHeteroTask,
checkPlatformCapability,
getAgentProfile,
editFile: editLocalFile,
getCommandOutput,
globFiles: globLocalFiles,
+31
View File
@@ -350,6 +350,37 @@
"pageSelection.reference": "Selected Text",
"pin": "Pin",
"pinOff": "Unpin",
"platformAgent.create.available": "Available",
"platformAgent.create.back": "Back",
"platformAgent.create.checkFailed": "Check failed",
"platformAgent.create.checking": "Checking availability...",
"platformAgent.create.comingSoon": "Coming Soon",
"platformAgent.create.create": "Create Agent",
"platformAgent.create.creating": "Creating...",
"platformAgent.create.desc.hermes": "Run Hermes agents on your local machine",
"platformAgent.create.desc.openclaw": "Run OpenClaw agents on your local machine",
"platformAgent.create.descriptionPlaceholder": "Brief description (optional)",
"platformAgent.create.fetchingProfile": "Fetching profile...",
"platformAgent.create.namePlaceholder": "e.g. My OpenClaw Agent",
"platformAgent.create.next": "Next",
"platformAgent.create.noDevices": "No devices connected",
"platformAgent.create.noDevicesHint": "Run `lh connect` on the target machine first",
"platformAgent.create.notInstalled": "{{name}} not installed on this device",
"platformAgent.create.refresh": "Refresh",
"platformAgent.create.selectDevice": "Select a device",
"platformAgent.create.step1": "Select Platform",
"platformAgent.create.step2": "Select Device",
"platformAgent.create.step3": "Configure Agent",
"platformAgent.create.title": "Add Platform Agent",
"platformAgent.device.online": "Online",
"platformAgent.deviceGuard.configure": "Configure",
"platformAgent.deviceGuard.deviceOffline.desc": "The bound device is not connected. Run `lh connect` on that machine then refresh.",
"platformAgent.deviceGuard.deviceOffline.title": "Device not connected",
"platformAgent.deviceGuard.noDevice.desc": "This agent has no bound device. Edit the agent profile to configure one.",
"platformAgent.deviceGuard.noDevice.title": "No device bound",
"platformAgent.deviceGuard.platformUnavailable.desc": "{{name}} is not installed on the connected device.",
"platformAgent.deviceGuard.platformUnavailable.title": "{{name}} not available",
"platformAgent.deviceGuard.refresh": "Refresh",
"plus.addSkills": "Add Skills...",
"plus.search.appSearch": "Smart Search",
"plus.search.appSearchDesc": "LobeHub optimized search service, delivering best retrieval results.",
+2
View File
@@ -9,5 +9,7 @@
"features.groupChat.title": "Group Chat (Multi-Agent)",
"features.inputMarkdown.desc": "Render Markdown in the input area in real time (bold text, code blocks, tables, etc.).",
"features.inputMarkdown.title": "Input Markdown Rendering",
"features.platformAgent.desc": "Show the \"Add Platform Agent\" entry in the create menu. Platform agents (e.g. OpenClaw, Hermes) run on a connected device and communicate back via lh connect.",
"features.platformAgent.title": "Platform Agent Creation",
"title": "Labs"
}
+14
View File
@@ -479,6 +479,20 @@
"notification.item.image_generation_completed": "Image generation completed",
"notification.item.video_generation_completed": "Video generation completed",
"notification.title": "Notification Channels",
"platformAgentConfig.availability.available": "Available",
"platformAgentConfig.availability.checking": "Checking...",
"platformAgentConfig.availability.label": "Availability",
"platformAgentConfig.availability.noDevice": "No device bound",
"platformAgentConfig.availability.notInstalled": "Not installed",
"platformAgentConfig.changeDevice": "Change Device",
"platformAgentConfig.device.label": "Bound Device",
"platformAgentConfig.device.none": "None",
"platformAgentConfig.device.offline": "Offline",
"platformAgentConfig.device.online": "Online",
"platformAgentConfig.platform.label": "Platform",
"platformAgentConfig.redetect": "Re-detect",
"platformAgentConfig.selectDevice": "Select a device",
"platformAgentConfig.title": "Platform Configuration",
"plugin.addMCPPlugin": "Add MCP",
"plugin.addTooltip": "Custom Skills",
"plugin.clearDeprecated": "Remove Deprecated Skills",
+31
View File
@@ -350,6 +350,37 @@
"pageSelection.reference": "选中文本",
"pin": "置顶",
"pinOff": "取消置顶",
"platformAgent.create.available": "可用",
"platformAgent.create.back": "上一步",
"platformAgent.create.checkFailed": "检测失败",
"platformAgent.create.checking": "正在检测可用性...",
"platformAgent.create.comingSoon": "即将推出",
"platformAgent.create.create": "创建 Agent",
"platformAgent.create.creating": "创建中...",
"platformAgent.create.desc.hermes": "在本地机器上运行 Hermes Agent",
"platformAgent.create.desc.openclaw": "在本地机器上运行 OpenClaw Agent",
"platformAgent.create.descriptionPlaceholder": "简短描述(可选)",
"platformAgent.create.fetchingProfile": "正在读取配置...",
"platformAgent.create.namePlaceholder": "例:我的 OpenClaw Agent",
"platformAgent.create.next": "下一步",
"platformAgent.create.noDevices": "没有已连接的设备",
"platformAgent.create.noDevicesHint": "请先在目标机器上运行 `lh connect`",
"platformAgent.create.notInstalled": "此设备未安装 {{name}}",
"platformAgent.create.refresh": "刷新",
"platformAgent.create.selectDevice": "选择设备",
"platformAgent.create.step1": "选择平台",
"platformAgent.create.step2": "选择设备",
"platformAgent.create.step3": "配置 Agent",
"platformAgent.create.title": "添加平台 Agent",
"platformAgent.device.online": "在线",
"platformAgent.deviceGuard.configure": "去配置",
"platformAgent.deviceGuard.deviceOffline.desc": "绑定的设备未连接,请在目标机器上运行 `lh connect` 后刷新。",
"platformAgent.deviceGuard.deviceOffline.title": "设备未连接",
"platformAgent.deviceGuard.noDevice.desc": "该 Agent 未绑定设备,请在 Agent 配置中进行设置。",
"platformAgent.deviceGuard.noDevice.title": "未绑定设备",
"platformAgent.deviceGuard.platformUnavailable.desc": "连接的设备上未安装 {{name}}。",
"platformAgent.deviceGuard.platformUnavailable.title": "{{name}} 不可用",
"platformAgent.deviceGuard.refresh": "刷新",
"plus.addSkills": "添加技能...",
"plus.search.appSearch": "智能检索",
"plus.search.appSearchDesc": "LobeHub 专属调优的搜索服务,检索效果最佳。",
+2
View File
@@ -9,5 +9,7 @@
"features.groupChat.title": "群聊(多代理)",
"features.inputMarkdown.desc": "在输入区域实时渲染 Markdown(粗体、代码块、表格等)",
"features.inputMarkdown.title": "输入框 Markdown 渲染",
"features.platformAgent.desc": "在创建菜单中显示「添加平台 Agent」入口。平台 Agent(如 OpenClaw、Hermes)运行在已连接的设备上,通过 lh connect 与 LobeHub 通信。",
"features.platformAgent.title": "平台 Agent 创建",
"title": "实验室"
}
+14
View File
@@ -479,6 +479,20 @@
"notification.item.image_generation_completed": "图片生成完成",
"notification.item.video_generation_completed": "视频生成完成",
"notification.title": "通知渠道",
"platformAgentConfig.availability.available": "可用",
"platformAgentConfig.availability.checking": "检测中...",
"platformAgentConfig.availability.label": "可用性",
"platformAgentConfig.availability.noDevice": "未绑定设备",
"platformAgentConfig.availability.notInstalled": "未安装",
"platformAgentConfig.changeDevice": "更换设备",
"platformAgentConfig.device.label": "绑定设备",
"platformAgentConfig.device.none": "无",
"platformAgentConfig.device.offline": "离线",
"platformAgentConfig.device.online": "在线",
"platformAgentConfig.platform.label": "平台",
"platformAgentConfig.redetect": "重新检测",
"platformAgentConfig.selectDevice": "选择设备",
"platformAgentConfig.title": "平台配置",
"plugin.addMCPPlugin": "添加 MCP",
"plugin.addTooltip": "自定义技能",
"plugin.clearDeprecated": "移除无效技能",
@@ -35,6 +35,13 @@ export type AgentStreamEventType =
| 'agent_intervention_response'
| 'step_start'
| 'step_complete'
/**
* Lightweight invalidation signal emitted by `agentNotify.notify` when a
* remote hetero agent (openclaw / hermes) writes a message to DB via
* `lh notify`. The frontend reacts by calling `fetchAndReplaceMessages` —
* no content is carried in the event itself (DB is the source of truth).
*/
| 'notify_update'
| 'error';
export interface AgentStreamEvent {
@@ -18,7 +18,7 @@ import { discoverService } from '@/services/discover';
import { getAgentStoreState, useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { selectRuntimeType } from '@/store/chat/slices/aiChat/actions/agentDispatcher';
import { dispatchNonHeteroSubAgent } from '@/store/chat/slices/aiChat/actions/nonHeteroSubAgentDispatcher';
import { dbMessageSelectors } from '@/store/chat/slices/message/selectors';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
@@ -212,18 +212,17 @@ class AgentManagementExecutor extends BaseExecutor<typeof AgentManagementApiName
}
}
// Register afterCompletion to execute the agent
// Register afterCompletion to execute the agent.
// Runtime routing is fully delegated to dispatchNonHeteroSubAgent (LOBE-8927).
ctx.registerAfterCompletion(async () => {
const get = useChatStore.getState;
// Build conversation context - use current agent's context
const conversationContext: ConversationContext = {
agentId: ctx.agentId || '',
topicId: ctx.topicId || null,
// subAgentId will be set when calling executeClientAgent
};
// Get current messages
// Get current messages for client-mode runner (gateway loads from DB).
const chatKey = messageMapKey(conversationContext);
const messages = dbMessageSelectors.getDbMessagesByKey(chatKey)(get());
@@ -232,9 +231,9 @@ class AgentManagementExecutor extends BaseExecutor<typeof AgentManagementApiName
return;
}
// If instruction is provided, inject it as a virtual User Message
// Same pattern as group orchestration's call_agent executor:
// virtual message with <speaker> tag gives the called agent clear direction
// Inject a virtual instruction message so the sub-agent has clear direction.
// Only used by the client runner; gateway mode sends `instruction` as a real
// user message via dispatchNonHeteroSubAgent.
const now = Date.now();
const messagesWithInstruction = instruction
? [
@@ -249,35 +248,23 @@ class AgentManagementExecutor extends BaseExecutor<typeof AgentManagementApiName
]
: messages;
// callAgent inherits the parent's runtime selection — a hetero/gateway
// parent must keep the called sub-agent on the same path. See LOBE-8519.
const parentAgentConfig = conversationContext.agentId
? agentSelectors.getAgentConfigById(conversationContext.agentId)(getAgentStoreState())
: undefined;
const runtimeType = selectRuntimeType({
heterogeneousProvider: parentAgentConfig?.agencyConfig?.heterogeneousProvider,
isGatewayMode: get().isGatewayModeEnabled(),
});
// TODO(LOBE-8519 follow-up): only client sub-agent dispatch is wired.
// Gateway / hetero callAgent invocations fall through to client and
// will need their own runner once Step 2 lands.
if (runtimeType !== 'client') {
console.warn(
`[callAgent] runtime=${runtimeType} not yet supported for sub-agent dispatch; ` +
'falling through to client mode',
);
}
try {
await get().executeClientAgent({
context: { ...conversationContext, subAgentId: agentId, scope: 'sub_agent' },
messages: messagesWithInstruction,
parentMessageId: ctx.messageId,
parentMessageType: 'tool',
});
await dispatchNonHeteroSubAgent(
{ kind: 'callAgent', targetAgentId: agentId, instruction, parentMessageId: ctx.messageId },
{
conversationContext,
heterogeneousProvider: parentAgentConfig?.agencyConfig?.heterogeneousProvider,
isGatewayMode: get().isGatewayModeEnabled(),
messages: messagesWithInstruction,
},
get(),
);
} catch (error) {
console.error('[callAgent] executeClientAgent failed:', error);
console.error('[callAgent] dispatchNonHeteroSubAgent failed:', error);
throw error;
}
});
+1
View File
@@ -15,6 +15,7 @@ export const DEFAULT_PREFERENCE: UserPreference = {
lab: {
enableAgentSelfIteration: false,
enableInputMarkdown: true,
enablePlatformAgent: false,
},
topicGroupMode: 'byTime',
topicIncludeCompleted: false,
+1 -1
View File
@@ -74,7 +74,7 @@ export class GatewayHttpClient {
}
async dispatchAgentRun(params: {
agentType: 'claude-code' | 'codex';
agentType: 'claude-code' | 'codex' | 'hermes' | 'openclaw';
cwd?: string;
deviceId?: string;
jwt: string;
+1 -1
View File
@@ -90,7 +90,7 @@ export interface SystemInfoResponseMessage {
/** Server → Client: request the desktop to spawn `lh hetero exec`. */
export interface AgentRunRequestMessage {
agentType: 'claude-code' | 'codex';
agentType: 'claude-code' | 'codex' | 'hermes' | 'openclaw';
cwd?: string;
jwt: string;
operationId: string;
@@ -29,7 +29,7 @@ export const HETEROGENEOUS_AGENT_CLIENT_CONFIGS = HETEROGENEOUS_AGENT_CONFIGS.ma
icon: heterogeneousAgentIcons[config.type],
})) as readonly HeterogeneousAgentClientConfig[];
export const getHeterogeneousAgentClientConfig = (type: HeterogeneousAgentConfig['type']) => {
export const getHeterogeneousAgentClientConfig = (type: string) => {
const config = getHeterogeneousAgentConfig(type);
if (!config) return undefined;
@@ -37,6 +37,6 @@ export const getHeterogeneousAgentClientConfig = (type: HeterogeneousAgentConfig
return {
...config,
avatar: createAgentAvatar(config.iconId),
icon: heterogeneousAgentIcons[config.type],
icon: heterogeneousAgentIcons[config.type as keyof typeof heterogeneousAgentIcons],
} satisfies HeterogeneousAgentClientConfig;
};
+40 -4
View File
@@ -1,14 +1,17 @@
import type { HeterogeneousProviderConfig } from '@lobechat/types';
export type HeterogeneousAgentMenuLabelKey = 'newClaudeCodeAgent' | 'newCodexAgent';
/**
* Config for local CLI hetero agents (Claude Code, Codex) that run as
* desktop subprocesses via Electron IPC. Remote device agents (openclaw,
* hermes) have their own setup flow and are not listed here.
*/
export interface HeterogeneousAgentConfig {
command: string;
iconId: string;
menuKey: string;
menuLabelKey: HeterogeneousAgentMenuLabelKey;
title: string;
type: HeterogeneousProviderConfig['type'];
type: 'claude-code' | 'codex';
}
export const HETEROGENEOUS_AGENT_CONFIGS = [
@@ -30,5 +33,38 @@ export const HETEROGENEOUS_AGENT_CONFIGS = [
},
] as const satisfies readonly HeterogeneousAgentConfig[];
export const getHeterogeneousAgentConfig = (type: HeterogeneousProviderConfig['type']) =>
export const getHeterogeneousAgentConfig = (type: string) =>
HETEROGENEOUS_AGENT_CONFIGS.find((config) => config.type === type);
/**
* Config for remote platform hetero agents that communicate back via
* agentNotify.notify. Unlike local CLI agents these are always bound to
* a device via `lh connect` and do not run as desktop subprocesses.
* Add new remote platform types here to automatically propagate display
* names across the UI (model tag, loading indicator, agent list, etc.).
*/
export interface RemoteHeterogeneousAgentConfig {
title: string;
type: 'hermes' | 'openclaw';
}
export const REMOTE_HETEROGENEOUS_AGENT_CONFIGS = [
{ title: 'OpenClaw', type: 'openclaw' },
{ title: 'Hermes', type: 'hermes' },
] as const satisfies readonly RemoteHeterogeneousAgentConfig[];
/** Union of all local CLI hetero types. */
export type LocalHeterogeneousAgentType = (typeof HETEROGENEOUS_AGENT_CONFIGS)[number]['type'];
/** Union of all remote platform hetero types. */
export type RemoteHeterogeneousAgentType =
(typeof REMOTE_HETEROGENEOUS_AGENT_CONFIGS)[number]['type'];
/** Union of every supported hetero agent type. */
export type HeterogeneousAgentType = LocalHeterogeneousAgentType | RemoteHeterogeneousAgentType;
const REMOTE_HETERO_TYPES = new Set<string>(REMOTE_HETEROGENEOUS_AGENT_CONFIGS.map((c) => c.type));
/** Returns true when `type` identifies a remote platform agent (openclaw, hermes, …). */
export const isRemoteHeterogeneousType = (type: string): type is RemoteHeterogeneousAgentType =>
REMOTE_HETERO_TYPES.has(type);
+11 -1
View File
@@ -1,5 +1,15 @@
export { ClaudeCodeAdapter, claudeCodePreset } from './adapters';
export { getHeterogeneousAgentConfig, HETEROGENEOUS_AGENT_CONFIGS } from './config';
export type {
HeterogeneousAgentType,
LocalHeterogeneousAgentType,
RemoteHeterogeneousAgentType,
} from './config';
export {
getHeterogeneousAgentConfig,
HETEROGENEOUS_AGENT_CONFIGS,
isRemoteHeterogeneousType,
REMOTE_HETEROGENEOUS_AGENT_CONFIGS,
} from './config';
export { HETEROGENEOUS_TYPE_LABELS } from './labels';
export { createAdapter, getPreset, listAgentTypes } from './registry';
export type {
+8 -5
View File
@@ -1,12 +1,15 @@
import { HETEROGENEOUS_AGENT_CONFIGS } from './config';
import { HETEROGENEOUS_AGENT_CONFIGS, REMOTE_HETEROGENEOUS_AGENT_CONFIGS } from './config';
/**
* Display-name mapping for heterogeneous agent types.
* Display-name mapping for all heterogeneous agent types (local CLI + remote platform).
*
* Keys mirror the registry keys in `registry.ts` (adapter type). UI layers
* use this to render user-facing names (e.g. "Claude Code is running")
* without knowing adapter-specific branding.
* Add new types to HETEROGENEOUS_AGENT_CONFIGS or REMOTE_HETEROGENEOUS_AGENT_CONFIGS
* in config.ts to automatically include them here.
*/
export const HETEROGENEOUS_TYPE_LABELS: Record<string, string> = Object.fromEntries(
HETEROGENEOUS_AGENT_CONFIGS.map((config) => [config.type, config.title]),
);
export const HETEROGENEOUS_TYPE_LABELS: Record<string, string> = Object.fromEntries([
...HETEROGENEOUS_AGENT_CONFIGS.map((config) => [config.type, config.title]),
...REMOTE_HETEROGENEOUS_AGENT_CONFIGS.map((config) => [config.type, config.title]),
]);
@@ -485,8 +485,7 @@ export const createAnthropicCompatibleRuntime = <T extends Record<string, any> =
baseURL: finalBaseURL,
...constructorOptions,
...rest,
timeout:
rest.timeout ?? constructorOptions?.timeout ?? resolveDefaultAnthropicTimeout(),
timeout: rest.timeout ?? constructorOptions?.timeout ?? resolveDefaultAnthropicTimeout(),
};
if (customClient?.createClient) {
@@ -31,7 +31,7 @@ export const params = {
...(thinking.budget_tokens !== undefined &&
thinking.budget_tokens !== 0 && {
thinking_budget: Math.min(Math.max(thinking.budget_tokens, 100), 16_384),
}),
}),
}),
} as any;
},
+28 -6
View File
@@ -1,15 +1,30 @@
/**
* Heterogeneous agent provider configuration.
* When set, the assistant delegates execution to an external agent CLI
* When set, the assistant delegates execution to an external agent runtime
* instead of using the built-in model runtime.
*
* Two families of hetero agents are supported:
*
* - **Local CLI** (`claude-code` | `codex`): spawned as a child process on the
* desktop; uses `command`, `args`, `env`, `systemContext`.
*
* - **Remote device** (`openclaw` | `hermes`): dispatched to a machine
* connected via `lh connect`; device is identified by `LobeAgentAgencyConfig.boundDeviceId`.
* `platformAgentId` selects the named agent on the remote platform (defaults to `'main'`).
*/
export interface HeterogeneousProviderConfig {
/** Additional CLI arguments for the agent command */
/** Additional CLI arguments for the agent command (local CLI only). */
args?: string[];
/** Command to spawn the agent (e.g. 'claude') */
/** Command to spawn the agent (e.g. 'claude') (local CLI only). */
command?: string;
/** Custom environment variables */
/** Custom environment variables (local CLI only). */
env?: Record<string, string>;
/**
* Platform-side agent identifier used by remote device runtimes.
* - openclaw: selects the named agent (defaults to `'main'`)
* - hermes: reserved for future use
*/
platformAgentId?: string;
/**
* Static context prepended to every user prompt before it reaches the agent CLI.
* Use this to prime the agent with workspace conventions, rules, or instructions
@@ -17,15 +32,22 @@ export interface HeterogeneousProviderConfig {
* Combined with any runtime-generated context (e.g. cloned repo list).
*/
systemContext?: string;
/** Agent runtime type */
type: 'claude-code' | 'codex';
/** Agent runtime type. */
type: 'claude-code' | 'codex' | 'hermes' | 'openclaw';
}
/**
* Agent agency configuration.
* Contains settings for agent execution modes and device binding.
*
* For remote hetero agents (`type: 'openclaw' | 'hermes'`), `boundDeviceId`
* identifies the target `lh connect` device and is required.
*/
export interface LobeAgentAgencyConfig {
/**
* Device ID of the machine connected via `lh connect`.
* Required when `heterogeneousProvider.type` is `'openclaw'` or `'hermes'`.
*/
boundDeviceId?: string;
heterogeneousProvider?: HeterogeneousProviderConfig;
}
+4
View File
@@ -54,6 +54,10 @@ export const UserLabSchema = z.object({
* enable markdown rendering in chat input editor
*/
enableInputMarkdown: z.boolean().optional(),
/**
* show the "Add Platform Agent" entry in the create menu
*/
enablePlatformAgent: z.boolean().optional(),
});
export type UserLab = z.infer<typeof UserLabSchema>;
@@ -1,4 +1,5 @@
import { LOADING_FLAT } from '@lobechat/const';
import { HETEROGENEOUS_TYPE_LABELS } from '@lobechat/heterogeneous-agents';
import { type ModelPerformance, type ModelUsage } from '@lobechat/types';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
@@ -30,7 +31,10 @@ export const AssistantMessageExtra = memo<AssistantMessageExtraProps>(
const isLogin = useUserStore(authSelectors.isLogin);
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
const showUsage = isDevMode && content !== LOADING_FLAT && !!model;
const showUsage =
isDevMode &&
content !== LOADING_FLAT &&
(!!model || !!(provider && HETEROGENEOUS_TYPE_LABELS[provider]));
const showTts = isLogin && !!extra?.tts;
const showTranslate = isLogin && !!extra?.translate;
@@ -1,3 +1,4 @@
import { HETEROGENEOUS_TYPE_LABELS } from '@lobechat/heterogeneous-agents';
import { type ModelPerformance, type ModelUsage } from '@lobechat/types';
import { ModelIcon } from '@lobehub/icons';
import { Center, Flexbox } from '@lobehub/ui';
@@ -32,6 +33,8 @@ const Usage = memo<UsageProps>(({ model, usage, performance, provider }) => {
if (!isDev && onboardingAgentId && conversationAgentId === onboardingAgentId) return null;
const heteroName = provider ? HETEROGENEOUS_TYPE_LABELS[provider] : undefined;
return (
<Flexbox
horizontal
@@ -41,8 +44,14 @@ const Usage = memo<UsageProps>(({ model, usage, performance, provider }) => {
justify={'space-between'}
>
<Center horizontal gap={4} style={{ fontSize: 12 }}>
<ModelIcon model={model as string} type={'mono'} />
{model}
{heteroName ? (
heteroName
) : (
<>
<ModelIcon model={model as string} type={'mono'} />
{model}
</>
)}
</Center>
{!!usage?.totalTokens && (
+491
View File
@@ -0,0 +1,491 @@
'use client';
import {
REMOTE_HETEROGENEOUS_AGENT_CONFIGS,
type RemoteHeterogeneousAgentType,
} from '@lobechat/heterogeneous-agents';
import { Button, Flexbox, Icon } from '@lobehub/ui';
import { Alert, Input, Modal, Select, Steps, Tag } from 'antd';
import { createStyles } from 'antd-style';
import { BotIcon, CheckCircle2, MonitorSmartphone, RefreshCw, XCircle } from 'lucide-react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { lambdaClient, lambdaQuery } from '@/libs/trpc/client';
import { useAgentStore } from '@/store/agent';
import { useHomeStore } from '@/store/home';
const useStyles = createStyles(({ css, token }) => ({
avatarPreview: css`
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: ${token.borderRadiusLG}px;
font-size: 28px;
line-height: 1;
background: ${token.colorFillSecondary};
`,
deviceItem: css`
display: flex;
gap: 8px;
align-items: center;
`,
platformCard: css`
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
padding-block: 12px;
padding-inline: 16px;
border: 1.5px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
background: ${token.colorBgContainer};
transition: border-color 0.2s;
&:hover {
border-color: ${token.colorPrimary};
}
&[data-selected='true'] {
border-color: ${token.colorPrimary};
background: ${token.colorPrimaryBg};
}
&[data-disabled='true'] {
cursor: not-allowed;
opacity: 0.5;
&:hover {
border-color: ${token.colorBorderSecondary};
}
}
`,
platformDesc: css`
font-size: 13px;
color: ${token.colorTextSecondary};
`,
platformName: css`
font-size: 15px;
font-weight: 500;
color: ${token.colorText};
`,
}));
interface AgentProfile {
avatar?: string;
description?: string;
title?: string;
}
interface CreatePlatformAgentModalProps {
groupId?: string;
onClose: () => void;
open: boolean;
}
const CreatePlatformAgentModal = memo<CreatePlatformAgentModalProps>(
({ open, onClose, groupId }) => {
const { t } = useTranslation('chat');
const { styles } = useStyles();
const navigate = useNavigate();
const storeCreateAgent = useAgentStore((s) => s.createAgent);
const refreshAgentList = useHomeStore((s) => s.refreshAgentList);
const [step, setStep] = useState(0);
const [platform, setPlatform] = useState<RemoteHeterogeneousAgentType>('openclaw');
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
const [agentName, setAgentName] = useState('');
const [agentDescription, setAgentDescription] = useState('');
const [agentProfile, setAgentProfile] = useState<AgentProfile | null>(null);
const [fetchingProfile, setFetchingProfile] = useState(false);
const [creating, setCreating] = useState(false);
const [capabilityResult, setCapabilityResult] = useState<
{ available: boolean; reason?: string; version?: string } | undefined
>(undefined);
const [checkingCapability, setCheckingCapability] = useState(false);
// Platforms that are not yet ready for production use.
// Remove a type from this set when the platform is fully supported.
const COMING_SOON_PLATFORMS = new Set<RemoteHeterogeneousAgentType>(['hermes']);
// Derive platform display list from the registry — adding a new platform to
// REMOTE_HETEROGENEOUS_AGENT_CONFIGS automatically includes it here.
const platformDefs = REMOTE_HETEROGENEOUS_AGENT_CONFIGS.map((c) => ({
comingSoon: COMING_SOON_PLATFORMS.has(c.type),
desc: t(`platformAgent.create.desc.${c.type}`),
name: c.title,
type: c.type,
}));
// Fetch device list when the modal opens; expose refetch for the refresh button
const {
data: devices,
isLoading: loadingDevices,
isFetching: fetchingDevices,
refetch: refetchDevices,
} = lambdaQuery.device.listDevices.useQuery(undefined, {
enabled: open,
staleTime: 0, // always re-fetch when explicitly called
});
const selectedPlatformDef = platformDefs.find((p) => p.type === platform)!;
// Reset state when modal opens
useEffect(() => {
if (open) {
setStep(0);
setPlatform('openclaw');
setDeviceId(undefined);
setAgentName('');
setAgentDescription('');
setAgentProfile(null);
setCapabilityResult(undefined);
}
}, [open]);
// Pre-fill name and description from fetched profile when entering step 2
useEffect(() => {
if (step !== 2) return;
if (agentProfile !== null) {
if (!agentName) setAgentName(agentProfile.title ?? selectedPlatformDef.name);
if (!agentDescription) setAgentDescription(agentProfile.description ?? '');
} else if (!fetchingProfile && !agentName) {
// Profile fetch failed or no profile — fall back to platform name
setAgentName(selectedPlatformDef.name);
}
}, [step, agentProfile, fetchingProfile]);
const handlePlatformChange = useCallback((type: RemoteHeterogeneousAgentType) => {
setPlatform(type);
// Reset device + capability state — capability is platform-specific;
// stale results from the previous platform must not carry over.
setDeviceId(undefined);
setCapabilityResult(undefined);
setAgentProfile(null);
}, []);
const checkCapability = useCallback(
async (dId: string) => {
setCheckingCapability(true);
setCapabilityResult(undefined);
try {
const result = await lambdaClient.device.checkCapability.query({
deviceId: dId,
platform,
});
setCapabilityResult(result);
} catch {
setCapabilityResult({ available: false, reason: t('platformAgent.create.checkFailed') });
} finally {
setCheckingCapability(false);
}
},
[platform, t],
);
const fetchProfile = useCallback(
async (dId: string) => {
setFetchingProfile(true);
setAgentProfile(null);
try {
const profile = await lambdaClient.device.getAgentProfile.query({
deviceId: dId,
platform,
});
setAgentProfile(profile);
} catch {
setAgentProfile({});
} finally {
setFetchingProfile(false);
}
},
[platform],
);
const handleDeviceChange = useCallback(
(dId: string) => {
setDeviceId(dId);
void checkCapability(dId);
void fetchProfile(dId);
},
[checkCapability, fetchProfile],
);
const handleNext = useCallback(() => {
setStep((s) => s + 1);
}, []);
const handleBack = useCallback(() => {
setStep((s) => s - 1);
}, []);
const handleCreate = useCallback(async () => {
if (!deviceId) return;
setCreating(true);
try {
const title = agentName.trim() || selectedPlatformDef.name;
const result = await storeCreateAgent({
config: {
agencyConfig: {
boundDeviceId: deviceId,
heterogeneousProvider: {
type: platform,
},
},
avatar: agentProfile?.avatar || undefined,
description: agentDescription.trim() || undefined,
title,
},
groupId,
});
await refreshAgentList();
onClose();
navigate(`/agent/${result.agentId}`);
} finally {
setCreating(false);
}
}, [
deviceId,
agentName,
agentDescription,
agentProfile,
platform,
groupId,
storeCreateAgent,
refreshAgentList,
onClose,
navigate,
selectedPlatformDef.name,
]);
const renderCapabilityStatus = () => {
if (!deviceId) return null;
if (checkingCapability)
return <Tag style={{ marginInlineEnd: 0 }}>{t('platformAgent.create.checking')}</Tag>;
if (!capabilityResult) return null;
if (capabilityResult.available) {
return (
<Flexbox horizontal align="center" gap={4}>
<Icon color="var(--ant-color-success)" icon={CheckCircle2} size={14} />
<Tag color="success" style={{ marginInlineEnd: 0 }}>
{capabilityResult.version ?? t('platformAgent.create.available')}
</Tag>
</Flexbox>
);
}
return (
<Flexbox horizontal align="center" gap={4}>
<Icon color="var(--ant-color-error)" icon={XCircle} size={14} />
<Tag color="error" style={{ marginInlineEnd: 0 }}>
{capabilityResult.reason ??
t('platformAgent.create.notInstalled', { name: selectedPlatformDef.name })}
</Tag>
</Flexbox>
);
};
const step2NextDisabled =
!deviceId || checkingCapability || capabilityResult?.available === false;
const renderStepContent = () => {
if (step === 0) {
return (
<Flexbox gap={12}>
{platformDefs.map((def) => (
<div
className={styles.platformCard}
data-disabled={def.comingSoon}
data-selected={!def.comingSoon && platform === def.type}
key={def.type}
role="button"
tabIndex={def.comingSoon ? -1 : 0}
onClick={() => !def.comingSoon && handlePlatformChange(def.type)}
onKeyDown={(e) => {
if (!def.comingSoon && (e.key === 'Enter' || e.key === ' '))
handlePlatformChange(def.type);
}}
>
<Flexbox horizontal align="center" gap={8}>
<Icon icon={MonitorSmartphone} size={18} />
<span className={styles.platformName}>{def.name}</span>
{def.comingSoon && (
<Tag style={{ marginInlineEnd: 0 }}>{t('platformAgent.create.comingSoon')}</Tag>
)}
</Flexbox>
<span className={styles.platformDesc}>{def.desc}</span>
</div>
))}
</Flexbox>
);
}
if (step === 1) {
const onlineDevices = (devices ?? []).filter((d) => d.online);
const isRefreshing = loadingDevices || fetchingDevices;
const refreshButton = (
<Button
icon={<Icon icon={RefreshCw} size={13} />}
loading={isRefreshing}
size="small"
type="text"
onClick={() => void refetchDevices()}
>
{t('platformAgent.create.refresh')}
</Button>
);
if (!isRefreshing && onlineDevices.length === 0) {
return (
<Flexbox gap={12}>
<Alert
showIcon
description={t('platformAgent.create.noDevicesHint')}
message={t('platformAgent.create.noDevices')}
type="warning"
/>
{refreshButton}
</Flexbox>
);
}
return (
<Flexbox gap={12}>
<Flexbox horizontal align="center" gap={8}>
<Select
loading={isRefreshing}
placeholder={t('platformAgent.create.selectDevice')}
style={{ flex: 1 }}
value={deviceId}
options={onlineDevices.map((d) => ({
label: (
<div className={styles.deviceItem}>
<Icon icon={BotIcon} size={14} />
<span>{d.hostname}</span>
<Tag color="success" style={{ marginInlineEnd: 0 }}>
{t('platformAgent.device.online')}
</Tag>
</div>
),
value: d.deviceId,
}))}
onChange={handleDeviceChange}
/>
{refreshButton}
</Flexbox>
{renderCapabilityStatus()}
</Flexbox>
);
}
if (step === 2) {
const avatar = agentProfile?.avatar;
return (
<Flexbox gap={12}>
{avatar && (
<Flexbox horizontal align="center" gap={12}>
<div className={styles.avatarPreview}>{avatar}</div>
</Flexbox>
)}
<Input
maxLength={60}
value={agentName}
placeholder={
fetchingProfile
? t('platformAgent.create.fetchingProfile')
: t('platformAgent.create.namePlaceholder')
}
onChange={(e) => setAgentName(e.target.value)}
onPressEnter={() => void handleCreate()}
/>
<Input.TextArea
autoSize={{ maxRows: 4, minRows: 2 }}
maxLength={200}
placeholder={t('platformAgent.create.descriptionPlaceholder')}
value={agentDescription}
onChange={(e) => setAgentDescription(e.target.value)}
/>
</Flexbox>
);
}
return null;
};
const renderFooter = () => {
const buttons = [];
if (step > 0) {
buttons.push(
<Button key="back" onClick={handleBack}>
{t('platformAgent.create.back')}
</Button>,
);
}
if (step < 2) {
const nextDisabled = step === 1 && step2NextDisabled;
buttons.push(
<Button disabled={nextDisabled} key="next" type="primary" onClick={handleNext}>
{t('platformAgent.create.next')}
</Button>,
);
}
if (step === 2) {
buttons.push(
<Button
disabled={!agentName.trim() && !selectedPlatformDef.name}
key="create"
loading={creating}
type="primary"
onClick={() => void handleCreate()}
>
{creating ? t('platformAgent.create.creating') : t('platformAgent.create.create')}
</Button>,
);
}
return buttons;
};
return (
<Modal
destroyOnClose
footer={renderFooter()}
open={open}
title={t('platformAgent.create.title')}
width={480}
onCancel={onClose}
>
<Flexbox gap={24} paddingBlock={'16px 8px'}>
<Steps
current={step}
size="small"
items={[
{ title: t('platformAgent.create.step1') },
{ title: t('platformAgent.create.step2') },
{ title: t('platformAgent.create.step3') },
]}
/>
{renderStepContent()}
</Flexbox>
</Modal>
);
},
);
CreatePlatformAgentModal.displayName = 'CreatePlatformAgentModal';
export default CreatePlatformAgentModal;
+87
View File
@@ -0,0 +1,87 @@
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
import { useCallback, useEffect, useState } from 'react';
import { lambdaClient } from '@/libs/trpc/client';
import { useAgentStore } from '@/store/agent';
export type RemoteAgentDeviceStatus =
| 'checking'
| 'device-offline'
| 'no-device'
| 'ok'
| 'platform-unavailable';
interface UseRemoteAgentDeviceGuardOptions {
enabled?: boolean;
}
interface UseRemoteAgentDeviceGuardResult {
refresh: () => void;
status: RemoteAgentDeviceStatus;
}
/**
* Checks whether the bound device is online and the agent platform is available.
* Used in HeterogeneousChatInput to gate sending for openclaw / hermes agents.
*/
export const useRemoteAgentDeviceGuard = ({
enabled = true,
}: UseRemoteAgentDeviceGuardOptions = {}): UseRemoteAgentDeviceGuardResult => {
const agentId = useAgentStore((s) => s.activeAgentId);
const agencyConfig = useAgentStore((s) =>
agentId ? s.agentMap[agentId]?.agencyConfig : undefined,
);
const boundDeviceId = agencyConfig?.boundDeviceId;
const providerType = agencyConfig?.heterogeneousProvider?.type;
const [status, setStatus] = useState<RemoteAgentDeviceStatus>('checking');
const check = useCallback(async () => {
if (!enabled) return;
if (!boundDeviceId) {
setStatus('no-device');
return;
}
setStatus('checking');
try {
const devices = await lambdaClient.device.listDevices.query();
const device = devices.find((d) => d.deviceId === boundDeviceId);
if (!device || !device.online) {
setStatus('device-offline');
return;
}
if (providerType && isRemoteHeterogeneousType(providerType)) {
const capability = await lambdaClient.device.checkCapability.query({
deviceId: boundDeviceId,
platform: providerType,
});
setStatus(capability.available ? 'ok' : 'platform-unavailable');
} else {
setStatus('ok');
}
} catch {
// On error, allow sending — don't block user on network issues
setStatus('ok');
}
}, [enabled, boundDeviceId, providerType]);
useEffect(() => {
void check();
}, [check]);
// Re-check when window regains focus
useEffect(() => {
if (!enabled) return;
const handler = () => void check();
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
}, [enabled, check]);
return { refresh: () => void check(), status };
};
+43
View File
@@ -343,7 +343,50 @@ export default {
'newAgent': 'Create Agent',
'newClaudeCodeAgent': 'Add Claude Code',
'newCodexAgent': 'Add Codex',
'newPlatformAgent': 'Add Platform Agent',
'newGroupChat': 'Create Group',
// Platform agent: per-platform descriptions shown in step 0 of the creation modal
'platformAgent.create.desc.openclaw': 'Run OpenClaw agents on your local machine',
'platformAgent.create.desc.hermes': 'Run Hermes agents on your local machine',
// Platform agent: shared device status label (used in Select option labels)
'platformAgent.device.online': 'Online',
// Platform agent creation modal (openclaw / hermes)
'platformAgent.create.title': 'Add Platform Agent',
'platformAgent.create.step1': 'Select Platform',
'platformAgent.create.step2': 'Select Device',
'platformAgent.create.step3': 'Configure Agent',
'platformAgent.create.next': 'Next',
'platformAgent.create.back': 'Back',
'platformAgent.create.create': 'Create Agent',
'platformAgent.create.creating': 'Creating...',
'platformAgent.create.namePlaceholder': 'e.g. My OpenClaw Agent',
'platformAgent.create.descriptionPlaceholder': 'Brief description (optional)',
'platformAgent.create.fetchingProfile': 'Fetching profile...',
'platformAgent.create.noDevices': 'No devices connected',
'platformAgent.create.noDevicesHint': 'Run `lh connect` on the target machine first',
'platformAgent.create.refresh': 'Refresh',
'platformAgent.create.selectDevice': 'Select a device',
'platformAgent.create.checking': 'Checking availability...',
'platformAgent.create.available': 'Available',
'platformAgent.create.notInstalled': '{{name}} not installed on this device',
'platformAgent.create.checkFailed': 'Check failed',
'platformAgent.create.comingSoon': 'Coming Soon',
// Platform agent device guard banner
'platformAgent.deviceGuard.deviceOffline.title': 'Device not connected',
'platformAgent.deviceGuard.deviceOffline.desc':
'The bound device is not connected. Run `lh connect` on that machine then refresh.',
'platformAgent.deviceGuard.platformUnavailable.title': '{{name}} not available',
'platformAgent.deviceGuard.platformUnavailable.desc':
'{{name}} is not installed on the connected device.',
'platformAgent.deviceGuard.noDevice.title': 'No device bound',
'platformAgent.deviceGuard.noDevice.desc':
'This agent has no bound device. Edit the agent profile to configure one.',
'platformAgent.deviceGuard.refresh': 'Refresh',
'platformAgent.deviceGuard.configure': 'Configure',
'newPage': 'Create Page',
'noAgentsYet': 'This group has no members yet. Click the + button to invite agents.',
'noAvailableAgents': 'No members available to invite',
+3
View File
@@ -13,5 +13,8 @@ export default {
'features.inputMarkdown.desc':
'Render Markdown in the input area in real time (bold text, code blocks, tables, etc.).',
'features.inputMarkdown.title': 'Input Markdown Rendering',
'features.platformAgent.desc':
'Show the "Add Platform Agent" entry in the create menu. Platform agents (e.g. OpenClaw, Hermes) run on a connected device and communicate back via lh connect.',
'features.platformAgent.title': 'Platform Agent Creation',
'title': 'Labs',
};
+16
View File
@@ -241,6 +241,22 @@ export default {
// Heterogeneous agent — Desktop tab
'heterogeneousStatus.desktop.tabLabel': 'Desktop',
// Remote platform agent profile config panel (openclaw / hermes)
'platformAgentConfig.title': 'Platform Configuration',
'platformAgentConfig.platform.label': 'Platform',
'platformAgentConfig.device.label': 'Bound Device',
'platformAgentConfig.device.none': 'None',
'platformAgentConfig.device.online': 'Online',
'platformAgentConfig.device.offline': 'Offline',
'platformAgentConfig.availability.label': 'Availability',
'platformAgentConfig.availability.checking': 'Checking...',
'platformAgentConfig.availability.available': 'Available',
'platformAgentConfig.availability.notInstalled': 'Not installed',
'platformAgentConfig.availability.noDevice': 'No device bound',
'platformAgentConfig.changeDevice': 'Change Device',
'platformAgentConfig.redetect': 'Re-detect',
'platformAgentConfig.selectDevice': 'Select a device',
'checking': 'Checking...',
// Credentials Management
@@ -1,13 +1,22 @@
'use client';
import {
HETEROGENEOUS_TYPE_LABELS,
isRemoteHeterogeneousType,
} from '@lobechat/heterogeneous-agents';
import { Alert, Button, Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import urlJoin from 'url-join';
import { useHeteroAgentCloudConfig } from '@/business/client/hooks/useHeteroAgentCloudConfig';
import { type ActionKeys } from '@/features/ChatInput';
import { ChatInput } from '@/features/Conversation';
import WideScreenContainer from '@/features/WideScreenContainer';
import { useRemoteAgentDeviceGuard } from '@/hooks/useRemoteAgentDeviceGuard';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import WorkingDirectoryBar from './WorkingDirectoryBar';
@@ -31,17 +40,75 @@ const rightActions: ActionKeys[] = [];
const HeterogeneousChatInput = memo(() => {
const { t } = useTranslation('chat');
const { isConfigured, goToConfig } = useHeteroAgentCloudConfig();
const params = useParams<{ aid: string }>();
const navigate = useNavigate();
const providerType = useAgentStore(agentSelectors.currentAgentHeterogeneousProviderType);
const isRemoteAgent = !!providerType && isRemoteHeterogeneousType(providerType);
const { status, refresh } = useRemoteAgentDeviceGuard({ enabled: isRemoteAgent });
const goToAgentProfile = () => {
if (params.aid) navigate(urlJoin('/agent', params.aid, 'profile'));
};
const deviceBlocked =
isRemoteAgent &&
(status === 'device-offline' || status === 'platform-unavailable' || status === 'no-device');
const renderDeviceGuard = () => {
if (!isRemoteAgent || !deviceBlocked) return null;
let title: string;
let desc: string;
if (status === 'no-device') {
title = t('platformAgent.deviceGuard.noDevice.title');
desc = t('platformAgent.deviceGuard.noDevice.desc');
} else if (status === 'device-offline') {
title = t('platformAgent.deviceGuard.deviceOffline.title');
desc = t('platformAgent.deviceGuard.deviceOffline.desc');
} else {
const name = HETEROGENEOUS_TYPE_LABELS[providerType] ?? providerType;
title = t('platformAgent.deviceGuard.platformUnavailable.title', { name });
desc = t('platformAgent.deviceGuard.platformUnavailable.desc', { name });
}
return (
<Flexbox paddingBlock={'0 6px'} paddingInline={12}>
<Alert
title={title}
type={'warning'}
description={
<Flexbox horizontal align={'center'} gap={8} justify={'space-between'}>
<span>{desc}</span>
<Flexbox horizontal gap={6}>
<Button size={'small'} onClick={refresh}>
{t('platformAgent.deviceGuard.refresh')}
</Button>
<Button size={'small'} type={'primary'} onClick={goToAgentProfile}>
{t('platformAgent.deviceGuard.configure')}
</Button>
</Flexbox>
</Flexbox>
}
/>
</Flexbox>
);
};
const inputDisabled = (!isConfigured && !isRemoteAgent) || deviceBlocked;
return (
<Flexbox>
{!isConfigured && (
{!isRemoteAgent && !isConfigured && (
<WideScreenContainer>
<Flexbox paddingBlock={'0 6px'} paddingInline={12}>
<Alert
type={'warning'}
title={t('heteroAgent.cloudNotConfigured.title')}
type={'warning'}
description={
<Flexbox horizontal align={'center'} justify={'space-between'} gap={8}>
<Flexbox horizontal align={'center'} gap={8} justify={'space-between'}>
<span>{t('heteroAgent.cloudNotConfigured.desc')}</span>
<Button size={'small'} type={'primary'} onClick={goToConfig}>
{t('heteroAgent.cloudNotConfigured.action')}
@@ -52,12 +119,13 @@ const HeterogeneousChatInput = memo(() => {
</Flexbox>
</WideScreenContainer>
)}
{renderDeviceGuard()}
<ChatInput
skipScrollMarginWithList
leftActions={leftActions}
rightActions={rightActions}
runtimeConfigSlot={<WorkingDirectoryBar />}
sendButtonProps={{ disabled: !isConfigured, shape: 'round' }}
sendButtonProps={{ disabled: inputDisabled, shape: 'round' }}
onEditorReady={(instance) => {
// Sync to global ChatStore for compatibility with other features
useChatStore.setState({ mainInputEditor: instance });
@@ -250,7 +250,13 @@ const HeterogeneousAgentStatusCard = memo<HeterogeneousAgentStatusCardProps>(
}, [provider.type, resolvedCommand]);
const detect = useCallback(async () => {
if (!isDesktop || !resolvedCommand) {
// openclaw / hermes are remote device agents — no local CLI to detect.
if (
provider.type === 'openclaw' ||
provider.type === 'hermes' ||
!isDesktop ||
!resolvedCommand
) {
setDetecting(false);
return;
}
@@ -0,0 +1,337 @@
'use client';
import {
HETEROGENEOUS_TYPE_LABELS,
type RemoteHeterogeneousAgentType,
} from '@lobechat/heterogeneous-agents';
import type { HeterogeneousProviderConfig } from '@lobechat/types';
import { ActionIcon, Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
import { Button, Modal, Select, Tag } from 'antd';
import { createStyles } from 'antd-style';
import { BotIcon, CheckCircle2, MonitorSmartphone, RefreshCw, XCircle } from 'lucide-react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { lambdaClient, lambdaQuery } from '@/libs/trpc/client';
import { useAgentStore } from '@/store/agent';
const useStyles = createStyles(({ css, token }) => ({
card: css`
padding-block: 16px 4px;
padding-inline: 16px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
background: ${token.colorBgContainer};
`,
cardHeader: css`
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding-block-end: 12px;
`,
title: css`
font-size: 14px;
font-weight: 500;
`,
detailList: css`
border-block-start: 1px solid ${token.colorBorderSecondary};
`,
detailRow: css`
display: flex;
gap: 16px;
align-items: center;
min-height: 44px;
padding-block: 6px;
& + & {
border-block-start: 1px solid ${token.colorBorderSecondary};
}
`,
detailLabel: css`
flex-shrink: 0;
width: 96px;
font-size: 12px;
color: ${token.colorTextTertiary};
text-transform: uppercase;
letter-spacing: 0.04em;
`,
detailContent: css`
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-width: 0;
`,
deviceItem: css`
display: flex;
gap: 6px;
align-items: center;
`,
}));
interface RemoteAgentConfigCardProps {
onBoundDeviceChange?: (deviceId: string) => Promise<void> | void;
provider: HeterogeneousProviderConfig;
}
const RemoteAgentConfigCard = memo<RemoteAgentConfigCardProps>(
({ provider, onBoundDeviceChange }) => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const agentId = useAgentStore((s) => s.activeAgentId);
const boundDeviceId = useAgentStore((s) =>
agentId ? s.agentMap[agentId]?.agencyConfig?.boundDeviceId : undefined,
);
const [changeDeviceOpen, setChangeDeviceOpen] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | undefined>(undefined);
const [capabilityResult, setCapabilityResult] = useState<
{ available: boolean; reason?: string; version?: string } | undefined
>(undefined);
const [checkingCapability, setCheckingCapability] = useState(false);
const [saving, setSaving] = useState(false);
const platformName = HETEROGENEOUS_TYPE_LABELS[provider.type] ?? provider.type;
const { data: devices, isLoading: loadingDevices } = lambdaQuery.device.listDevices.useQuery(
undefined,
{ staleTime: 30_000 },
);
const boundDevice = devices?.find((d) => d.deviceId === boundDeviceId);
const checkCapability = useCallback(
async (deviceId: string) => {
setCheckingCapability(true);
setCapabilityResult(undefined);
try {
const result = await lambdaClient.device.checkCapability.query({
deviceId,
platform: provider.type as RemoteHeterogeneousAgentType,
});
setCapabilityResult(result);
} catch {
setCapabilityResult({ available: false, reason: 'Check failed' });
} finally {
setCheckingCapability(false);
}
},
[provider.type],
);
// Check capability on mount when bound device is online
useEffect(() => {
if (boundDeviceId && boundDevice?.online) {
void checkCapability(boundDeviceId);
}
}, [boundDeviceId, boundDevice?.online, checkCapability]);
const handleOpenChangeDevice = useCallback(() => {
setSelectedDeviceId(boundDeviceId);
setCapabilityResult(undefined);
setChangeDeviceOpen(true);
}, [boundDeviceId]);
const handleDeviceSelect = useCallback(
(dId: string) => {
setSelectedDeviceId(dId);
void checkCapability(dId);
},
[checkCapability],
);
const handleSaveDevice = useCallback(async () => {
if (!selectedDeviceId) return;
setSaving(true);
try {
await onBoundDeviceChange?.(selectedDeviceId);
setChangeDeviceOpen(false);
} finally {
setSaving(false);
}
}, [selectedDeviceId, onBoundDeviceChange]);
const renderAvailability = () => {
if (!boundDeviceId) {
return (
<Tag style={{ marginInlineEnd: 0 }}>{t('platformAgentConfig.availability.noDevice')}</Tag>
);
}
if (!boundDevice?.online) {
return (
<Tag color="warning" style={{ marginInlineEnd: 0 }}>
{t('platformAgentConfig.device.offline')}
</Tag>
);
}
if (checkingCapability) {
return (
<Tag style={{ marginInlineEnd: 0 }}>{t('platformAgentConfig.availability.checking')}</Tag>
);
}
if (!capabilityResult) return null;
if (capabilityResult.available) {
return (
<Flexbox horizontal align="center" gap={4}>
<Icon color="var(--ant-color-success)" icon={CheckCircle2} size={14} />
<Tag color="success" style={{ marginInlineEnd: 0 }}>
{capabilityResult.version ?? t('platformAgentConfig.availability.available')}
</Tag>
</Flexbox>
);
}
return (
<Flexbox horizontal align="center" gap={4}>
<Icon color="var(--ant-color-error)" icon={XCircle} size={14} />
<Tag color="error" style={{ marginInlineEnd: 0 }}>
{t('platformAgentConfig.availability.notInstalled')}
</Tag>
</Flexbox>
);
};
const onlineDevices = (devices ?? []).filter((d) => d.online);
const capabilityOk = capabilityResult?.available === true;
const capabilityBad = capabilityResult?.available === false;
return (
<>
<Flexbox className={styles.card} gap={0}>
<div className={styles.cardHeader}>
<Flexbox horizontal align="center" gap={8}>
<Icon icon={MonitorSmartphone} size={16} />
<Text strong className={styles.title}>
{t('platformAgentConfig.title')}
</Text>
</Flexbox>
<Tooltip title={t('platformAgentConfig.redetect')}>
<ActionIcon
aria-label={t('platformAgentConfig.redetect')}
disabled={!boundDeviceId || checkingCapability}
icon={RefreshCw}
loading={checkingCapability}
size="small"
onClick={() => boundDeviceId && void checkCapability(boundDeviceId)}
/>
</Tooltip>
</div>
<div className={styles.detailList}>
<div className={styles.detailRow}>
<Text className={styles.detailLabel}>{t('platformAgentConfig.platform.label')}</Text>
<div className={styles.detailContent}>
<Tag style={{ marginInlineEnd: 0 }}>{platformName}</Tag>
</div>
</div>
<div className={styles.detailRow}>
<Text className={styles.detailLabel}>{t('platformAgentConfig.device.label')}</Text>
<div className={styles.detailContent}>
{boundDevice ? (
<Flexbox horizontal align="center" gap={6}>
<Text ellipsis style={{ fontSize: 14 }}>
{boundDevice.hostname}
</Text>
<Tag
color={boundDevice.online ? 'success' : 'default'}
style={{ marginInlineEnd: 0 }}
>
{boundDevice.online
? t('platformAgentConfig.device.online')
: t('platformAgentConfig.device.offline')}
</Tag>
</Flexbox>
) : (
<Tag style={{ marginInlineEnd: 0 }}>{t('platformAgentConfig.device.none')}</Tag>
)}
</div>
</div>
<div className={styles.detailRow}>
<Text className={styles.detailLabel}>
{t('platformAgentConfig.availability.label')}
</Text>
<div className={styles.detailContent}>{renderAvailability()}</div>
</div>
<div className={styles.detailRow}>
<div className={styles.detailLabel} />
<div className={styles.detailContent}>
<Button size="small" onClick={handleOpenChangeDevice}>
{t('platformAgentConfig.changeDevice')}
</Button>
</div>
</div>
</div>
</Flexbox>
{/* Change Device Modal */}
<Modal
destroyOnClose
okText={t('platformAgentConfig.changeDevice')}
open={changeDeviceOpen}
title={t('platformAgentConfig.changeDevice')}
width={400}
okButtonProps={{
disabled: !selectedDeviceId || checkingCapability || capabilityBad,
loading: saving,
}}
onCancel={() => setChangeDeviceOpen(false)}
onOk={() => void handleSaveDevice()}
>
<Flexbox gap={12} paddingBlock={'12px 4px'}>
<Select
loading={loadingDevices}
placeholder={t('platformAgentConfig.selectDevice')}
style={{ width: '100%' }}
value={selectedDeviceId}
options={onlineDevices.map((d) => ({
label: (
<div className={styles.deviceItem}>
<Icon icon={BotIcon} size={14} />
<span>{d.hostname}</span>
<Tag color="success" style={{ marginInlineEnd: 0 }}>
{t('platformAgentConfig.device.online')}
</Tag>
</div>
),
value: d.deviceId,
}))}
onChange={handleDeviceSelect}
/>
{checkingCapability && (
<Tag style={{ marginInlineEnd: 0 }}>
{t('platformAgentConfig.availability.checking')}
</Tag>
)}
{capabilityOk && (
<Flexbox horizontal align="center" gap={4}>
<Icon color="var(--ant-color-success)" icon={CheckCircle2} size={14} />
<Tag color="success" style={{ marginInlineEnd: 0 }}>
{capabilityResult?.version ?? t('platformAgentConfig.availability.available')}
</Tag>
</Flexbox>
)}
{capabilityBad && (
<Flexbox horizontal align="center" gap={4}>
<Icon color="var(--ant-color-error)" icon={XCircle} size={14} />
<Tag color="error" style={{ marginInlineEnd: 0 }}>
{t('platformAgentConfig.availability.notInstalled')}
</Tag>
</Flexbox>
)}
</Flexbox>
</Modal>
</>
);
},
);
RemoteAgentConfigCard.displayName = 'RemoteAgentConfigCard';
export default RemoteAgentConfigCard;
@@ -1,6 +1,7 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
import { Flexbox } from '@lobehub/ui';
import { Divider, Tabs } from 'antd';
import isEqual from 'fast-deep-equal';
@@ -17,6 +18,7 @@ import AgentHeader from './AgentHeader';
import AgentTool from './AgentTool';
import CloudHeterogeneousConfig from './CloudHeterogeneousConfig';
import HeterogeneousAgentStatusCard from './HeterogeneousAgentStatusCard';
import RemoteAgentConfigCard from './RemoteAgentConfigCard';
const ProfileEditor = memo(() => {
const { t } = useTranslation('setting');
@@ -43,6 +45,15 @@ const ProfileEditor = memo(() => {
});
};
const updateBoundDeviceId = async (boundDeviceId: string) => {
await updateConfig({ agencyConfig: { ...config.agencyConfig, boundDeviceId } });
};
const isRemoteHetero =
isHeterogeneous &&
!!heterogeneousProvider &&
isRemoteHeterogeneousType(heterogeneousProvider.type);
return (
<>
<Flexbox
@@ -53,8 +64,16 @@ const ProfileEditor = memo(() => {
>
{/* Header: Avatar + Name + Description */}
<AgentHeader />
{isHeterogeneous && heterogeneousProvider ? (
// Heterogeneous integration mode: tabs for cloud (web) and desktop environments
{isRemoteHetero && heterogeneousProvider ? (
// Remote platform agents (openclaw / hermes): show device config panel
<Flexbox paddingBlock={'8px 0'}>
<RemoteAgentConfigCard
provider={heterogeneousProvider}
onBoundDeviceChange={updateBoundDeviceId}
/>
</Flexbox>
) : isHeterogeneous && heterogeneousProvider ? (
// Local CLI agents (claude-code, codex): tabs for cloud (web) and desktop environments
<Tabs
defaultActiveKey={isDesktop ? 'desktop' : 'cloud'}
size="small"
@@ -48,6 +48,7 @@ const CreateAgentButton = memo<CreateAgentButtonProps>(({ groupId, className })
createAgentMenuItem,
createGroupChatMenuItem,
createHeterogeneousAgentMenuItems,
createPlatformAgentMenuItem,
isMutatingAgent,
openCreateModal,
} = useCreateMenuItems();
@@ -60,15 +61,18 @@ const CreateAgentButton = memo<CreateAgentButtonProps>(({ groupId, className })
const dropdownItems = useMemo(() => {
const heteroItems = createHeterogeneousAgentMenuItems(menuOptions);
const platformItem = createPlatformAgentMenuItem(menuOptions);
return [
createAgentMenuItem(menuOptions),
createGroupChatMenuItem(menuOptions),
...(heteroItems.length > 0 ? [{ type: 'divider' as const }, ...heteroItems] : []),
...(platformItem ? [{ type: 'divider' as const }, platformItem] : []),
];
}, [
createAgentMenuItem,
createGroupChatMenuItem,
createHeterogeneousAgentMenuItems,
createPlatformAgentMenuItem,
menuOptions,
]);
@@ -24,6 +24,10 @@ vi.mock('@/components/MemberSelectionModal', () => ({
MemberSelectionModal: () => null,
}));
vi.mock('@/features/CreatePlatformAgent', () => ({
default: () => null,
}));
vi.mock('@/features/EditingPopover', () => ({
default: () => null,
}));
@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { ChatGroupWizard } from '@/components/ChatGroupWizard';
import { MemberSelectionModal } from '@/components/MemberSelectionModal';
import CreatePlatformAgentModal from '@/features/CreatePlatformAgent';
import EditingPopover from '@/features/EditingPopover';
import { CreateAgentModal } from '@/routes/(main)/home/_layout/hooks/useCreateModal';
import { useAgentStore } from '@/store/agent';
@@ -24,11 +25,13 @@ interface AgentModalContextValue {
closeAllModals: () => void;
closeConfigGroupModal: () => void;
closeCreateGroupModal: () => void;
closeCreatePlatformAgentModal: () => void;
closeGroupWizardModal: () => void;
closeMemberSelectionModal: () => void;
openConfigGroupModal: () => void;
openCreateGroupModal: (sessionId: string) => void;
openCreateModal: (type: 'agent' | 'group', options?: OpenCreateModalOptions) => void;
openCreatePlatformAgentModal: (options?: OpenCreateModalOptions) => void;
openGroupWizardModal: (callbacks: GroupWizardCallbacks) => void;
openMemberSelectionModal: (callbacks: MemberSelectionCallbacks) => void;
setGroupWizardLoading: (loading: boolean) => void;
@@ -135,6 +138,12 @@ export const AgentModalProvider = memo<AgentModalProviderProps>(({ children }) =
const [createModalType, setCreateModalType] = useState<'agent' | 'group'>('agent');
const [createModalGroupId, setCreateModalGroupId] = useState<string | undefined>(undefined);
// CreatePlatformAgentModal state
const [createPlatformAgentOpen, setCreatePlatformAgentOpen] = useState(false);
const [createPlatformAgentGroupId, setCreatePlatformAgentGroupId] = useState<string | undefined>(
undefined,
);
const contextValue = useMemo<AgentModalContextValue>(
() => ({
closeAllModals: () => {
@@ -143,9 +152,11 @@ export const AgentModalProvider = memo<AgentModalProviderProps>(({ children }) =
setGroupWizardOpen(false);
setMemberSelectionOpen(false);
setCreateModalOpen(false);
setCreatePlatformAgentOpen(false);
},
closeConfigGroupModal: () => setConfigGroupModalOpen(false),
closeCreateGroupModal: () => setCreateGroupModalOpen(false),
closeCreatePlatformAgentModal: () => setCreatePlatformAgentOpen(false),
closeGroupWizardModal: () => setGroupWizardOpen(false),
closeMemberSelectionModal: () => setMemberSelectionOpen(false),
openConfigGroupModal: () => setConfigGroupModalOpen(true),
@@ -158,6 +169,10 @@ export const AgentModalProvider = memo<AgentModalProviderProps>(({ children }) =
setCreateModalGroupId(options?.groupId);
setCreateModalOpen(true);
},
openCreatePlatformAgentModal: (options?: OpenCreateModalOptions) => {
setCreatePlatformAgentGroupId(options?.groupId);
setCreatePlatformAgentOpen(true);
},
openGroupWizardModal: (callbacks: GroupWizardCallbacks) => {
setGroupWizardCallbacks(callbacks);
setGroupWizardOpen(true);
@@ -179,6 +194,11 @@ export const AgentModalProvider = memo<AgentModalProviderProps>(({ children }) =
type={createModalType}
onClose={() => setCreateModalOpen(false)}
/>
<CreatePlatformAgentModal
groupId={createPlatformAgentGroupId}
open={createPlatformAgentOpen}
onClose={() => setCreatePlatformAgentOpen(false)}
/>
{children}
{/* All modals rendered at top level */}
@@ -29,11 +29,13 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
createAgentMenuItem,
createGroupChatMenuItem,
createHeterogeneousAgentMenuItems,
createPlatformAgentMenuItem,
isLoading,
} = useCreateMenuItems();
const addMenuItems = useMemo(() => {
const heterogeneousItems = createHeterogeneousAgentMenuItems();
const platformItem = createPlatformAgentMenuItem();
return [
createAgentMenuItem(),
@@ -41,8 +43,14 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
...(heterogeneousItems.length > 0
? [{ type: 'divider' as const }, ...heterogeneousItems]
: []),
...(platformItem ? [{ type: 'divider' as const }, platformItem] : []),
];
}, [createAgentMenuItem, createGroupChatMenuItem, createHeterogeneousAgentMenuItems]);
}, [
createAgentMenuItem,
createGroupChatMenuItem,
createHeterogeneousAgentMenuItems,
createPlatformAgentMenuItem,
]);
const handleOpenConfigGroupModal = useCallback(() => {
openConfigGroupModal();
@@ -18,6 +18,7 @@ const AddButton = memo(() => {
createGroupChatMenuItem,
createHeterogeneousAgentMenuItems,
createPageMenuItem,
createPlatformAgentMenuItem,
openCreateModal,
isMutatingAgent,
isCreatingGroup,
@@ -34,6 +35,7 @@ const AddButton = memo(() => {
const dropdownItems = useMemo(() => {
const heterogeneousItems = createHeterogeneousAgentMenuItems();
const platformItem = createPlatformAgentMenuItem();
return [
createAgentMenuItem(),
@@ -42,12 +44,14 @@ const AddButton = memo(() => {
...(heterogeneousItems.length > 0
? [{ type: 'divider' as const }, ...heterogeneousItems]
: []),
...(platformItem ? [{ type: 'divider' as const }, platformItem] : []),
];
}, [
createAgentMenuItem,
createGroupChatMenuItem,
createHeterogeneousAgentMenuItems,
createPageMenuItem,
createPlatformAgentMenuItem,
]);
return (
@@ -124,6 +124,17 @@ vi.mock('@/store/page', () => ({
}),
}));
vi.mock('@/store/user', () => ({
useUserStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ preference: { lab: {} } }),
}));
vi.mock('@/store/user/selectors', () => ({
labPreferSelectors: {
enablePlatformAgent: () => false,
},
}));
const isActionItem = (
item: unknown,
): item is {
@@ -4,7 +4,7 @@ import { Icon } from '@lobehub/ui';
import { GroupBotSquareIcon } from '@lobehub/ui/icons';
import { App } from 'antd';
import type { ItemType } from 'antd/es/menu/interface';
import { BotIcon, FileTextIcon, FolderCogIcon, FolderPlus } from 'lucide-react';
import { BotIcon, FileTextIcon, FolderCogIcon, FolderPlus, MonitorSmartphone } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -21,6 +21,8 @@ import { useAgentStore } from '@/store/agent';
import { useAgentGroupStore } from '@/store/agentGroup';
import { useHomeStore } from '@/store/home';
import { usePageStore } from '@/store/page';
import { useUserStore } from '@/store/user';
import { labPreferSelectors } from '@/store/user/selectors';
interface CreateAgentOptions {
groupId?: string;
@@ -198,6 +200,7 @@ export const useCreateMenuItems = () => {
const agentModal = useOptionalAgentModal();
const openCreateModal = agentModal?.openCreateModal;
const enablePlatformAgent = useUserStore(labPreferSelectors.enablePlatformAgent);
/**
* Create agent menu item
@@ -243,6 +246,28 @@ export const useCreateMenuItems = () => {
[t, createHeterogeneousAgent],
);
/**
* Create platform agent menu item (openclaw / hermes remote device agents)
* Opens the 3-step creation modal
*/
const createPlatformAgentMenuItem = useCallback(
(options?: CreateAgentOptions): ItemType => {
if (!enablePlatformAgent) return null;
return {
icon: <Icon icon={MonitorSmartphone} />,
key: 'newPlatformAgent',
label: t('newPlatformAgent'),
onClick: (info) => {
info.domEvent?.stopPropagation();
agentModal?.openCreatePlatformAgentModal(
options?.groupId ? { groupId: options.groupId } : undefined,
);
},
};
},
[t, agentModal, enablePlatformAgent],
);
/**
* Create group chat menu item
* Creates an empty group and navigates to its profile page
@@ -340,6 +365,7 @@ export const useCreateMenuItems = () => {
createGroupWithMembers,
createPage,
createPageMenuItem,
createPlatformAgentMenuItem,
createSessionGroupMenuItem,
openCreateModal,
+17 -4
View File
@@ -35,14 +35,14 @@ const Page = memo(() => {
const [setSettings, isUserStateInit] = useUserStore((s) => [s.setSettings, s.isUserStateInit]);
const [loading, setLoading] = useState(false);
const [isPreferenceInit, enableInputMarkdown, enableGatewayMode, updateLab] = useUserStore(
(s) => [
const [isPreferenceInit, enableInputMarkdown, enableGatewayMode, enablePlatformAgent, updateLab] =
useUserStore((s) => [
preferenceSelectors.isPreferenceInit(s),
labPreferSelectors.enableInputMarkdown(s),
labPreferSelectors.enableGatewayMode(s),
labPreferSelectors.enablePlatformAgent(s),
s.updateLab,
],
);
]);
const hasGatewayUrl = useServerConfigStore((s) => !!s.serverConfig.agentGatewayUrl);
@@ -125,6 +125,19 @@ const Page = memo(() => {
label: tLabs('features.gatewayMode.title'),
minWidth: undefined,
} satisfies FormItemProps,
{
children: (
<Switch
checked={enablePlatformAgent}
loading={!isPreferenceInit}
onChange={(checked: boolean) => updateLab({ enablePlatformAgent: checked })}
/>
),
className: styles.labItem,
desc: tLabs('features.platformAgent.desc'),
label: tLabs('features.platformAgent.title'),
minWidth: undefined,
} satisfies FormItemProps,
]
: []),
];
+98 -8
View File
@@ -7,8 +7,16 @@ import { MessageModel } from '@/database/models/message';
import { TopicModel } from '@/database/models/topic';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { createStreamEventManager } from '@/server/modules/AgentRuntime/factory';
import { AiAgentService } from '@/server/services/aiAgent';
// Module-level singleton so we don't create a new Redis connection per request.
let _streamManager: ReturnType<typeof createStreamEventManager> | undefined;
const getStreamManager = () => {
if (!_streamManager) _streamManager = createStreamEventManager();
return _streamManager;
};
const log = debug('lobe-server:agent-notify-router');
const agentNotifyProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
@@ -33,6 +41,13 @@ const NotifySchema = z.object({
* the assistant message. Defaults to false.
*/
continue: z.boolean().optional(),
/**
* Signal that the remote hetero agent (openclaw / hermes) has finished its
* task. When true, the server publishes `agent_runtime_end` to the stream event
* manager so the frontend's gateway WS subscription closes cleanly.
* Can be combined with content (final message + done) or sent alone (just done).
*/
done: z.boolean().optional(),
/**
* When role is 'assistant': update an existing message instead of creating a
* new one. The caller is responsible for passing the messageId returned by the
@@ -69,20 +84,22 @@ export const agentNotifyRouter = router({
threadId,
role = 'user',
continue: shouldContinue = false,
done = false,
messageId,
} = input;
log(
'notify: topicId=%s, agentId=%s, role=%s, continue=%s, messageId=%s, content=%s',
'notify: topicId=%s, agentId=%s, role=%s, continue=%s, done=%s, messageId=%s, content=%s',
topicId,
inputAgentId,
role,
shouldContinue,
done,
messageId,
content.slice(0, 80),
);
// 1. Verify the topic exists and get its agentId
// 1. Verify the topic exists and get its agentId + running operationId
const topic = await ctx.topicModel.findById(topicId);
if (!topic) {
throw new TRPCError({
@@ -91,6 +108,12 @@ export const agentNotifyRouter = router({
});
}
// Extract the operationId seeded by execAgent for remote hetero agents.
// Used to publish notify_update / agent_runtime_end events to the gateway WS.
const remoteOperationId = (topic.metadata as any)?.runningOperation?.operationId as
| string
| undefined;
const agentId = inputAgentId ?? topic.agentId;
if (!agentId) {
throw new TRPCError({
@@ -99,24 +122,89 @@ export const agentNotifyRouter = router({
});
}
/**
* Publish a stream event for remote hetero agents (openclaw / hermes).
* Fire-and-forget stream publish failures must not break the notify response.
*/
const publishRemoteHeteroEvent = async (writtenMessageId?: string) => {
if (!remoteOperationId) return;
try {
const stream = getStreamManager();
if (done) {
// Signal task completion — frontend gateway WS subscription closes.
await stream.publishAgentRuntimeEnd(
remoteOperationId,
0,
{ reason: 'success' },
'success',
'Remote hetero agent task completed',
);
} else {
// Lightweight invalidation — frontend calls fetchAndReplaceMessages.
await stream.publishStreamEvent(remoteOperationId, {
data: { messageId: writtenMessageId },
stepIndex: 0,
type: 'notify_update',
});
}
} catch (err) {
log(
'notify: failed to publish stream event for operationId=%s: %O',
remoteOperationId,
err,
);
}
};
// 2a. Assistant mode: write message directly without triggering LLM
if (role === 'assistant') {
try {
// Update existing message if messageId provided (single-bubble progress updates)
if (messageId) {
await ctx.messageModel.update(messageId, { content });
// Resolve the target message ID:
// 1. Caller-supplied messageId (subsequent notify calls with --message-id)
// 2. Placeholder assistantMessageId seeded by execAgent (first notify call for remote hetero)
// Using the placeholder avoids creating a second empty bubble in the UI.
const placeholderMessageId = (topic.metadata as any)?.runningOperation
?.assistantMessageId as string | undefined;
const resolvedMessageId = messageId ?? placeholderMessageId;
// Update existing message if we have a resolved target
if (resolvedMessageId) {
// Security: verify the message belongs to this topic before writing.
// MessageModel.update scopes only by userId; without this check, a remote
// runtime could overwrite messages from other conversations.
const existingMsg = await ctx.messageModel.findById(resolvedMessageId);
if (!existingMsg || existingMsg.topicId !== topicId) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Message does not belong to this topic',
});
}
// done=true with empty content + existing placeholder → just signal completion, no update.
if (done && !content) {
void publishRemoteHeteroEvent();
return { messageId: resolvedMessageId, operationId: undefined, topicId };
}
await ctx.messageModel.update(resolvedMessageId, { content });
void publishRemoteHeteroEvent(resolvedMessageId);
if (shouldContinue) {
const result = await ctx.aiAgentService.execAgent({
agentId,
appContext: { threadId, topicId },
parentMessageId: messageId,
parentMessageId: resolvedMessageId,
prompt: '',
resume: true,
trigger: RequestTrigger.Notify,
});
return { messageId, operationId: result.operationId, topicId };
return { messageId: resolvedMessageId, operationId: result.operationId, topicId };
}
return { messageId, operationId: undefined, topicId };
return { messageId: resolvedMessageId, operationId: undefined, topicId };
}
// done=true with no messageId and empty content → just signal completion, no DB write.
if (done && !content) {
void publishRemoteHeteroEvent();
return { messageId: undefined, operationId: undefined, topicId };
}
const msg = await ctx.messageModel.create({
@@ -127,6 +215,8 @@ export const agentNotifyRouter = router({
topicId,
});
void publishRemoteHeteroEvent(msg.id);
// Optionally trigger a follow-up agent turn.
// Use resume=true + parentMessageId so execAgent skips creating an
// empty user message (effectiveResume=true bypasses that branch).
+10 -3
View File
@@ -325,6 +325,12 @@ const InterruptTaskSchema = z
operationId: z.string().optional(),
/** Thread ID */
threadId: z.string().optional(),
/**
* Topic ID required to cancel remote hetero tasks (openclaw / hermes).
* When provided and the topic's runningOperation has a deviceId, the server
* will dispatch a cancelHeteroTask tool call to kill the remote process.
*/
topicId: z.string().optional(),
})
.refine((data) => data.threadId || data.operationId, {
message: 'Either threadId or operationId must be provided',
@@ -357,6 +363,7 @@ const AgentStreamEventSchema = z.object({
'agent_intervention_response',
'step_start',
'step_complete',
'notify_update',
'error',
]),
});
@@ -1131,12 +1138,12 @@ export const aiAgentRouter = router({
* It updates both operation status and Thread status to cancelled state.
*/
interruptTask: aiAgentProcedure.input(InterruptTaskSchema).mutation(async ({ input, ctx }) => {
const { threadId, operationId } = input;
const { threadId, operationId, topicId } = input;
log('interruptTask: threadId=%s, operationId=%s', threadId, operationId);
log('interruptTask: threadId=%s, operationId=%s, topicId=%s', threadId, operationId, topicId);
try {
return await ctx.aiAgentService.interruptTask({ operationId, threadId });
return await ctx.aiAgentService.interruptTask({ operationId, threadId, topicId });
} catch (error: any) {
if (error.message === 'Thread not found') {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Thread not found' });
+87
View File
@@ -1,8 +1,21 @@
import { REMOTE_HETEROGENEOUS_AGENT_CONFIGS } from '@lobechat/heterogeneous-agents';
import { z } from 'zod';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { deviceProxy } from '@/server/services/toolExecution/deviceProxy';
// Derive the zod enum from the canonical config so new platforms are
// automatically covered without touching this file.
const remotePlatformEnum = z.enum(
REMOTE_HETEROGENEOUS_AGENT_CONFIGS.map((c) => c.type) as [
(typeof REMOTE_HETEROGENEOUS_AGENT_CONFIGS)[number]['type'],
...(typeof REMOTE_HETEROGENEOUS_AGENT_CONFIGS)[number]['type'][],
],
);
const CAPABILITY_TIMEOUT_MS = 5_000;
const PROFILE_TIMEOUT_MS = 5_000;
const deviceProcedure = authedProcedure.use(async (opts) => {
const { ctx } = opts;
@@ -12,6 +25,80 @@ const deviceProcedure = authedProcedure.use(async (opts) => {
});
export const deviceRouter = router({
/**
* Probe whether a specific agent platform (openclaw / hermes) is available
* on the given device. Dispatches a `checkPlatformCapability` tool call to
* the device via the gateway and waits up to 5 s for a response.
*/
checkCapability: deviceProcedure
.input(
z.object({
deviceId: z.string(),
platform: remotePlatformEnum,
}),
)
.query(async ({ ctx, input }) => {
const result = await deviceProxy.executeToolCall(
{ deviceId: input.deviceId, userId: ctx.userId },
{
apiName: 'checkPlatformCapability',
arguments: JSON.stringify({ platform: input.platform }),
identifier: 'local',
},
CAPABILITY_TIMEOUT_MS,
);
if (!result.success) {
return { available: false, reason: result.error ?? 'Device tool call failed' };
}
try {
return JSON.parse(result.content) as {
available: boolean;
reason?: string;
version?: string;
};
} catch {
return { available: false, reason: 'Invalid response from device' };
}
}),
/**
* Fetch the agent profile (title, description, avatar) from the platform
* installed on the given device. Used to pre-fill the creation modal.
* Returns an empty object on failure or when the platform has no profile.
*/
getAgentProfile: deviceProcedure
.input(
z.object({
deviceId: z.string(),
platform: remotePlatformEnum,
}),
)
.query(async ({ ctx, input }) => {
const result = await deviceProxy.executeToolCall(
{ deviceId: input.deviceId, userId: ctx.userId },
{
apiName: 'getAgentProfile',
arguments: JSON.stringify({ platform: input.platform }),
identifier: 'local',
},
PROFILE_TIMEOUT_MS,
);
if (!result.success) return {};
try {
return JSON.parse(result.content) as {
avatar?: string;
description?: string;
title?: string;
};
} catch {
return {};
}
}),
getDeviceSystemInfo: deviceProcedure
.input(z.object({ deviceId: z.string() }))
.query(async ({ ctx, input }) => {
+162 -11
View File
@@ -23,6 +23,7 @@ import type {
} from '@lobechat/context-engine';
import { SkillEngine } from '@lobechat/context-engine';
import type { LobeChatDatabase } from '@lobechat/database';
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
import { buildTaskManagerDefaultsPrompt } from '@lobechat/prompts';
import type {
ChatFileItem,
@@ -650,10 +651,11 @@ export class AiAgentService {
const model = agentConfig.model!;
const provider = agentConfig.provider!;
// 3.5. Hetero-agent early exit — Claude Code / Codex agents bypass the
// 3.5. Hetero-agent early exit — Claude Code / Codex / OpenClaw / Hermes agents bypass the
// server-side LLM pipeline. After topic + message creation we hand off to
// the device gateway (desktop) or cloud sandbox, which will push events
// back via `heteroIngest` / `heteroFinish`.
// back via `heteroIngest` / `heteroFinish` (claude-code / codex) or
// `agentNotify.notify` (openclaw / hermes).
//
// Detection: prefer agencyConfig.heterogeneousProvider.type (set by the UI),
// fall back to model field for backwards compatibility.
@@ -661,7 +663,12 @@ export class AiAgentService {
const heteroProviderType = agentConfig.agencyConfig?.heterogeneousProvider?.type;
const isHeteroAgent = !!heteroProviderType || HETERO_AGENT_MODELS.has(model);
if (isHeteroAgent) {
const heteroType = (heteroProviderType ?? model) as 'claude-code' | 'codex';
const heteroType = (heteroProviderType ?? model) as
| 'claude-code'
| 'codex'
| 'hermes'
| 'openclaw';
const isRemoteHetero = isRemoteHeterogeneousType(heteroType);
const operationId = nanoid();
// Create user message so the conversation is visible in the UI immediately.
@@ -676,12 +683,14 @@ export class AiAgentService {
});
// Create an assistant message placeholder (shows spinner in the UI).
// For remote hetero agents (openclaw/hermes), override provider with the hetero type
// so the frontend can identify the platform and display the correct name in the model tag.
const assistantMsg = await this.messageModel.create({
agentId: resolvedAgentId,
content: LOADING_FLAT,
model,
parentId: parentMessageId ?? userMsg?.id,
provider,
provider: isRemoteHetero ? heteroType : provider,
role: 'assistant',
threadId: appContext?.threadId ?? undefined,
topicId,
@@ -746,6 +755,9 @@ export class AiAgentService {
userId: this.userId,
};
const remoteDeviceId =
requestedDeviceId || agentConfig.agencyConfig?.boundDeviceId || undefined;
// Seed topic.metadata.runningOperation so heteroIngest can validate the operation.
// completionWebhook is stored so heteroFinish can call back to the IM bot-callback
// endpoint even though the hetero path bypasses the normal hook registration flow.
@@ -753,14 +765,111 @@ export class AiAgentService {
runningOperation: {
assistantMessageId: assistantMsg.id,
completionWebhook: hooks?.find((h) => h.type === 'onComplete')?.webhook,
// Store deviceId + heteroType so interruptTask can cancel remote processes
...(isRemoteHetero && remoteDeviceId
? { deviceId: remoteDeviceId, heteroType }
: undefined),
operationId,
scope: appContext?.scope ?? undefined,
threadId: appContext?.threadId ?? undefined,
},
});
if (requestedDeviceId) {
// Dispatch to the user's connected desktop via device-gateway.
// Remote hetero agents (openclaw / hermes) dispatch to the device identified
// by agencyConfig.boundDeviceId and communicate back via agentNotify.notify.
// They always go through the gateway WS channel — open the stream now so the
// frontend can subscribe before the first lh notify arrives.
if (isRemoteHetero) {
if (!remoteDeviceId) {
log('execAgent: openclaw/hermes requires a bound device (boundDeviceId not set)');
await this.messageModel.update(assistantMsg.id, {
content: '',
error: {
body: { detail: 'No device bound to this agent. Configure boundDeviceId.' },
message: 'No bound device for remote hetero agent',
type: 'ServerAgentRuntimeError',
},
});
return {
agentId: resolvedAgentId,
assistantMessageId: assistantMsg.id,
autoStarted: false,
createdAt: new Date().toISOString(),
error: 'No bound device',
message: 'Remote hetero agent requires boundDeviceId',
operationId,
status: 'error',
success: false,
timestamp: new Date().toISOString(),
topicId,
userMessageId: userMsg?.id ?? parentMessageId ?? '',
};
}
// Open the stream channel so the gateway WS subscription can receive
// notify_update events published by agentNotify.notify.
const { createStreamEventManager } = await import('@/server/modules/AgentRuntime/factory');
const streamManager = createStreamEventManager();
await streamManager
.publishAgentRuntimeInit(operationId, {
agentId: resolvedAgentId,
assistantMessageId: assistantMsg.id,
heteroType,
topicId,
userId: this.userId,
})
.catch((err) => log('execAgent: failed to init stream for remote hetero: %O', err));
// lh connect only handles tool_call_request (not agent_run_request),
// so we use executeToolCall with the runHeteroTask tool instead of dispatchAgentRun.
const result = await deviceProxy.executeToolCall(
{ deviceId: remoteDeviceId, userId: this.userId },
{
apiName: 'runHeteroTask',
arguments: JSON.stringify({
agentId: resolvedAgentId,
agentType: heteroType,
cwd: undefined,
operationId,
prompt,
taskId: operationId,
topicId,
}),
identifier: 'runHeteroTask',
},
120_000, // hetero tasks can take longer than the default 30 s
);
if (!result.success) {
log('execAgent: remote hetero dispatch failed: %s', result.error);
await streamManager
.publishAgentRuntimeEnd(operationId, 0, { error: result.error }, 'error', result.error)
.catch(() => {});
await this.messageModel.update(assistantMsg.id, {
content: '',
error: {
body: { detail: result.error },
message: result.error ?? 'Device dispatch failed',
type: 'ServerAgentRuntimeError',
},
});
return {
agentId: resolvedAgentId,
assistantMessageId: assistantMsg.id,
autoStarted: false,
createdAt: new Date().toISOString(),
error: result.error,
message: 'Remote hetero agent dispatch failed',
operationId,
status: 'error',
success: false,
timestamp: new Date().toISOString(),
topicId,
userMessageId: userMsg?.id ?? parentMessageId ?? '',
};
}
} else if (requestedDeviceId) {
// Local CLI (claude-code / codex) — dispatch to user's connected desktop.
const result = await deviceProxy.dispatchAgentRun({
...heteroParams,
deviceId: requestedDeviceId,
@@ -791,10 +900,15 @@ export class AiAgentService {
};
}
} else {
// Cloud sandbox path — fire-and-forget; errors surfaced via heteroFinish.
// Cloud sandbox path — only for local CLI agents (claude-code / codex).
// Remote agents (openclaw / hermes) always require a bound device.
const { spawnHeteroSandbox } =
await import('@/server/services/heterogeneousAgent/sandboxRunner');
spawnHeteroSandbox({ ...heteroParams, marketService: this.marketService }).catch((err) => {
spawnHeteroSandbox({
...heteroParams,
agentType: heteroType as 'claude-code' | 'codex',
marketService: this.marketService,
}).catch((err) => {
log('execAgent: hetero sandbox spawn failed: %O', err);
});
}
@@ -2549,8 +2663,9 @@ export class AiAgentService {
async interruptTask(params: {
operationId?: string;
threadId?: string;
topicId?: string;
}): Promise<{ operationId?: string; success: boolean; threadId?: string }> {
const { threadId, operationId } = params;
const { threadId, operationId, topicId } = params;
log('interruptTask: threadId=%s, operationId=%s', threadId, operationId);
@@ -2570,7 +2685,43 @@ export class AiAgentService {
throw new Error('Operation ID not found');
}
// 2. Interrupt the runtime operation first. Only mark the thread cancelled
// 2. Cancel remote hetero process (openclaw / hermes) if applicable.
// Check topic.metadata.runningOperation for device + heteroType info seeded by execAgent.
// This runs regardless of whether interruptOperation succeeds — the remote process
// is independent of the local operation registry.
if (topicId) {
const topic = await this.topicModel.findById(topicId);
const runningOp = (topic?.metadata as any)?.runningOperation as
| { deviceId?: string; heteroType?: string; operationId?: string }
| undefined;
if (
runningOp?.deviceId &&
runningOp.heteroType &&
isRemoteHeterogeneousType(runningOp.heteroType)
) {
const taskId = runningOp.operationId ?? resolvedOperationId;
log(
'interruptTask: cancelling remote hetero process heteroType=%s deviceId=%s taskId=%s',
runningOp.heteroType,
runningOp.deviceId,
taskId,
);
await deviceProxy
.executeToolCall(
{ deviceId: runningOp.deviceId, userId: this.userId },
{
apiName: 'cancelHeteroTask',
arguments: JSON.stringify({ signal: 'SIGINT', taskId }),
identifier: 'cancelHeteroTask',
},
5_000,
)
.catch((err) => log('interruptTask: cancelHeteroTask dispatch failed: %O', err));
}
}
// 3. Interrupt the runtime operation first. Only mark the thread cancelled
// after the runtime acknowledges the interrupt to avoid unlocking a live task.
const interrupted = await this.agentRuntimeService.interruptOperation(resolvedOperationId);
log(
@@ -2589,7 +2740,7 @@ export class AiAgentService {
};
}
// 3. Update Thread status to cancel
// 4. Update Thread status to cancel
if (thread) {
await this.threadModel.update(thread.id, {
metadata: {
@@ -4,6 +4,7 @@ import {
type DeviceSystemInfo,
GatewayHttpClient,
} from '@lobechat/device-gateway-client';
import type { HeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
import debug from 'debug';
import { gatewayEnv } from '@/envs/gateway';
@@ -67,7 +68,7 @@ export class DeviceProxy {
}
async dispatchAgentRun(params: {
agentType: 'claude-code' | 'codex';
agentType: HeterogeneousAgentType;
cwd?: string;
deviceId?: string;
jwt: string;
+1
View File
@@ -77,6 +77,7 @@ export interface GetSubAgentTaskStatusParams {
export interface InterruptTaskParams {
operationId?: string;
threadId?: string;
topicId?: string;
}
/**
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest';
import { selectRuntimeType } from '../agentDispatcher';
const heteroProvider = { command: 'claude', type: 'claude-code' as const };
const remoteHeteroProvider = { type: 'openclaw' as const };
const remoteHeteroProviderHermes = { type: 'hermes' as const };
describe('selectRuntimeType', () => {
describe('on web (isDesktop = false)', () => {
@@ -16,7 +18,7 @@ describe('selectRuntimeType', () => {
expect(selectRuntimeType({ isGatewayMode: true }, opts)).toBe('gateway');
});
it('routes heterogeneousProvider to gateway on web — cloud sandbox is the only execution env', () => {
it('routes local heterogeneousProvider to gateway on web — cloud sandbox is the only execution env', () => {
expect(
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: true }, opts),
).toBe('gateway');
@@ -24,12 +26,27 @@ describe('selectRuntimeType', () => {
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: false }, opts),
).toBe('gateway');
});
it('routes remote platform agents (openclaw/hermes) to gateway on web', () => {
expect(
selectRuntimeType(
{ heterogeneousProvider: remoteHeteroProvider, isGatewayMode: false },
opts,
),
).toBe('gateway');
expect(
selectRuntimeType(
{ heterogeneousProvider: remoteHeteroProviderHermes, isGatewayMode: false },
opts,
),
).toBe('gateway');
});
});
describe('on desktop (isDesktop = true)', () => {
const opts = { isDesktop: true };
it('returns hetero when a heterogeneousProvider is configured', () => {
it('returns hetero for local CLI agents (claude-code, codex)', () => {
expect(
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: true }, opts),
).toBe('hetero');
@@ -38,6 +55,22 @@ describe('selectRuntimeType', () => {
).toBe('hetero');
});
it('routes remote platform agents (openclaw/hermes) to gateway even on desktop', () => {
// openclaw and hermes use device gateway, not desktop subprocess — must not go to hetero
expect(
selectRuntimeType(
{ heterogeneousProvider: remoteHeteroProvider, isGatewayMode: false },
opts,
),
).toBe('gateway');
expect(
selectRuntimeType(
{ heterogeneousProvider: remoteHeteroProviderHermes, isGatewayMode: false },
opts,
),
).toBe('gateway');
});
it('falls back to gateway/client when no hetero provider', () => {
expect(selectRuntimeType({ isGatewayMode: true }, opts)).toBe('gateway');
expect(selectRuntimeType({ isGatewayMode: false }, opts)).toBe('client');
@@ -742,7 +742,10 @@ describe('GatewayActionImpl', () => {
// ...and, when invoked, fires tRPC interruptTask with the *server-side* operation id
const [, handler] = onOperationCancel.mock.calls[0];
await handler();
expect(interruptTaskSpy).toHaveBeenCalledWith({ operationId: 'server-op-xyz' });
expect(interruptTaskSpy).toHaveBeenCalledWith({
operationId: 'server-op-xyz',
topicId: 'topic-1',
});
});
});
});
@@ -1,4 +1,5 @@
import { isDesktop as defaultIsDesktop } from '@lobechat/const';
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
import { type HeterogeneousProviderConfig } from '@lobechat/types';
/**
@@ -10,6 +11,41 @@ import { type HeterogeneousProviderConfig } from '@lobechat/types';
*/
export type AgentRuntimeType = 'client' | 'gateway' | 'hetero';
/**
* Unified intent for a non-hetero, non-group sub-agent invocation.
*
* All three caller patterns (`callSubAgent` / `callAgent` / `@agent`) map
* their parameters into this shape before handing off to
* `dispatchNonHeteroSubAgent`. Runtime routing is entirely the dispatcher's
* responsibility callers only declare *what* they want, not *how* to run it.
*
* Excluded from this contract:
* - Hetero agents (handled by the heterogeneous pipeline)
* - Group orchestration (handled by `groupOrchestration.triggerSpeak`)
* - Async task mode (handled by the `execSubAgent` executor via state.type)
*/
export interface AgentInvocationIntent {
/**
* Instruction delivered to the sub-agent.
* In client mode it is injected as a virtual user message prepended to the
* existing message history. In gateway mode it becomes the `message` param
* of `executeGatewayAgent` (i.e. a real user message on the server).
*/
instruction: string;
/**
* Which invocation pattern produced this intent.
* Preserved for logging / debugging; has no effect on runtime selection.
*/
kind: 'callAgent' | 'callSubAgent' | 'mention';
/**
* ID of the tool result message that triggered this invocation.
* Used as `parentMessageId` by the client executor.
*/
parentMessageId: string;
/** Target agent to execute. */
targetAgentId: string;
}
export interface RuntimeSelectionContext {
/** Per-agent heterogeneous provider config (desktop only — takes priority over gateway). */
heterogeneousProvider?: HeterogeneousProviderConfig;
@@ -45,9 +81,15 @@ export const selectRuntimeType = (
{ isDesktop = defaultIsDesktop }: SelectRuntimeTypeOptions = {},
): AgentRuntimeType => {
if (ctx.parentRuntime) return ctx.parentRuntime;
// Remote device agents (openclaw / hermes) always use the gateway path regardless of
// desktop/web — they communicate via a device connected with `lh connect`, not via
// local desktop IPC. No special desktop handling needed.
if (ctx.heterogeneousProvider && isRemoteHeterogeneousType(ctx.heterogeneousProvider.type)) {
return 'gateway';
}
// Local CLI agents (claude-code, codex) run as desktop subprocesses.
if (isDesktop && ctx.heterogeneousProvider) return 'hetero';
// On web, heterogeneous agents always run via Gateway sandbox regardless of the
// isGatewayMode user preference — the sandbox is the only execution environment.
// On web, all remaining hetero agents run via the Gateway sandbox.
if (!isDesktop && ctx.heterogeneousProvider) return 'gateway';
if (ctx.isGatewayMode) return 'gateway';
return 'client';
@@ -41,6 +41,7 @@ import {
import { agentGroupByIdSelectors, getChatGroupStoreState } from '@/store/agentGroup';
import { selectRuntimeType } from '@/store/chat/slices/aiChat/actions/agentDispatcher';
import { resolveHeteroResume } from '@/store/chat/slices/aiChat/actions/heteroResume';
import { dispatchNonHeteroSubAgent } from '@/store/chat/slices/aiChat/actions/nonHeteroSubAgentDispatcher';
import { type ChatStore } from '@/store/chat/store';
import {
mergeAgentRuntimeInitialContexts,
@@ -1184,34 +1185,24 @@ export class ConversationLifecycleActionImpl {
: currentMessages;
// Sub-agent dispatch inherits the parent's runtime selection — a
// hetero/gateway parent must keep its sub-agents on the same path so
// events route through the same wire. See LOBE-8519.
// gateway/hetero parent must keep its sub-agents on the same path.
// Runtime routing is fully delegated to dispatchNonHeteroSubAgent (LOBE-8927).
const parentAgentConfig = context.agentId
? agentSelectors.getAgentConfigById(context.agentId)(getAgentStoreState())
: undefined;
const runtimeType = selectRuntimeType({
heterogeneousProvider: parentAgentConfig?.agencyConfig?.heterogeneousProvider,
isGatewayMode: this.#get().isGatewayModeEnabled(),
});
// TODO(LOBE-8519 follow-up): only client sub-agent dispatch is
// implemented today. Gateway / hetero direct mentions fall through to
// client and will need their own runner once Step 2 lands.
if (runtimeType !== 'client') {
console.warn(
`[directMentionRoute] runtime=${runtimeType} not yet supported for sub-agent dispatch; ` +
'falling through to client mode',
);
}
await this.#get().executeClientAgent({
context: { ...context, scope: 'sub_agent', subAgentId: targetAgentId },
inPortalThread,
messages: messagesWithInstruction,
parentMessageId: toolMessage.id,
parentMessageType: 'tool',
parentOperationId: operationId,
});
await dispatchNonHeteroSubAgent(
{ kind: 'mention', targetAgentId, instruction, parentMessageId: toolMessage.id },
{
conversationContext: context,
heterogeneousProvider: parentAgentConfig?.agencyConfig?.heterogeneousProvider,
inPortalThread,
isGatewayMode: this.#get().isGatewayModeEnabled(),
messages: messagesWithInstruction,
parentOperationId: operationId,
},
this.#get(),
);
this.#get().completeOperation(operationId);
} catch (error) {
@@ -423,7 +423,7 @@ export class GatewayActionImpl {
// never block the local cancel flow.
this.#get().onOperationCancel(gatewayOpId, async () => {
await aiAgentService
.interruptTask({ operationId: result.operationId })
.interruptTask({ operationId: result.operationId, topicId: result.topicId })
.catch((err) => console.error('[Gateway] interruptTask failed:', err));
});
@@ -504,6 +504,15 @@ export const createGatewayEventHandler = (
break;
}
case 'notify_update': {
// Remote hetero agent (openclaw / hermes) wrote a message to DB via
// `lh notify`. DB is the source of truth — just refresh the message list.
enqueue(async () => {
await fetchAndReplaceMessages(get, context).catch(console.error);
});
break;
}
case 'error': {
enqueue(async () => {
const messageError = toChatMessageError(event.data);
@@ -0,0 +1,129 @@
import type {
ConversationContext,
HeterogeneousProviderConfig,
UIChatMessage,
} from '@lobechat/types';
import type { ChatStore } from '@/store/chat/store';
import {
type AgentInvocationIntent,
type AgentRuntimeType,
selectRuntimeType,
} from './agentDispatcher';
/**
* Execution context supplied by the caller at dispatch time.
* Carries the runtime-selection inputs and client-mode extras that the
* dispatcher needs but that do not belong in the intent itself.
*/
export interface NonHeteroSubAgentDispatchContext {
/** Conversation context of the *parent* agent (agentId = parent agent). */
conversationContext: ConversationContext;
/** Per-agent heterogeneous provider config used for runtime resolution. */
heterogeneousProvider?: HeterogeneousProviderConfig;
/**
* Whether the sub-agent runs inside a portal thread.
* Client mode only has no effect in gateway mode.
*/
inPortalThread?: boolean;
/** Current gateway mode status (`chatStore.isGatewayModeEnabled()`). */
isGatewayMode: boolean;
/**
* Messages passed to the client-side runner.
* Typically the current conversation messages plus a virtual instruction
* message prepended by the caller. Only consumed when runtime = 'client'.
*/
messages?: UIChatMessage[];
/**
* Parent operation ID to link the sub-agent operation as a child.
* Optional only provided when the caller has an active operation to chain.
*/
parentOperationId?: string;
/**
* Explicit runtime inherited from the parent operation.
* When set, `selectRuntimeType` returns this value immediately, preserving
* the parent's execution environment for the child invocation.
*/
parentRuntime?: AgentRuntimeType;
}
/**
* Unified dispatcher for non-hetero, non-group sub-agent invocations (LOBE-8927).
*
* Resolves the child runtime by inheriting from the parent (via
* `selectRuntimeType` with `parentRuntime`), then routes to the correct
* executor. Replaces the per-entry-point runtime selection and client-only
* fallback that previously lived in `callAgent` and `#executeDirectMentionRoute`.
*
* Runtime routing rules (same as top-level `selectRuntimeType`):
* `parentRuntime` wins otherwise hetero gateway client
*
* Context semantics by runtime:
* - client: `agentId` = parent agent (for message key), `subAgentId` = target
* - gateway: `agentId` = target agent (gateway runs this agent), `subAgentId` = target
*
* Explicitly excluded:
* - `hetero` runtime throws (handled by the heterogeneous pipeline)
* - group orchestration not routed here (callers guard this upstream)
*/
export async function dispatchNonHeteroSubAgent(
intent: AgentInvocationIntent,
ctx: NonHeteroSubAgentDispatchContext,
store: Pick<ChatStore, 'executeClientAgent' | 'executeGatewayAgent'>,
): Promise<void> {
const runtimeType = selectRuntimeType({
heterogeneousProvider: ctx.heterogeneousProvider,
isGatewayMode: ctx.isGatewayMode,
parentRuntime: ctx.parentRuntime,
});
switch (runtimeType) {
case 'client': {
// Keep agentId as the parent agent so the message map key is correct.
// subAgentId selects the target agent's config (effectiveAgentId = subAgentId).
await store.executeClientAgent({
context: {
...ctx.conversationContext,
scope: 'sub_agent',
subAgentId: intent.targetAgentId,
},
inPortalThread: ctx.inPortalThread,
messages: ctx.messages ?? [],
parentMessageId: intent.parentMessageId,
parentMessageType: 'tool',
parentOperationId: ctx.parentOperationId,
});
break;
}
case 'gateway': {
// Switch agentId to the target agent so the gateway runs the correct one.
// The gateway loads conversation history from the topic DB, so we do NOT
// pass the client-side message array. The instruction becomes a real user
// message created on the server.
await store.executeGatewayAgent({
context: {
...ctx.conversationContext,
agentId: intent.targetAgentId,
scope: 'sub_agent',
subAgentId: intent.targetAgentId,
},
message: intent.instruction,
parentOperationId: ctx.parentOperationId,
});
break;
}
case 'hetero': {
// Hetero sub-agent invocation is out of scope for LOBE-8926.
// Hetero agents are dispatched through a dedicated heterogeneous pipeline
// (`executeHeterogeneousAgent`) and must not fall through to client mode.
throw new Error(
`[dispatchNonHeteroSubAgent] Hetero runtime is not supported for ` +
`non-hetero sub-agent dispatch. ` +
`kind=${intent.kind}, targetAgentId=${intent.targetAgentId}`,
);
}
}
}
@@ -8,4 +8,5 @@ export const labPreferSelectors = {
enableGatewayMode: (s: UserState): boolean => s.preference.lab?.enableGatewayMode ?? false,
enableInputMarkdown: (s: UserState): boolean =>
s.preference.lab?.enableInputMarkdown ?? DEFAULT_PREFERENCE.lab!.enableInputMarkdown!,
enablePlatformAgent: (s: UserState): boolean => s.preference.lab?.enablePlatformAgent ?? false,
};