Compare commits

...

10 Commits

Author SHA1 Message Date
ONLY-yours ebd4bbaf0f feat: add exportAndUploadFile in sandbox servers 2026-01-19 18:41:10 +08:00
arvinxx 83ff13680e fix memory schema 2026-01-19 18:28:38 +08:00
arvinxx d2bb7d1ad8 fix batch async tasks 2026-01-19 17:09:02 +08:00
arvinxx 26c1d09411 fix 2026-01-19 17:05:39 +08:00
arvinxx a24cb4f9c9 fix 2026-01-19 17:01:16 +08:00
arvinxx 5975a45666 fix cloud sandbox in server agent runtime 2026-01-19 14:51:22 +08:00
arvinxx ea6900b44c fix cloud sandbox in server agent runtime 2026-01-19 14:09:33 +08:00
arvinxx 78a7274172 improve task message issue 2026-01-19 13:55:33 +08:00
arvinxx 63fc0f3ed4 refactor the cloud sandbox in client mode 2026-01-19 13:55:32 +08:00
arvinxx 3c8c478846 fix server agent task run with headless 2026-01-19 13:55:32 +08:00
42 changed files with 2280 additions and 434 deletions
@@ -88,10 +88,22 @@ export class GeneralChatAgent implements Agent {
}
// Priority 0: CRITICAL - Check security blacklist FIRST
// This overrides ALL other settings, including auto-run mode
const securityCheck = InterventionChecker.checkSecurityBlacklist(securityBlacklist, toolArgs);
// Priority 0.5: Headless mode - fully automated for async tasks
// In headless mode: blacklisted tools are skipped, all other tools execute directly
if (approvalMode === 'headless') {
if (securityCheck.blocked) {
// Skip blacklisted tools entirely (don't execute, don't wait for approval)
continue;
}
// All other tools execute directly
toolsToExecute.push(toolCalling);
continue;
}
// For non-headless modes: security blacklist requires intervention
if (securityCheck.blocked) {
// Security blacklist always requires intervention
toolsNeedingIntervention.push(toolCalling);
continue;
}
@@ -911,7 +911,12 @@ describe('GeneralChatAgent', () => {
const context = createMockContext('task_result', {
parentMessageId: 'task-parent-msg',
result: { success: true, taskMessageId: 'task-1', threadId: 'thread-1', result: 'Task result' },
result: {
success: true,
taskMessageId: 'task-1',
threadId: 'thread-1',
result: 'Task result',
},
});
const result = await agent.runner(context, state);
@@ -1519,4 +1524,273 @@ describe('GeneralChatAgent', () => {
]);
});
});
describe('headless mode (for async tasks)', () => {
it('should execute all tools directly in headless mode including those requiring approval', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCall: ChatToolPayload = {
id: 'call-1',
identifier: 'dangerous-tool',
apiName: 'delete',
arguments: '{}',
type: 'default',
};
const state = createMockState({
toolManifestMap: {
'dangerous-tool': {
identifier: 'dangerous-tool',
humanIntervention: 'required', // Tool requires approval
},
},
userInterventionConfig: {
approvalMode: 'headless', // Headless mode for async tasks
},
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [toolCall],
parentMessageId: 'msg-1',
});
const result = await agent.runner(context, state);
// Should execute directly in headless mode
expect(result).toEqual([
{
type: 'call_tool',
payload: {
parentMessageId: 'msg-1',
toolCalling: toolCall,
},
},
]);
});
it('should execute tools with "always" policy in headless mode', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const alwaysTool: ChatToolPayload = {
id: 'call-1',
identifier: 'agent-builder',
apiName: 'installPlugin',
arguments: '{"identifier":"some-plugin","source":"market"}',
type: 'builtin',
};
const state = createMockState({
toolManifestMap: {
'agent-builder': {
identifier: 'agent-builder',
api: [
{
name: 'installPlugin',
humanIntervention: 'always', // Always requires intervention normally
},
],
},
},
userInterventionConfig: {
approvalMode: 'headless', // Headless mode bypasses even 'always'
},
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [alwaysTool],
parentMessageId: 'msg-1',
});
const result = await agent.runner(context, state);
// Should execute directly in headless mode, even for 'always' policy
expect(result).toEqual([
{
type: 'call_tool',
payload: {
parentMessageId: 'msg-1',
toolCalling: alwaysTool,
},
},
]);
});
it('should skip security blacklisted tools in headless mode', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const blacklistedTool: ChatToolPayload = {
id: 'call-1',
identifier: 'bash',
apiName: 'bash',
arguments: '{"command":"rm -rf /"}', // Matches security blacklist
type: 'builtin',
};
const state = createMockState({
toolManifestMap: {
bash: {
identifier: 'bash',
humanIntervention: 'never',
},
},
userInterventionConfig: {
approvalMode: 'headless',
},
// Using default security blacklist which blocks "rm -rf /"
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [blacklistedTool],
parentMessageId: 'msg-1',
});
const result = await agent.runner(context, state);
// Should return empty array (tool is skipped, not executed or pending)
expect(result).toEqual([]);
});
it('should handle mixed tools in headless mode - execute safe ones, skip blacklisted', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const safeTool: ChatToolPayload = {
id: 'call-1',
identifier: 'web-search',
apiName: 'search',
arguments: '{"query":"hello"}',
type: 'default',
};
const blacklistedTool: ChatToolPayload = {
id: 'call-2',
identifier: 'bash',
apiName: 'bash',
arguments: '{"command":"rm -rf /"}', // Matches security blacklist
type: 'builtin',
};
const alwaysTool: ChatToolPayload = {
id: 'call-3',
identifier: 'agent-builder',
apiName: 'installPlugin',
arguments: '{}',
type: 'builtin',
};
const state = createMockState({
toolManifestMap: {
'web-search': {
identifier: 'web-search',
humanIntervention: 'required',
},
'bash': {
identifier: 'bash',
},
'agent-builder': {
identifier: 'agent-builder',
api: [
{
name: 'installPlugin',
humanIntervention: 'always',
},
],
},
},
userInterventionConfig: {
approvalMode: 'headless',
},
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [safeTool, blacklistedTool, alwaysTool],
parentMessageId: 'msg-1',
});
const result = await agent.runner(context, state);
// Should execute safeTool and alwaysTool, skip blacklistedTool
expect(result).toEqual([
{
type: 'call_tools_batch',
payload: {
parentMessageId: 'msg-1',
toolsCalling: [safeTool, alwaysTool],
},
},
]);
});
it('should execute multiple tools as batch in headless mode', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const tool1: ChatToolPayload = {
id: 'call-1',
identifier: 'search',
apiName: 'search',
arguments: '{}',
type: 'default',
};
const tool2: ChatToolPayload = {
id: 'call-2',
identifier: 'crawl',
apiName: 'crawl',
arguments: '{}',
type: 'default',
};
const state = createMockState({
toolManifestMap: {
search: { identifier: 'search', humanIntervention: 'required' },
crawl: { identifier: 'crawl', humanIntervention: 'always' },
},
userInterventionConfig: {
approvalMode: 'headless',
},
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [tool1, tool2],
parentMessageId: 'msg-1',
});
const result = await agent.runner(context, state);
// Should execute both tools as batch in headless mode
expect(result).toEqual([
{
type: 'call_tools_batch',
payload: {
parentMessageId: 'msg-1',
toolsCalling: [tool1, tool2],
},
},
]);
});
});
});
@@ -5,6 +5,7 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executor": "./src/executor/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
},
"main": "./src/index.ts",
@@ -1,125 +1,62 @@
import {
formatEditResult,
formatFileContent,
formatFileList,
formatFileSearchResults,
formatGlobResults,
formatMoveResults,
formatRenameResult,
formatWriteResult,
} from '@lobechat/prompts';
import { type BuiltinServerRuntimeOutput } from '@lobechat/types';
import { codeInterpreterService } from '@/services/codeInterpreter';
import {
type EditLocalFileParams,
type EditLocalFileState,
type ExecuteCodeParams,
type ExecuteCodeState,
type ExportFileParams,
type ExportFileState,
type GetCommandOutputParams,
type GetCommandOutputState,
type GlobFilesState,
type GlobLocalFilesParams,
type GrepContentParams,
type GrepContentState,
type ISandboxService,
type KillCommandParams,
type KillCommandState,
type ListLocalFilesParams,
type ListLocalFilesState,
type MoveLocalFilesParams,
type MoveLocalFilesState,
type ReadLocalFileParams,
type ReadLocalFileState,
type RenameLocalFileParams,
type RenameLocalFileState,
type RunCommandParams,
type RunCommandState,
type SearchLocalFilesParams,
type SearchLocalFilesState,
type WriteLocalFileParams,
type WriteLocalFileState,
} from '../types';
/**
* Cloud Sandbox Execution Runtime
*
* This runtime executes tools via the LobeHub Market SDK's runBuildInTool API,
* which connects to AWS Bedrock AgentCore sandbox.
* This runtime executes tools via the injected ISandboxService.
* The service handles context (topicId, userId) internally - Runtime doesn't need to know about it.
*
* Session Management:
* - Sessions are automatically created per userId + topicId combination
* - Sessions are recreated automatically if expired
* - The sessionExpiredAndRecreated flag indicates if recreation occurred
* Dependency Injection:
* - Client: Inject codeInterpreterService (uses tRPC client)
* - Server: Inject ServerSandboxService (uses MarketSDK directly)
*/
interface ExecutionContext {
topicId: string;
userId: string;
}
// Types for tool parameters matching market-sdk
interface ListLocalFilesParams {
directoryPath: string;
}
interface ReadLocalFileParams {
endLine?: number;
path: string;
startLine?: number;
}
interface WriteLocalFileParams {
content: string;
createDirectories?: boolean;
path: string;
}
interface EditLocalFileParams {
all?: boolean;
path: string;
replace: string;
search: string;
}
interface SearchLocalFilesParams {
directory: string;
fileType?: string;
keyword?: string;
modifiedAfter?: string;
modifiedBefore?: string;
}
interface MoveLocalFilesParams {
operations: Array<{
destination: string;
source: string;
}>;
}
interface RenameLocalFileParams {
newName: string;
oldPath: string;
}
interface RunCommandParams {
background?: boolean;
command: string;
timeout?: number;
}
interface GetCommandOutputParams {
commandId: string;
}
interface KillCommandParams {
commandId: string;
}
interface GrepContentParams {
directory: string;
filePattern?: string;
pattern: string;
recursive?: boolean;
}
interface GlobLocalFilesParams {
directory?: string;
pattern: string;
}
interface ExportFileParams {
path: string;
}
interface ExecuteCodeParams {
code: string;
language?: 'javascript' | 'python' | 'typescript';
}
export class CloudSandboxExecutionRuntime {
private context: ExecutionContext;
private sandboxService: ISandboxService;
constructor(context: ExecutionContext) {
this.context = context;
constructor(sandboxService: ISandboxService) {
this.sandboxService = sandboxService;
}
// ==================== File Operations ====================
@@ -128,12 +65,19 @@ export class CloudSandboxExecutionRuntime {
try {
const result = await this.callTool('listLocalFiles', args);
const state: ListLocalFilesState = {
files: result.result?.files || [],
};
const files = result.result?.files || [];
const state: ListLocalFilesState = { files };
const content = formatFileList(
files.map((f: { isDirectory: boolean; name: string }) => ({
isDirectory: f.isDirectory,
name: f.name,
})),
args.directoryPath,
);
return {
content: JSON.stringify(result.result),
content,
state,
success: true,
};
@@ -154,8 +98,19 @@ export class CloudSandboxExecutionRuntime {
totalLines: result.result?.totalLines,
};
const lineRange: [number, number] | undefined =
args.startLine !== undefined && args.endLine !== undefined
? [args.startLine, args.endLine]
: undefined;
const content = formatFileContent({
content: result.result?.content || '',
lineRange,
path: args.path,
});
return {
content: JSON.stringify(result.result),
content,
state,
success: true,
};
@@ -174,11 +129,13 @@ export class CloudSandboxExecutionRuntime {
success: result.success,
};
const content = formatWriteResult({
path: args.path,
success: true,
});
return {
content: JSON.stringify({
message: `Successfully wrote to ${args.path}`,
success: true,
}),
content,
state,
success: true,
};
@@ -199,13 +156,15 @@ export class CloudSandboxExecutionRuntime {
replacements: result.result?.replacements || 0,
};
const statsText =
state.linesAdded || state.linesDeleted
? ` (+${state.linesAdded || 0} -${state.linesDeleted || 0})`
: '';
const content = formatEditResult({
filePath: args.path,
linesAdded: state.linesAdded,
linesDeleted: state.linesDeleted,
replacements: state.replacements,
});
return {
content: `Successfully replaced ${state.replacements} occurrence(s) in ${args.path}${statsText}`,
content,
state,
success: true,
};
@@ -218,13 +177,18 @@ export class CloudSandboxExecutionRuntime {
try {
const result = await this.callTool('searchLocalFiles', args);
const results = result.result?.results || [];
const state: SearchLocalFilesState = {
results: result.result?.results || [],
results,
totalCount: result.result?.totalCount || 0,
};
const content = formatFileSearchResults(
results.map((r: { path: string }) => ({ path: r.path })),
);
return {
content: JSON.stringify(result.result),
content,
state,
success: true,
};
@@ -237,17 +201,17 @@ export class CloudSandboxExecutionRuntime {
try {
const result = await this.callTool('moveLocalFiles', args);
const results = result.result?.results || [];
const state: MoveLocalFilesState = {
results: result.result?.results || [],
results,
successCount: result.result?.successCount || 0,
totalCount: args.operations.length,
};
const content = formatMoveResults(results);
return {
content: JSON.stringify({
message: `Moved ${state.successCount}/${state.totalCount} items`,
results: state.results,
}),
content,
state,
success: true,
};
@@ -267,11 +231,15 @@ export class CloudSandboxExecutionRuntime {
success: result.success,
};
const content = formatRenameResult({
error: result.result?.error,
newName: args.newName,
oldPath: args.oldPath,
success: result.success,
});
return {
content: JSON.stringify({
message: `Successfully renamed ${args.oldPath} to ${args.newName}`,
success: true,
}),
content,
state,
success: result.success,
};
@@ -405,14 +373,22 @@ export class CloudSandboxExecutionRuntime {
try {
const result = await this.callTool('globLocalFiles', args);
const files = result.result?.files || [];
const totalCount = result.result?.totalCount || 0;
const state: GlobFilesState = {
files: result.result?.files || [],
files,
pattern: args.pattern,
totalCount: result.result?.totalCount || 0,
totalCount,
};
const content = formatGlobResults({
files,
totalFiles: totalCount,
});
return {
content: JSON.stringify(result.result),
content,
state,
success: true,
};
@@ -425,7 +401,7 @@ export class CloudSandboxExecutionRuntime {
/**
* Export a file from the sandbox to cloud storage
* Uses a single tRPC call that handles:
* Uses a single call that handles:
* 1. Generate pre-signed upload URL
* 2. Call sandbox to upload file
* 3. Create persistent file record
@@ -437,11 +413,7 @@ export class CloudSandboxExecutionRuntime {
const filename = args.path.split('/').pop() || 'exported_file';
// Single call that handles everything: upload URL generation, sandbox upload, and file record creation
const result = await codeInterpreterService.exportAndUploadFile(
args.path,
filename,
this.context.topicId,
);
const result = await this.sandboxService.exportAndUploadFile(args.path, filename);
const state: ExportFileState = {
downloadUrl: result.success && result.url ? result.url : '',
@@ -478,17 +450,16 @@ export class CloudSandboxExecutionRuntime {
// ==================== Helper Methods ====================
/**
* Call a tool via the market SDK through tRPC
* Routes through: ExecutionRuntime -> codeInterpreterService -> tRPC -> codeInterpreterRouter -> MarketSDK
* Call a tool via the injected sandbox service
*/
private async callTool(
toolName: string,
params: Record<string, any>,
): Promise<{ result: any; sessionExpiredAndRecreated?: boolean; success: boolean }> {
const result = await codeInterpreterService.callTool(toolName, params, this.context);
const result = await this.sandboxService.callTool(toolName, params);
if (!result.success) {
throw new Error((result as any).error?.message || `Cloud Sandbox tool ${toolName} failed`);
throw new Error(result.error?.message || `Cloud Sandbox tool ${toolName} failed`);
}
return result;
@@ -0,0 +1,254 @@
import { BaseExecutor, type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types';
import { cloudSandboxService } from '@/services/cloudSandbox';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/slices/auth/selectors';
import { CloudSandboxExecutionRuntime } from '../ExecutionRuntime';
import { CloudSandboxIdentifier } from '../manifest';
import {
CloudSandboxApiName,
type EditLocalFileParams,
type ExecuteCodeParams,
type ExportFileParams,
type GetCommandOutputParams,
type GlobLocalFilesParams,
type GrepContentParams,
type ISandboxService,
type KillCommandParams,
type ListLocalFilesParams,
type MoveLocalFilesParams,
type ReadLocalFileParams,
type RenameLocalFileParams,
type RunCommandParams,
type SandboxCallToolResult,
type SandboxExportFileResult,
type SearchLocalFilesParams,
type WriteLocalFileParams,
} from '../types';
/**
* Client-side Sandbox Service
* Wraps codeInterpreterService with bound context (topicId, userId)
*/
class ClientSandboxService implements ISandboxService {
private topicId: string;
private userId: string;
constructor(topicId: string) {
this.topicId = topicId;
// Get userId from user store - client-side auth
const userId = userProfileSelectors.userId(useUserStore.getState());
if (!userId) {
throw new Error('userId must be provided');
}
this.userId = userId;
}
async callTool(toolName: string, params: Record<string, any>): Promise<SandboxCallToolResult> {
return cloudSandboxService.callTool(toolName, params, {
topicId: this.topicId,
userId: this.userId,
});
}
async exportAndUploadFile(path: string, filename: string): Promise<SandboxExportFileResult> {
return cloudSandboxService.exportAndUploadFile(path, filename, this.topicId);
}
}
/**
* Cloud Sandbox Client Executor
*
* This executor handles Cloud Sandbox tool calls on the client side.
* It creates a CloudSandboxExecutionRuntime with a ClientSandboxService
* that has topicId bound at construction time.
*/
class CloudSandboxExecutor extends BaseExecutor<typeof CloudSandboxApiName> {
readonly identifier = CloudSandboxIdentifier;
protected readonly apiEnum = CloudSandboxApiName;
/**
* Get or create a runtime for the given context
*/
private getRuntime(ctx: BuiltinToolContext): CloudSandboxExecutionRuntime {
const topicId = ctx.topicId;
if (!topicId) {
throw new Error('Can not init runtime with empty topicId');
}
const service = new ClientSandboxService(topicId);
return new CloudSandboxExecutionRuntime(service);
}
// ==================== File Operations ====================
listLocalFiles = async (
params: ListLocalFilesParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.listLocalFiles(params);
return this.toBuiltinResult(result);
};
readLocalFile = async (
params: ReadLocalFileParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.readLocalFile(params);
return this.toBuiltinResult(result);
};
writeLocalFile = async (
params: WriteLocalFileParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.writeLocalFile(params);
return this.toBuiltinResult(result);
};
editLocalFile = async (
params: EditLocalFileParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.editLocalFile(params);
return this.toBuiltinResult(result);
};
searchLocalFiles = async (
params: SearchLocalFilesParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.searchLocalFiles(params);
return this.toBuiltinResult(result);
};
moveLocalFiles = async (
params: MoveLocalFilesParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.moveLocalFiles(params);
return this.toBuiltinResult(result);
};
renameLocalFile = async (
params: RenameLocalFileParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.renameLocalFile(params);
return this.toBuiltinResult(result);
};
// ==================== Code Execution ====================
executeCode = async (
params: ExecuteCodeParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.executeCode(params);
return this.toBuiltinResult(result);
};
// ==================== Shell Commands ====================
runCommand = async (
params: RunCommandParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.runCommand(params);
return this.toBuiltinResult(result);
};
getCommandOutput = async (
params: GetCommandOutputParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.getCommandOutput(params);
return this.toBuiltinResult(result);
};
killCommand = async (
params: KillCommandParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.killCommand(params);
return this.toBuiltinResult(result);
};
// ==================== Search & Find ====================
grepContent = async (
params: GrepContentParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.grepContent(params);
return this.toBuiltinResult(result);
};
globLocalFiles = async (
params: GlobLocalFilesParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.globLocalFiles(params);
return this.toBuiltinResult(result);
};
// ==================== Export Operations ====================
exportFile = async (
params: ExportFileParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.exportFile(params);
return this.toBuiltinResult(result);
};
// ==================== Helper Methods ====================
/**
* Convert BuiltinServerRuntimeOutput to BuiltinToolResult
*/
private toBuiltinResult(output: {
content: string;
error?: any;
state?: any;
success: boolean;
}): BuiltinToolResult {
if (!output.success) {
return {
content: output.content,
error: {
body: output.error,
message: output.content || 'Unknown error',
type: 'PluginServerError',
},
state: output.state,
success: false,
};
}
return {
content: output.content,
state: output.state,
success: true,
};
}
}
// Export the executor instance for registration
export const cloudSandboxExecutor = new CloudSandboxExecutor();
@@ -1,3 +1,4 @@
export * from './ExecutionRuntime';
export * from './manifest';
export * from './systemRole';
export * from './types';
@@ -0,0 +1,22 @@
/**
* API names for Cloud Sandbox tool
*/
export const CloudSandboxApiName = {
editLocalFile: 'editLocalFile',
executeCode: 'executeCode',
exportFile: 'exportFile',
getCommandOutput: 'getCommandOutput',
globLocalFiles: 'globLocalFiles',
grepContent: 'grepContent',
killCommand: 'killCommand',
listLocalFiles: 'listLocalFiles',
moveLocalFiles: 'moveLocalFiles',
readLocalFile: 'readLocalFile',
renameLocalFile: 'renameLocalFile',
runCommand: 'runCommand',
searchLocalFiles: 'searchLocalFiles',
writeLocalFile: 'writeLocalFile',
} as const;
export type CloudSandboxApiNameType =
(typeof CloudSandboxApiName)[keyof typeof CloudSandboxApiName];
@@ -0,0 +1,4 @@
export * from './api';
export * from './params';
export * from './service';
export * from './state';
@@ -0,0 +1,85 @@
// ==================== File Operations Params ====================
export interface ListLocalFilesParams {
directoryPath: string;
}
export interface ReadLocalFileParams {
endLine?: number;
path: string;
startLine?: number;
}
export interface WriteLocalFileParams {
content: string;
createDirectories?: boolean;
path: string;
}
export interface EditLocalFileParams {
all?: boolean;
path: string;
replace: string;
search: string;
}
export interface SearchLocalFilesParams {
directory: string;
fileType?: string;
keyword?: string;
modifiedAfter?: string;
modifiedBefore?: string;
}
export interface MoveLocalFilesParams {
operations: Array<{
destination: string;
source: string;
}>;
}
export interface RenameLocalFileParams {
newName: string;
oldPath: string;
}
export interface GlobLocalFilesParams {
directory?: string;
pattern: string;
}
export interface ExportFileParams {
path: string;
}
// ==================== Code Execution Params ====================
export interface ExecuteCodeParams {
code: string;
language?: 'javascript' | 'python' | 'typescript';
}
// ==================== Shell Command Params ====================
export interface RunCommandParams {
background?: boolean;
command: string;
timeout?: number;
}
export interface GetCommandOutputParams {
commandId: string;
}
export interface KillCommandParams {
commandId: string;
}
// ==================== Search & Find Params ====================
export interface GrepContentParams {
directory: string;
filePattern?: string;
pattern: string;
recursive?: boolean;
}
@@ -0,0 +1,48 @@
// ==================== Sandbox Service Interface ====================
/**
* Result of calling a sandbox tool
*/
export interface SandboxCallToolResult {
error?: { message: string; name?: string };
result: any;
sessionExpiredAndRecreated?: boolean;
success: boolean;
}
/**
* Result of exporting and uploading a file from sandbox
*/
export interface SandboxExportFileResult {
error?: { message: string };
fileId?: string;
filename: string;
mimeType?: string;
size?: number;
success: boolean;
url?: string;
}
/**
* Sandbox Service Interface - for dependency injection
*
* Context (topicId, userId) is bound at service creation time, not passed per-call.
* This allows CloudSandboxExecutionRuntime to work on both client and server:
* - Client: Implemented via tRPC client (codeInterpreterService)
* - Server: Implemented via MarketSDK directly (ServerSandboxService)
*/
export interface ISandboxService {
/**
* Call a sandbox tool
* @param toolName - The name of the tool to call (e.g., 'runCommand', 'writeLocalFile')
* @param params - The parameters for the tool
*/
callTool(toolName: string, params: Record<string, any>): Promise<SandboxCallToolResult>;
/**
* Export a file from sandbox and upload to cloud storage
* @param path - The file path in the sandbox
* @param filename - The name of the file to export
*/
exportAndUploadFile(path: string, filename: string): Promise<SandboxExportFileResult>;
}
@@ -1,26 +1,3 @@
/**
* API names for Cloud Sandbox tool
*/
export const CloudSandboxApiName = {
editLocalFile: 'editLocalFile',
executeCode: 'executeCode',
exportFile: 'exportFile',
getCommandOutput: 'getCommandOutput',
globLocalFiles: 'globLocalFiles',
grepContent: 'grepContent',
killCommand: 'killCommand',
listLocalFiles: 'listLocalFiles',
moveLocalFiles: 'moveLocalFiles',
readLocalFile: 'readLocalFile',
renameLocalFile: 'renameLocalFile',
runCommand: 'runCommand',
searchLocalFiles: 'searchLocalFiles',
writeLocalFile: 'writeLocalFile',
} as const;
export type CloudSandboxApiNameType =
(typeof CloudSandboxApiName)[keyof typeof CloudSandboxApiName];
// ==================== File Operations ====================
export interface ListLocalFilesState {
+8 -8
View File
@@ -278,16 +278,16 @@ export const MemoryManifest: BuiltinToolManifest = {
},
},
required: [
'action',
'keyLearning',
'knowledgeValueScore',
'labels',
'possibleOutcome',
'problemSolvingScore',
'reasoning',
'scoreConfidence',
'situation',
'reasoning',
'action',
'possibleOutcome',
'keyLearning',
'type',
'labels',
'problemSolvingScore',
'scoreConfidence',
'knowledgeValueScore',
],
type: 'object',
},
@@ -3,5 +3,4 @@ export * from './context';
export * from './experience';
export * from './gatekeeper';
export * from './identity';
export * from './jsonSchemas';
export * from './preference';
@@ -1,37 +0,0 @@
import { searchMemorySchema } from '@lobechat/types';
import type { PluginSchema } from '@lobehub/chat-plugin-sdk';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { ContextMemoryItemSchema } from './context';
import { ExperienceMemoryItemSchema } from './experience';
import {
AddIdentityActionSchema,
RemoveIdentityActionSchema,
UpdateIdentityActionSchema,
} from './identity';
import { PreferenceMemoryItemSchema } from './preference';
// Pre-compute JSON schemas to avoid runtime zod-to-json-schema type issues
// These are used by the builtin-tool-memory manifest
export const searchMemoryJsonSchema = zodToJsonSchema(
searchMemorySchema,
) as unknown as PluginSchema;
export const contextMemoryJsonSchema = zodToJsonSchema(
ContextMemoryItemSchema,
) as unknown as PluginSchema;
export const experienceMemoryJsonSchema = zodToJsonSchema(
ExperienceMemoryItemSchema,
) as unknown as PluginSchema;
export const preferenceMemoryJsonSchema = zodToJsonSchema(
PreferenceMemoryItemSchema,
) as unknown as PluginSchema;
export const addIdentityJsonSchema = zodToJsonSchema(
AddIdentityActionSchema,
) as unknown as PluginSchema;
export const updateIdentityJsonSchema = zodToJsonSchema(
UpdateIdentityActionSchema,
) as unknown as PluginSchema;
export const removeIdentityJsonSchema = zodToJsonSchema(
RemoveIdentityActionSchema,
) as unknown as PluginSchema;
+4 -2
View File
@@ -138,13 +138,15 @@ export interface UserInterventionConfig {
* - auto-run: Automatically approve all tools without user consent
* - allow-list: Only approve tools in the allow list
* - manual: Use tool's own humanIntervention config (default)
* - headless: Fully automated mode for async tasks - all tools execute automatically,
* security blacklist tools are skipped (not blocked)
*/
approvalMode: 'auto-run' | 'allow-list' | 'manual';
approvalMode: 'auto-run' | 'allow-list' | 'manual' | 'headless';
}
export const UserInterventionConfigSchema = z.object({
allowList: z.array(z.string()).optional(),
approvalMode: z.enum(['auto-run', 'allow-list', 'manual']),
approvalMode: z.enum(['auto-run', 'allow-list', 'manual', 'headless']),
});
/**
@@ -128,8 +128,6 @@ export const AssistantActionsBar = memo<AssistantActionsBarProps>(
defaultActions.edit,
defaultActions.copy,
collapseAction,
defaultActions.divider,
defaultActions.divider,
defaultActions.share,
defaultActions.divider,
@@ -43,7 +43,7 @@ const TaskMessage = memo<TaskMessageProps>(({ id, index, disableEditing, isLates
const editing = useConversationStore(messageStateSelectors.isMessageEditing(id));
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
const creating = useConversationStore(messageStateSelectors.isMessageCreating(id));
const newScreen = useNewScreen({ creating, isLatestItem });
const newScreen = useNewScreen({ creating: generating || creating, isLatestItem });
const errorContent = useErrorContent(error);
@@ -30,7 +30,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
align-items: center;
padding-block: 8px;
padding-inline: 16px;
`,
footer: css`
padding-block-start: 8px;
+2 -5
View File
@@ -31,14 +31,11 @@ export const generateTrustedClientToken = (userInfo: TrustedClientUserInfo): str
return undefined;
}
if (!userInfo.email) {
return undefined;
}
try {
const payload = buildTrustedClientPayload({
clientId: MARKET_TRUSTED_CLIENT_ID,
email: userInfo.email,
// TODO: remove '' when sdk update
email: userInfo.email || '',
name: userInfo.name,
userId: userInfo.userId,
});
@@ -36,6 +36,7 @@ export interface RuntimeExecutorContext {
stepIndex: number;
streamManager: IStreamEventManager;
toolExecutionService: ToolExecutionService;
topicId?: string;
userId?: string;
}
@@ -475,6 +476,7 @@ export const createRuntimeExecutors = (
const executionResult = await toolExecutionService.executeTool(chatToolPayload, {
serverDB: ctx.serverDB,
toolManifestMap: state.toolManifestMap,
topicId: ctx.topicId,
userId: ctx.userId,
});
+7 -9
View File
@@ -549,15 +549,13 @@ export const marketRouter = router({
const uploadUrl = await s3.createPreSignedUrl(key);
log('Generated upload URL for key: %s', key);
// Step 2: Use MarketService from ctx
const market = ctx.marketService.market;
// Step 3: Call sandbox's exportFile tool with the upload URL
const response = await market.plugins.runBuildInTool(
'exportFile',
{ path, uploadUrl },
{ topicId, userId: ctx.userId },
);
// Step 2: Call sandbox's exportFile tool with the upload URL
const response = await ctx.marketService.exportFile({
path,
topicId,
uploadUrl,
userId: ctx.userId,
});
log('Sandbox exportFile response: %O', response);
@@ -233,6 +233,7 @@ export class AgentRuntimeService {
toolManifestMap,
toolSourceMap,
stepCallbacks,
userInterventionConfig,
} = params;
try {
@@ -261,6 +262,8 @@ export class AgentRuntimeService {
toolManifestMap,
toolSourceMap,
tools,
// User intervention config for headless mode in async tasks
userInterventionConfig,
} as Partial<AgentState>;
// Use coordinator to create operation, automatically sends initialization event
@@ -353,8 +356,10 @@ export class AgentRuntimeService {
}
// Create Agent and Runtime instances
// Use agentState.metadata which contains the full app context (topicId, agentId, etc.)
// operationMetadata only contains basic fields (agentConfig, modelRuntimeConfig, userId)
const { runtime } = await this.createAgentRuntime({
metadata: operationMetadata,
metadata: agentState?.metadata,
operationId,
stepIndex,
});
@@ -850,6 +855,7 @@ export class AgentRuntimeService {
stepIndex,
streamManager: this.streamManager,
toolExecutionService: this.toolExecutionService,
topicId: metadata?.topicId,
userId: metadata?.userId,
};
@@ -1,5 +1,6 @@
import { type AgentRuntimeContext, type AgentState } from '@lobechat/agent-runtime';
import { type LobeToolManifest } from '@lobechat/context-engine';
import type { UserInterventionConfig } from '@lobechat/types';
// ==================== Step Lifecycle Callbacks ====================
@@ -90,6 +91,12 @@ export interface OperationCreationParams {
toolSourceMap?: Record<string, 'builtin' | 'plugin' | 'mcp' | 'klavis' | 'lobehubSkill'>;
tools?: any[];
userId?: string;
/**
* User intervention configuration
* Controls how tools requiring approval are handled
* Use { approvalMode: 'headless' } for async tasks that should never wait for human approval
*/
userInterventionConfig?: UserInterventionConfig;
}
export interface OperationCreationResult {
@@ -185,6 +185,9 @@ describe('AiAgentService.execSubAgentTask', () => {
onAfterStep: expect.any(Function),
onComplete: expect.any(Function),
}),
userInterventionConfig: {
approvalMode: 'headless',
},
});
});
+10 -4
View File
@@ -8,6 +8,7 @@ import type {
ExecGroupAgentResult,
ExecSubAgentTaskParams,
ExecSubAgentTaskResult,
UserInterventionConfig,
} from '@lobechat/types';
import { ThreadStatus, ThreadType } from '@lobechat/types';
import { nanoid } from '@lobechat/utils';
@@ -66,6 +67,11 @@ interface InternalExecAgentParams extends ExecAgentParams {
stepCallbacks?: StepLifecycleCallbacks;
/** Topic creation trigger source ('cron' | 'chat' | 'api') */
trigger?: string;
/**
* User intervention configuration
* Use { approvalMode: 'headless' } for async tasks that should never wait for human approval
*/
userInterventionConfig?: UserInterventionConfig;
}
/**
@@ -125,6 +131,7 @@ export class AiAgentService {
stepCallbacks,
trigger,
cronJobId,
userInterventionConfig,
} = params;
// Validate that either agentId or slug is provided
@@ -231,10 +238,6 @@ export class AiAgentService {
const tools = toolsResult.tools;
// Log detailed tools generation result
if (toolsResult.filteredTools && toolsResult.filteredTools.length > 0) {
log('execAgent: filtered tools: %O', toolsResult.filteredTools);
}
log('execAgent: enabled tool ids: %O', toolsResult.enabledToolIds);
// Get manifest map and convert from Map to Record
@@ -396,6 +399,7 @@ export class AiAgentService {
toolSourceMap,
tools,
userId: this.userId,
userInterventionConfig,
});
log('execAgent: created operation %s (autoStarted: %s)', operationId, result.autoStarted);
@@ -572,12 +576,14 @@ export class AiAgentService {
// 4. Delegate to execAgent with threadId in appContext and callbacks
// The instruction will be created as user message in the Thread
// Use headless mode to skip human approval in async task execution
const result = await this.execAgent({
agentId,
appContext: { groupId, threadId: thread.id, topicId },
autoStart: true,
prompt: instruction,
stepCallbacks,
userInterventionConfig: { approvalMode: 'headless' },
});
log(
+13
View File
@@ -280,6 +280,19 @@ export class MarketService {
return this.market.plugins.callCloudGateway(params, options);
}
/**
* Export file from sandbox to upload URL
*/
async exportFile(params: { path: string; topicId: string; uploadUrl: string; userId: string }) {
const { path, uploadUrl, topicId, userId } = params;
return this.market.plugins.runBuildInTool(
'exportFile',
{ path, uploadUrl },
{ topicId, userId },
);
}
/**
* Get plugin manifest
*/
+186
View File
@@ -0,0 +1,186 @@
import {
type ISandboxService,
type SandboxCallToolResult,
type SandboxExportFileResult,
} from '@lobechat/builtin-tool-cloud-sandbox';
import { type CodeInterpreterToolName } from '@lobehub/market-sdk';
import debug from 'debug';
import { sha256 } from 'js-sha256';
import { FileS3 } from '@/server/modules/S3';
import { type FileService } from '@/server/services/file';
import { MarketService } from '@/server/services/market';
const log = debug('lobe-server:sandbox-service');
export interface ServerSandboxServiceOptions {
fileService: FileService;
marketService: MarketService;
topicId: string;
userId: string;
}
/**
* Server-side Sandbox Service
*
* This service implements ISandboxService for server-side execution.
* Context (topicId, userId) is bound at construction time.
* It uses MarketService to call sandbox tools.
*
* Usage:
* - Used by BuiltinToolsExecutor when executing CloudSandbox tools on server
* - MarketService handles authentication via trustedClientToken
*/
export class ServerSandboxService implements ISandboxService {
private fileService: FileService;
private marketService: MarketService;
private topicId: string;
private userId: string;
constructor(options: ServerSandboxServiceOptions) {
this.fileService = options.fileService;
this.marketService = options.marketService;
this.topicId = options.topicId;
this.userId = options.userId;
}
/**
* Call a sandbox tool via MarketService
*/
async callTool(toolName: string, params: Record<string, any>): Promise<SandboxCallToolResult> {
log('Calling sandbox tool: %s with params: %O, topicId: %s', toolName, params, this.topicId);
try {
const response = await this.marketService
.getSDK()
.plugins.runBuildInTool(toolName as CodeInterpreterToolName, params as any, {
topicId: this.topicId,
userId: this.userId,
});
log('Sandbox tool %s response: %O', toolName, response);
if (!response.success) {
return {
error: {
message: response.error?.message || 'Unknown error',
name: response.error?.code,
},
result: null,
sessionExpiredAndRecreated: false,
success: false,
};
}
return {
result: response.data?.result,
sessionExpiredAndRecreated: response.data?.sessionExpiredAndRecreated || false,
success: true,
};
} catch (error) {
log('Error calling sandbox tool %s: %O', toolName, error);
return {
error: {
message: (error as Error).message,
name: (error as Error).name,
},
result: null,
sessionExpiredAndRecreated: false,
success: false,
};
}
}
/**
* Export and upload a file from sandbox to S3
*
* Steps:
* 1. Generate S3 pre-signed upload URL
* 2. Call sandbox exportFile tool to upload file
* 3. Verify upload success and get metadata
* 4. Create persistent file record
*/
async exportAndUploadFile(path: string, filename: string): Promise<SandboxExportFileResult> {
log('Exporting file: %s from path: %s, topicId: %s', filename, path, this.topicId);
try {
const s3 = new FileS3();
// Use date-based sharding for privacy compliance (GDPR, CCPA)
const today = new Date().toISOString().split('T')[0];
// Generate a unique key for the exported file
const key = `code-interpreter-exports/${today}/${this.topicId}/${filename}`;
// Step 1: Generate pre-signed upload URL
const uploadUrl = await s3.createPreSignedUrl(key);
log('Generated upload URL for key: %s', key);
// Step 2: Call sandbox's exportFile tool with the upload URL
const response = await this.marketService.exportFile({
path,
topicId: this.topicId,
uploadUrl,
userId: this.userId,
});
log('Sandbox exportFile response: %O', response);
if (!response.success) {
return {
error: { message: response.error?.message || 'Failed to export file from sandbox' },
filename,
success: false,
};
}
const result = response.data?.result;
const uploadSuccess = result?.success !== false;
if (!uploadSuccess) {
return {
error: { message: result?.error || 'Failed to upload file from sandbox' },
filename,
success: false,
};
}
// Step 3: Get file metadata from S3 to verify upload and get actual size
const metadata = await s3.getFileMetadata(key);
const fileSize = metadata.contentLength;
const mimeType = metadata.contentType || result?.mimeType || 'application/octet-stream';
// Step 4: Create persistent file record using FileService
// Generate a simple hash from the key (since we don't have the actual file content)
const fileHash = sha256(key + Date.now().toString());
const { fileId, url } = await this.fileService.createFileRecord({
fileHash,
fileType: mimeType,
name: filename,
size: fileSize,
url: key, // Store S3 key
});
log('Created file record: fileId=%s, url=%s', fileId, url);
return {
fileId,
filename,
mimeType,
size: fileSize,
success: true,
url, // This is the permanent /f/:id URL
};
} catch (error) {
log('Error exporting file: %O', error);
return {
error: { message: (error as Error).message },
filename,
success: false,
};
}
}
}
+12 -18
View File
@@ -1,28 +1,26 @@
import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing';
import { WebBrowsingExecutionRuntime } from '@lobechat/builtin-tool-web-browsing/executionRuntime';
import { type LobeChatDatabase } from '@lobechat/database';
import { type ChatToolPayload } from '@lobechat/types';
import { safeParseJSON } from '@lobechat/utils';
import debug from 'debug';
import { MarketService } from '@/server/services/market';
import { SearchService } from '@/server/services/search';
import { type IToolExecutor, type ToolExecutionResult } from './types';
import { getServerRuntime, hasServerRuntime } from './serverRuntimes';
import { type IToolExecutor, type ToolExecutionContext, type ToolExecutionResult } from './types';
const log = debug('lobe-server:builtin-tools-executor');
const BuiltinToolServerRuntimes: Record<string, any> = {
[WebBrowsingManifest.identifier]: WebBrowsingExecutionRuntime,
};
export class BuiltinToolsExecutor implements IToolExecutor {
private marketService: MarketService;
constructor(db: LobeChatDatabase, userId: string) {
this.marketService = new MarketService({ userInfo: { userId } });
}
async execute(payload: ChatToolPayload): Promise<ToolExecutionResult> {
async execute(
payload: ChatToolPayload,
context: ToolExecutionContext,
): Promise<ToolExecutionResult> {
const { identifier, apiName, arguments: argsStr, source } = payload;
const args = safeParseJSON(argsStr) || {};
@@ -43,19 +41,15 @@ export class BuiltinToolsExecutor implements IToolExecutor {
});
}
// Default: original builtin runtime logic
const ServerRuntime = BuiltinToolServerRuntimes[identifier];
if (!ServerRuntime) {
// Use server runtime registry (handles both pre-instantiated and per-request runtimes)
if (!hasServerRuntime(identifier)) {
throw new Error(`Builtin tool "${identifier}" is not implemented`);
}
const runtime = new ServerRuntime({
searchService: new SearchService(),
});
const runtime = getServerRuntime(identifier, context);
if (!runtime[apiName]) {
throw new Error(`Builtin tool ${identifier} 's ${apiName} is not implemented`);
throw new Error(`Builtin tool ${identifier}'s ${apiName} is not implemented`);
}
try {
@@ -64,7 +58,7 @@ export class BuiltinToolsExecutor implements IToolExecutor {
const error = e as Error;
console.error('Error executing builtin tool %s:%s: %O', identifier, apiName, error);
return { content: error.message, error: error, success: false };
return { content: error.message, error, success: false };
}
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ export class ToolExecutionService {
let data: ToolExecutionResult;
switch (typeStr) {
case 'builtin': {
data = await this.builtinToolsExecutor.execute(payload);
data = await this.builtinToolsExecutor.execute(payload, context);
break;
}
@@ -0,0 +1,38 @@
import {
CloudSandboxExecutionRuntime,
CloudSandboxIdentifier,
} from '@lobechat/builtin-tool-cloud-sandbox';
import { FileService } from '@/server/services/file';
import { MarketService } from '@/server/services/market';
import { ServerSandboxService } from '@/server/services/sandbox';
import { type ServerRuntimeRegistration } from './types';
/**
* CloudSandbox Server Runtime
* Per-request runtime (needs topicId, userId)
*/
export const cloudSandboxRuntime: ServerRuntimeRegistration = {
factory: (context) => {
if (!context.userId || !context.topicId) {
throw new Error('userId and topicId are required for Cloud Sandbox execution');
}
if (!context.serverDB) {
throw new Error('serverDB is required for Cloud Sandbox execution');
}
const marketService = new MarketService({ userInfo: { userId: context.userId } });
const fileService = new FileService(context.serverDB, context.userId);
const sandboxService = new ServerSandboxService({
fileService,
marketService,
topicId: context.topicId,
userId: context.userId,
});
return new CloudSandboxExecutionRuntime(sandboxService);
},
identifier: CloudSandboxIdentifier,
};
@@ -0,0 +1,55 @@
/**
* Server Runtime Registry
*
* Central registry for all builtin tool server runtimes.
* Uses factory functions to support both:
* - Pre-instantiated runtimes (e.g., WebBrowsing - no per-request context needed)
* - Per-request runtimes (e.g., CloudSandbox - needs topicId, userId)
*/
import { type ToolExecutionContext } from '../types';
import { cloudSandboxRuntime } from './cloudSandbox';
import { type ServerRuntimeFactory, type ServerRuntimeRegistration } from './types';
import { webBrowsingRuntime } from './webBrowsing';
/**
* Registry of server runtime factories by identifier
*/
const serverRuntimeFactories = new Map<string, ServerRuntimeFactory>();
/**
* Register server runtimes
*/
const registerRuntimes = (runtimes: ServerRuntimeRegistration[]) => {
for (const runtime of runtimes) {
serverRuntimeFactories.set(runtime.identifier, runtime.factory);
}
};
// Register all server runtimes
registerRuntimes([webBrowsingRuntime, cloudSandboxRuntime]);
// ==================== Registry API ====================
/**
* Get a server runtime by identifier
* @param identifier - The tool identifier
* @param context - Execution context (required for per-request runtimes)
*/
export const getServerRuntime = (identifier: string, context: ToolExecutionContext): any => {
const factory = serverRuntimeFactories.get(identifier);
return factory?.(context);
};
/**
* Check if a server runtime exists for the given identifier
*/
export const hasServerRuntime = (identifier: string): boolean => {
return serverRuntimeFactories.has(identifier);
};
/**
* Get all registered server runtime identifiers
*/
export const getServerRuntimeIdentifiers = (): string[] => {
return Array.from(serverRuntimeFactories.keys());
};
@@ -0,0 +1,14 @@
import { type ToolExecutionContext } from '../types';
/**
* Factory function type for creating server runtimes
*/
export type ServerRuntimeFactory = (context: ToolExecutionContext) => any;
/**
* Server runtime registration object
*/
export interface ServerRuntimeRegistration {
factory: ServerRuntimeFactory;
identifier: string;
}
@@ -0,0 +1,20 @@
import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing';
import { WebBrowsingExecutionRuntime } from '@lobechat/builtin-tool-web-browsing/executionRuntime';
import { SearchService } from '@/server/services/search';
import { type ServerRuntimeRegistration } from './types';
// Pre-instantiated (no per-request context needed)
const runtime = new WebBrowsingExecutionRuntime({
searchService: new SearchService(),
});
/**
* WebBrowsing Server Runtime
* Pre-instantiated runtime (no per-request context needed)
*/
export const webBrowsingRuntime: ServerRuntimeRegistration = {
factory: () => runtime,
identifier: WebBrowsingManifest.identifier,
};
@@ -6,6 +6,8 @@ export interface ToolExecutionContext {
/** Server database for LobeHub Skills execution */
serverDB?: LobeChatDatabase;
toolManifestMap: Record<string, LobeToolManifest>;
/** Topic ID for sandbox session management */
topicId?: string;
userId?: string;
}
@@ -6,9 +6,9 @@ import type {
ExportAndUploadFileResult,
} from '@/server/routers/tools/market';
class CodeInterpreterService {
class CloudSandboxService {
/**
* Call a cloud code interpreter tool
* Call a cloud sandbox tool
* @param toolName - The name of the tool to call (e.g., 'runCommand', 'writeLocalFile')
* @param params - The parameters for the tool
* @param context - Session context containing userId and topicId for isolation
@@ -51,4 +51,4 @@ class CodeInterpreterService {
}
}
export const codeInterpreterService = new CodeInterpreterService();
export const cloudSandboxService = new CloudSandboxService();
@@ -0,0 +1,626 @@
import type { AgentState } from '@lobechat/agent-runtime';
import { ThreadStatus } from '@lobechat/types';
import { nanoid } from '@lobechat/utils';
import { describe, expect, it, vi } from 'vitest';
import { aiAgentService } from '@/services/aiAgent';
import type { ChatStore } from '@/store/chat/store';
import { createGroupOrchestrationExecutors } from '../createGroupOrchestrationExecutors';
vi.mock('@/services/aiAgent', () => ({
aiAgentService: {
execSubAgentTask: vi.fn(),
getSubAgentTaskStatus: vi.fn(),
},
}));
/**
* Helper to create a mock ExecSubAgentTaskResult
*/
const createMockExecResult = (overrides: Record<string, any> = {}) => ({
assistantMessageId: `assistant_${nanoid()}`,
operationId: `op_${nanoid()}`,
success: true,
threadId: `thread_${nanoid()}`,
...overrides,
});
const TEST_IDS = {
AGENT_1_ID: 'test-agent-1-id',
AGENT_2_ID: 'test-agent-2-id',
GROUP_ID: 'test-group-id',
OPERATION_ID: 'test-operation-id',
ORCHESTRATION_OPERATION_ID: 'test-orchestration-operation-id',
SUPERVISOR_AGENT_ID: 'test-supervisor-agent-id',
TOOL_MESSAGE_ID: 'test-tool-message-id',
TOPIC_ID: 'test-topic-id',
};
/**
* Create a minimal mock store for group orchestration executor tests
*/
const createMockStore = (overrides: Partial<ChatStore> = {}): ChatStore => {
const operations: Record<string, any> = {
[TEST_IDS.OPERATION_ID]: {
abortController: new AbortController(),
context: {},
id: TEST_IDS.OPERATION_ID,
status: 'running',
type: 'agent',
},
};
return {
dbMessagesMap: {},
internal_dispatchMessage: vi.fn(),
internal_execAgentRuntime: vi.fn().mockResolvedValue(undefined),
messagesMap: {},
operations,
optimisticCreateMessage: vi.fn().mockImplementation(async () => ({
id: `msg_${nanoid()}`,
messages: [],
})),
optimisticUpdateMessageContent: vi.fn().mockResolvedValue(undefined),
startOperation: vi.fn().mockImplementation((config) => {
const operationId = `op_${nanoid()}`;
const abortController = new AbortController();
operations[operationId] = {
abortController,
context: config.context || {},
id: operationId,
status: 'running',
type: config.type,
};
return { abortController, operationId };
}),
...overrides,
} as unknown as ChatStore;
};
/**
* Create initial agent state for testing
*/
const createInitialState = (overrides: Partial<AgentState> = {}): AgentState => {
return {
cost: {
calculatedAt: new Date().toISOString(),
currency: 'USD',
llm: { byModel: [], currency: 'USD', total: 0 },
tools: { byTool: [], currency: 'USD', total: 0 },
total: 0,
},
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
maxSteps: 10,
messages: [],
operationId: TEST_IDS.OPERATION_ID,
status: 'running',
stepCount: 0,
toolManifestMap: {},
usage: {
humanInteraction: {
approvalRequests: 0,
promptRequests: 0,
selectRequests: 0,
totalWaitingTimeMs: 0,
},
llm: { apiCalls: 0, processingTimeMs: 0, tokens: { input: 0, output: 0, total: 0 } },
tools: { byTool: [], totalCalls: 0, totalTimeMs: 0 },
},
userInterventionConfig: { allowList: [], approvalMode: 'auto' },
...overrides,
} as AgentState;
};
describe('createGroupOrchestrationExecutors', () => {
describe('batch_exec_async_tasks executor', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return error result when no valid context (missing groupId or topicId)', async () => {
const mockStore = createMockStore();
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
scope: 'group',
// Missing topicId
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const batchExecTasksExecutor = executors.batch_exec_async_tasks!;
const result = await batchExecTasksExecutor(
{
payload: {
tasks: [
{ agentId: TEST_IDS.AGENT_1_ID, task: 'Task 1', title: 'Task 1 Title' },
{ agentId: TEST_IDS.AGENT_2_ID, task: 'Task 2', title: 'Task 2 Title' },
],
toolMessageId: TEST_IDS.TOOL_MESSAGE_ID,
},
type: 'batch_exec_async_tasks',
},
createInitialState(),
);
expect(result.result?.type).toBe('tasks_completed');
expect((result.result?.payload as any).results).toHaveLength(2);
expect((result.result?.payload as any).results[0].success).toBe(false);
expect((result.result?.payload as any).results[0].error).toBe('No valid context available');
});
it('should create task messages for all tasks in parallel', async () => {
const mockStore = createMockStore();
// Mock execSubAgentTask to return success
vi.mocked(aiAgentService.execSubAgentTask).mockResolvedValue(
createMockExecResult({ threadId: 'thread-1' }),
);
// Mock getSubAgentTaskStatus to return completed immediately
vi.mocked(aiAgentService.getSubAgentTaskStatus).mockResolvedValue({
result: 'Task completed',
status: 'completed',
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
groupId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const batchExecTasksExecutor = executors.batch_exec_async_tasks!;
await batchExecTasksExecutor(
{
payload: {
tasks: [
{ agentId: TEST_IDS.AGENT_1_ID, task: 'Task 1', title: 'Task 1 Title' },
{ agentId: TEST_IDS.AGENT_2_ID, task: 'Task 2', title: 'Task 2 Title' },
],
toolMessageId: TEST_IDS.TOOL_MESSAGE_ID,
},
type: 'batch_exec_async_tasks',
},
createInitialState(),
);
// Should create 2 task messages
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledTimes(2);
// Verify first task message creation
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
agentId: TEST_IDS.AGENT_1_ID,
groupId: TEST_IDS.GROUP_ID,
metadata: { instruction: 'Task 1', taskTitle: 'Task 1 Title' },
parentId: TEST_IDS.TOOL_MESSAGE_ID,
role: 'task',
topicId: TEST_IDS.TOPIC_ID,
}),
expect.objectContaining({ operationId: TEST_IDS.OPERATION_ID }),
);
// Verify second task message creation
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
agentId: TEST_IDS.AGENT_2_ID,
groupId: TEST_IDS.GROUP_ID,
metadata: { instruction: 'Task 2', taskTitle: 'Task 2 Title' },
parentId: TEST_IDS.TOOL_MESSAGE_ID,
role: 'task',
topicId: TEST_IDS.TOPIC_ID,
}),
expect.objectContaining({ operationId: TEST_IDS.OPERATION_ID }),
);
});
it('should call execSubAgentTask for each task', async () => {
const mockStore = createMockStore();
let messageIdCounter = 0;
vi.mocked(mockStore.optimisticCreateMessage).mockImplementation(async () => ({
id: `msg_${++messageIdCounter}`,
messages: [],
}));
vi.mocked(aiAgentService.execSubAgentTask).mockResolvedValue(
createMockExecResult({ threadId: 'thread-1' }),
);
vi.mocked(aiAgentService.getSubAgentTaskStatus).mockResolvedValue({
result: 'Task completed',
status: 'completed',
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
groupId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const batchExecTasksExecutor = executors.batch_exec_async_tasks!;
await batchExecTasksExecutor(
{
payload: {
tasks: [
{ agentId: TEST_IDS.AGENT_1_ID, task: 'Task 1', title: 'Task 1 Title' },
{ agentId: TEST_IDS.AGENT_2_ID, task: 'Task 2', title: 'Task 2 Title' },
],
toolMessageId: TEST_IDS.TOOL_MESSAGE_ID,
},
type: 'batch_exec_async_tasks',
},
createInitialState(),
);
// Should call execSubAgentTask for both tasks
expect(aiAgentService.execSubAgentTask).toHaveBeenCalledTimes(2);
expect(aiAgentService.execSubAgentTask).toHaveBeenCalledWith(
expect.objectContaining({
agentId: TEST_IDS.AGENT_1_ID,
groupId: TEST_IDS.GROUP_ID,
instruction: 'Task 1',
title: 'Task 1 Title',
topicId: TEST_IDS.TOPIC_ID,
}),
);
expect(aiAgentService.execSubAgentTask).toHaveBeenCalledWith(
expect.objectContaining({
agentId: TEST_IDS.AGENT_2_ID,
groupId: TEST_IDS.GROUP_ID,
instruction: 'Task 2',
title: 'Task 2 Title',
topicId: TEST_IDS.TOPIC_ID,
}),
);
});
it('should return tasks_completed result with all task results', async () => {
const mockStore = createMockStore();
vi.mocked(aiAgentService.execSubAgentTask).mockResolvedValue(
createMockExecResult({ threadId: 'thread-1' }),
);
vi.mocked(aiAgentService.getSubAgentTaskStatus).mockResolvedValue({
result: 'Task completed successfully',
status: 'completed',
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
groupId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const batchExecTasksExecutor = executors.batch_exec_async_tasks!;
const result = await batchExecTasksExecutor(
{
payload: {
tasks: [
{ agentId: TEST_IDS.AGENT_1_ID, task: 'Task 1', title: 'Task 1 Title' },
{ agentId: TEST_IDS.AGENT_2_ID, task: 'Task 2', title: 'Task 2 Title' },
],
toolMessageId: TEST_IDS.TOOL_MESSAGE_ID,
},
type: 'batch_exec_async_tasks',
},
createInitialState(),
);
expect(result.result?.type).toBe('tasks_completed');
expect((result.result?.payload as any).results).toHaveLength(2);
expect((result.result?.payload as any).results[0]).toMatchObject({
agentId: TEST_IDS.AGENT_1_ID,
result: 'Task completed successfully',
success: true,
});
expect((result.result?.payload as any).results[1]).toMatchObject({
agentId: TEST_IDS.AGENT_2_ID,
result: 'Task completed successfully',
success: true,
});
});
it('should handle task creation failure', async () => {
const mockStore = createMockStore();
// First task message creation fails
vi.mocked(mockStore.optimisticCreateMessage)
.mockResolvedValueOnce(undefined) // First task fails
.mockResolvedValueOnce({ id: 'msg_2', messages: [] }); // Second task succeeds
vi.mocked(aiAgentService.execSubAgentTask).mockResolvedValue(
createMockExecResult({ threadId: 'thread-2' }),
);
vi.mocked(aiAgentService.getSubAgentTaskStatus).mockResolvedValue({
result: 'Task completed',
status: 'completed',
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
groupId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const batchExecTasksExecutor = executors.batch_exec_async_tasks!;
const result = await batchExecTasksExecutor(
{
payload: {
tasks: [
{ agentId: TEST_IDS.AGENT_1_ID, task: 'Task 1', title: 'Task 1 Title' },
{ agentId: TEST_IDS.AGENT_2_ID, task: 'Task 2', title: 'Task 2 Title' },
],
toolMessageId: TEST_IDS.TOOL_MESSAGE_ID,
},
type: 'batch_exec_async_tasks',
},
createInitialState(),
);
expect(result.result?.type).toBe('tasks_completed');
// First task should fail due to message creation failure
expect((result.result?.payload as any).results[0]).toMatchObject({
agentId: TEST_IDS.AGENT_1_ID,
error: 'Failed to create task message',
success: false,
});
// Second task should succeed
expect((result.result?.payload as any).results[1]).toMatchObject({
agentId: TEST_IDS.AGENT_2_ID,
success: true,
});
});
it('should handle execSubAgentTask failure', async () => {
const mockStore = createMockStore();
vi.mocked(aiAgentService.execSubAgentTask)
.mockResolvedValueOnce(
createMockExecResult({
error: 'Backend error',
success: false,
}),
)
.mockResolvedValueOnce(createMockExecResult({ threadId: 'thread-2' }));
vi.mocked(aiAgentService.getSubAgentTaskStatus).mockResolvedValue({
result: 'Task completed',
status: 'completed',
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
groupId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const batchExecTasksExecutor = executors.batch_exec_async_tasks!;
const result = await batchExecTasksExecutor(
{
payload: {
tasks: [
{ agentId: TEST_IDS.AGENT_1_ID, task: 'Task 1', title: 'Task 1 Title' },
{ agentId: TEST_IDS.AGENT_2_ID, task: 'Task 2', title: 'Task 2 Title' },
],
toolMessageId: TEST_IDS.TOOL_MESSAGE_ID,
},
type: 'batch_exec_async_tasks',
},
createInitialState(),
);
expect(result.result?.type).toBe('tasks_completed');
// First task should fail
expect((result.result?.payload as any).results[0]).toMatchObject({
agentId: TEST_IDS.AGENT_1_ID,
error: 'Backend error',
success: false,
});
// Second task should succeed
expect((result.result?.payload as any).results[1]).toMatchObject({
agentId: TEST_IDS.AGENT_2_ID,
success: true,
});
});
it('should handle task failure status', async () => {
const mockStore = createMockStore();
vi.mocked(aiAgentService.execSubAgentTask).mockResolvedValue(
createMockExecResult({ threadId: 'thread-1' }),
);
vi.mocked(aiAgentService.getSubAgentTaskStatus).mockResolvedValue({
error: 'Task execution error',
status: 'failed',
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
groupId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const batchExecTasksExecutor = executors.batch_exec_async_tasks!;
const result = await batchExecTasksExecutor(
{
payload: {
tasks: [{ agentId: TEST_IDS.AGENT_1_ID, task: 'Task 1', title: 'Task 1 Title' }],
toolMessageId: TEST_IDS.TOOL_MESSAGE_ID,
},
type: 'batch_exec_async_tasks',
},
createInitialState(),
);
expect(result.result?.type).toBe('tasks_completed');
expect((result.result?.payload as any).results[0]).toMatchObject({
agentId: TEST_IDS.AGENT_1_ID,
error: 'Task execution error',
success: false,
});
});
it('should handle operation cancellation', async () => {
const mockStore = createMockStore();
// Set operation to cancelled
mockStore.operations[TEST_IDS.OPERATION_ID].status = 'cancelled';
vi.mocked(aiAgentService.execSubAgentTask).mockResolvedValue(
createMockExecResult({ threadId: 'thread-1' }),
);
// This should not be called since operation is cancelled
vi.mocked(aiAgentService.getSubAgentTaskStatus).mockResolvedValue({
status: 'processing',
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
groupId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const batchExecTasksExecutor = executors.batch_exec_async_tasks!;
const result = await batchExecTasksExecutor(
{
payload: {
tasks: [{ agentId: TEST_IDS.AGENT_1_ID, task: 'Task 1', title: 'Task 1 Title' }],
toolMessageId: TEST_IDS.TOOL_MESSAGE_ID,
},
type: 'batch_exec_async_tasks',
},
createInitialState(),
);
expect(result.newState.status).toBe('done');
expect((result.result?.payload as any).results[0]).toMatchObject({
agentId: TEST_IDS.AGENT_1_ID,
error: 'Operation cancelled',
success: false,
});
});
it('should dispatch taskDetail update when task status includes taskDetail', async () => {
const mockStore = createMockStore();
const messageId = 'msg_1';
vi.mocked(mockStore.optimisticCreateMessage).mockResolvedValue({
id: messageId,
messages: [],
});
vi.mocked(aiAgentService.execSubAgentTask).mockResolvedValue(
createMockExecResult({ threadId: 'thread-1' }),
);
// Return completed status with taskDetail in first poll
const taskDetail = {
status: ThreadStatus.Completed,
threadId: 'thread-1',
title: 'Done',
};
vi.mocked(aiAgentService.getSubAgentTaskStatus).mockResolvedValue({
result: 'Task completed',
status: 'completed',
taskDetail,
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
groupId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const batchExecTasksExecutor = executors.batch_exec_async_tasks!;
await batchExecTasksExecutor(
{
payload: {
tasks: [{ agentId: TEST_IDS.AGENT_1_ID, task: 'Task 1', title: 'Task 1 Title' }],
toolMessageId: TEST_IDS.TOOL_MESSAGE_ID,
},
type: 'batch_exec_async_tasks',
},
createInitialState(),
);
// Should dispatch message update with taskDetail
expect(mockStore.internal_dispatchMessage).toHaveBeenCalledWith(
expect.objectContaining({
id: messageId,
type: 'updateMessage',
value: { taskDetail },
}),
expect.objectContaining({ operationId: TEST_IDS.OPERATION_ID }),
);
});
});
});
@@ -3,6 +3,7 @@ import type {
GroupOrchestrationExecutor,
GroupOrchestrationExecutorOutput,
SupervisorInstruction,
SupervisorInstructionBatchExecAsyncTasks,
SupervisorInstructionCallAgent,
SupervisorInstructionCallSupervisor,
SupervisorInstructionDelegate,
@@ -589,5 +590,298 @@ export const createGroupOrchestrationExecutors = (
};
}
},
/**
* batch_exec_async_tasks Executor
* Executes multiple async tasks for agents in parallel using aiAgentService with polling
*
* Flow:
* 1. Create task messages (role: 'task') for each task as placeholders
* 2. Call execSubAgentTask API for each task in parallel
* 3. Poll for all tasks completion
* 4. Update task messages with results on completion
*
* Returns: tasks_completed result
*/
batch_exec_async_tasks: async (
instruction,
state,
): Promise<GroupOrchestrationExecutorOutput> => {
const { tasks, toolMessageId } = (instruction as SupervisorInstructionBatchExecAsyncTasks)
.payload;
const sessionLogId = `${state.operationId}:batch_exec_async_tasks`;
log(`[${sessionLogId}] Executing ${tasks.length} async tasks in parallel`);
const { groupId, topicId } = messageContext;
if (!groupId || !topicId) {
log(`[${sessionLogId}] No valid context, cannot execute async tasks`, groupId, topicId);
return {
events: [] as GroupOrchestrationEvent[],
newState: state,
result: {
payload: {
results: tasks.map((t) => ({
agentId: t.agentId,
error: 'No valid context available',
success: false,
})),
},
type: 'tasks_completed',
},
};
}
// Track all tasks with their messages and thread IDs
interface TaskTracker {
agentId: string;
error?: string;
result?: string;
status: 'pending' | 'running' | 'completed' | 'failed';
task: string;
taskMessageId?: string;
threadId?: string;
timeout: number;
title?: string;
}
const taskTrackers: TaskTracker[] = tasks.map((t) => ({
agentId: t.agentId,
status: 'pending',
task: t.task,
timeout: t.timeout || 1_800_000, // Default 30 minutes
title: t.title,
}));
// 1. Create task messages for all tasks in parallel
await Promise.all(
taskTrackers.map(async (tracker, index) => {
const taskLogId = `${sessionLogId}:task-${index}`;
try {
const taskMessageResult = await get().optimisticCreateMessage(
{
agentId: tracker.agentId,
content: '',
groupId,
metadata: { instruction: tracker.task, taskTitle: tracker.title },
parentId: toolMessageId,
role: 'task',
topicId,
},
{ operationId: state.operationId },
);
if (taskMessageResult) {
tracker.taskMessageId = taskMessageResult.id;
log(`[${taskLogId}] Created task message: ${tracker.taskMessageId}`);
} else {
tracker.status = 'failed';
tracker.error = 'Failed to create task message';
console.error(`[${taskLogId}] Failed to create task message`);
}
} catch (error) {
tracker.status = 'failed';
tracker.error = error instanceof Error ? error.message : 'Unknown error';
console.error(`[${taskLogId}] Error creating task message: ${error}`);
}
}),
);
// 2. Start all tasks in parallel via backend API
await Promise.all(
taskTrackers.map(async (tracker, index) => {
if (tracker.status === 'failed' || !tracker.taskMessageId) return;
const taskLogId = `${sessionLogId}:task-${index}`;
try {
const createResult = await aiAgentService.execSubAgentTask({
agentId: tracker.agentId,
groupId,
instruction: tracker.task,
parentMessageId: tracker.taskMessageId,
title: tracker.title,
topicId,
});
if (createResult.success) {
tracker.threadId = createResult.threadId;
tracker.status = 'running';
log(`[${taskLogId}] Task started with threadId: ${tracker.threadId}`);
} else {
tracker.status = 'failed';
tracker.error = createResult.error;
log(`[${taskLogId}] Failed to start task: ${createResult.error}`);
// Update task message with error
await get().optimisticUpdateMessageContent(
tracker.taskMessageId,
`Task creation failed: ${createResult.error}`,
undefined,
{ operationId: state.operationId },
);
}
} catch (error) {
tracker.status = 'failed';
tracker.error = error instanceof Error ? error.message : 'Unknown error';
console.error(`[${taskLogId}] Error starting task: ${error}`);
}
}),
);
// 3. Poll for all tasks completion
const pollInterval = 3000; // 3 seconds
const startTime = Date.now();
const maxTimeout = Math.max(...taskTrackers.map((t) => t.timeout));
while (Date.now() - startTime < maxTimeout) {
// Check if operation has been cancelled
const currentOperation = get().operations[state.operationId];
if (currentOperation?.status === 'cancelled') {
console.warn(`[${sessionLogId}] Operation cancelled, stopping polling`);
return {
events: [] as GroupOrchestrationEvent[],
newState: { ...state, status: 'done' },
result: {
payload: {
results: taskTrackers.map((t) => ({
agentId: t.agentId,
error: t.status === 'running' ? 'Operation cancelled' : t.error,
result: t.result,
success: t.status === 'completed',
})),
},
type: 'tasks_completed',
},
};
}
// Check status of all running tasks
const runningTasks = taskTrackers.filter((t) => t.status === 'running');
if (runningTasks.length === 0) {
// All tasks have completed or failed
break;
}
await Promise.all(
runningTasks.map(async (tracker, index) => {
if (!tracker.threadId || !tracker.taskMessageId) return;
const taskLogId = `${sessionLogId}:task-${index}`;
try {
const status = await aiAgentService.getSubAgentTaskStatus({
threadId: tracker.threadId,
});
// Update taskDetail in message if available
if (status.taskDetail) {
get().internal_dispatchMessage(
{
id: tracker.taskMessageId,
type: 'updateMessage',
value: { taskDetail: status.taskDetail },
},
{ operationId: state.operationId },
);
}
switch (status.status) {
case 'completed': {
tracker.status = 'completed';
tracker.result = status.result;
log(`[${taskLogId}] Task completed successfully`);
if (status.result) {
await get().optimisticUpdateMessageContent(
tracker.taskMessageId,
status.result,
undefined,
{ operationId: state.operationId },
);
}
break;
}
case 'failed': {
tracker.status = 'failed';
tracker.error = status.error;
console.error(`[${taskLogId}] Task failed: ${status.error}`);
await get().optimisticUpdateMessageContent(
tracker.taskMessageId,
`Task failed: ${status.error}`,
undefined,
{ operationId: state.operationId },
);
break;
}
case 'cancel': {
tracker.status = 'failed';
tracker.error = 'Task was cancelled';
log(`[${taskLogId}] Task was cancelled`);
await get().optimisticUpdateMessageContent(
tracker.taskMessageId,
'Task was cancelled',
undefined,
{ operationId: state.operationId },
);
break;
}
// No default
}
// Check individual task timeout
if (tracker.status === 'running' && Date.now() - startTime > tracker.timeout) {
tracker.status = 'failed';
tracker.error = `Task timeout after ${tracker.timeout}ms`;
log(`[${taskLogId}] Task timeout`);
await get().optimisticUpdateMessageContent(
tracker.taskMessageId,
`Task timeout after ${tracker.timeout}ms`,
undefined,
{ operationId: state.operationId },
);
}
} catch (error) {
console.error(`[${taskLogId}] Error polling task status: ${error}`);
}
}),
);
// Wait before next poll
await sleep(pollInterval);
}
// Mark any remaining running tasks as timed out
for (const tracker of taskTrackers) {
if (tracker.status === 'running' && tracker.taskMessageId) {
tracker.status = 'failed';
tracker.error = `Task timeout after ${tracker.timeout}ms`;
await get().optimisticUpdateMessageContent(
tracker.taskMessageId,
`Task timeout after ${tracker.timeout}ms`,
undefined,
{ operationId: state.operationId },
);
}
}
log(`[${sessionLogId}] All tasks completed`);
return {
events: [] as GroupOrchestrationEvent[],
newState: state,
result: {
payload: {
results: taskTrackers.map((t) => ({
agentId: t.agentId,
error: t.error,
result: t.result,
success: t.status === 'completed',
})),
},
type: 'tasks_completed',
},
};
},
};
};
@@ -1606,53 +1606,5 @@ describe('ChatPluginAction', () => {
);
});
});
describe('invokeCloudCodeInterpreterTool', () => {
it('should use optimisticUpdateToolMessage for successful result', async () => {
const mockResult = {
content: 'code interpreter result',
state: { output: 'test output' },
success: true,
};
// Mock CloudSandboxExecutionRuntime using doMock for dynamic mocking
vi.doMock('@lobechat/builtin-tool-cloud-sandbox/executionRuntime', () => ({
CloudSandboxExecutionRuntime: class {
'test-api' = vi.fn().mockResolvedValue(mockResult);
},
}));
const optimisticUpdateToolMessageMock = vi.fn().mockResolvedValue(undefined);
act(() => {
useChatStore.setState({
activeAgentId: 'session-id',
messagesMap: { [messageMapKey({ agentId: 'session-id' })]: [] },
optimisticUpdateToolMessage: optimisticUpdateToolMessageMock,
replaceMessages: vi.fn(),
messageOperationMap: {},
operations: {},
});
});
const { result } = renderHook(() => useChatStore());
await act(async () => {
await result.current.invokeCloudCodeInterpreterTool(messageId, payload);
});
expect(optimisticUpdateToolMessageMock).toHaveBeenCalledWith(
messageId,
{
content: mockResult.content,
pluginError: undefined,
pluginState: mockResult.state,
},
undefined,
);
vi.doUnmock('@lobechat/builtin-tool-cloud-sandbox/executionRuntime');
});
});
});
});
@@ -1,5 +1,4 @@
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
import { CloudSandboxIdentifier, type ExportFileState } from '@lobechat/builtin-tool-cloud-sandbox';
import { type ChatToolPayload, type RuntimeStepContext } from '@lobechat/types';
import { PluginErrorType } from '@lobehub/chat-plugin-sdk';
import debug from 'debug';
@@ -14,8 +13,6 @@ import { AI_RUNTIME_OPERATION_TYPES } from '@/store/chat/slices/operation';
import { type ChatStore } from '@/store/chat/store';
import { useToolStore } from '@/store/tool';
import { hasExecutor } from '@/store/tool/slices/builtin/executors';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/slices/auth/selectors';
import { safeParseJSON } from '@/utils/safeParseJSON';
import { dbMessageSelectors } from '../../message/selectors';
@@ -42,14 +39,6 @@ export interface PluginTypesAction {
stepContext?: RuntimeStepContext,
) => Promise<any>;
/**
* Invoke Cloud Code Interpreter tool
*/
invokeCloudCodeInterpreterTool: (
id: string,
payload: ChatToolPayload,
) => Promise<string | undefined>;
/**
* Invoke default type plugin (returns data)
*/
@@ -116,11 +105,6 @@ export const pluginTypes: StateCreator<
return await get().invokeLobehubSkillTypePlugin(id, payload);
}
// Check if this is Cloud Code Interpreter - route to specific handler
if (payload.identifier === CloudSandboxIdentifier) {
return await get().invokeCloudCodeInterpreterTool(id, payload);
}
const params = safeParseJSON(payload.arguments);
if (!params) return { error: 'Invalid arguments', success: false };
@@ -245,121 +229,6 @@ export const pluginTypes: StateCreator<
return;
},
invokeCloudCodeInterpreterTool: async (id, payload) => {
// Get message to extract topicId
const message = dbMessageSelectors.getDbMessageById(id)(get());
// Get abort controller from operation
const operationId = get().messageOperationMap[id];
const operation = operationId ? get().operations[operationId] : undefined;
const abortController = operation?.abortController;
log(
'[invokeCloudCodeInterpreterTool] messageId=%s, tool=%s, operationId=%s, aborted=%s',
id,
payload.apiName,
operationId,
abortController?.signal.aborted,
);
let data: { content: string; error?: any; state?: any; success: boolean } | undefined;
try {
// Import ExecutionRuntime dynamically to avoid circular dependencies
const { CloudSandboxExecutionRuntime } =
await import('@lobechat/builtin-tool-cloud-sandbox/executionRuntime');
// Get userId from user store
const userId = userProfileSelectors.userId(useUserStore.getState()) || 'anonymous';
// Create runtime with context
const runtime = new CloudSandboxExecutionRuntime({
topicId: message?.topicId || 'default',
userId,
});
// Parse arguments
const args = safeParseJSON(payload.arguments) || {};
// Call the appropriate method based on apiName
const apiMethod = (runtime as Record<string, any>)[payload.apiName];
if (!apiMethod) {
throw new Error(`Cloud Code Interpreter API not found: ${payload.apiName}`);
}
data = await apiMethod.call(runtime, args);
} catch (error) {
console.error('[invokeCloudCodeInterpreterTool] Error:', error);
const err = error as Error;
if (err.message.includes('aborted') || err.message.includes('The user aborted a request.')) {
log(
'[invokeCloudCodeInterpreterTool] Request aborted: messageId=%s, tool=%s',
id,
payload.apiName,
);
} else {
const result = await messageService.updateMessageError(id, error as any, {
agentId: message?.agentId,
topicId: message?.topicId,
});
if (result?.success && result.messages) {
get().replaceMessages(result.messages, {
context: {
agentId: message?.agentId,
topicId: message?.topicId,
},
});
}
}
}
if (!data) return;
const context = operationId ? { operationId } : undefined;
// Use optimisticUpdateToolMessage to update content and state/error in a single call
await get().optimisticUpdateToolMessage(
id,
{
content: data.content,
pluginError: data.success ? undefined : data.error,
pluginState: data.success ? data.state : undefined,
},
context,
);
// Handle exportFile: associate the file (already created by server) with assistant message (parent)
if (payload.apiName === 'exportFile' && data.success && data.state) {
const exportState = data.state as ExportFileState;
// Server now creates the file record and returns fileId in the response
if (exportState.fileId && exportState.filename) {
try {
// Associate file with the assistant message (parent of tool message)
// The current message (id) is the tool message, we need to attach to its parent
const targetMessageId = message?.parentId || id;
await messageService.addFilesToMessage(targetMessageId, [exportState.fileId], {
agentId: message?.agentId,
topicId: message?.topicId,
});
log(
'[invokeCloudCodeInterpreterTool] Associated exported file with message: targetMessageId=%s, fileId=%s, filename=%s',
targetMessageId,
exportState.fileId,
exportState.filename,
);
} catch (error) {
// Log error but don't fail the tool execution
console.error('[invokeCloudCodeInterpreterTool] Failed to save exported file:', error);
}
}
}
return data.content;
},
invokeDefaultTypePlugin: async (id, payload) => {
const { internal_callPluginApi } = get();
@@ -5,6 +5,7 @@
* Executors are registered as class instances by identifier.
*/
import { agentBuilderExecutor } from '@lobechat/builtin-tool-agent-builder/executor';
import { cloudSandboxExecutor } from '@lobechat/builtin-tool-cloud-sandbox/executor';
import { groupAgentBuilderExecutor } from '@lobechat/builtin-tool-group-agent-builder/executor';
import { groupManagementExecutor } from '@lobechat/builtin-tool-group-management/executor';
import { gtdExecutor } from '@lobechat/builtin-tool-gtd/executor';
@@ -120,6 +121,7 @@ const registerExecutors = (executors: IBuiltinToolExecutor[]): void => {
// Register all executor instances
registerExecutors([
agentBuilderExecutor,
cloudSandboxExecutor,
groupAgentBuilderExecutor,
groupManagementExecutor,
gtdExecutor,
@@ -0,0 +1,143 @@
import { UserStore } from '@/store/user';
import { UserState, initialState } from '@/store/user/initialState';
import { merge } from '@/utils/merge';
import { toolInterventionSelectors } from './toolIntervention';
describe('toolInterventionSelectors', () => {
describe('approvalMode', () => {
it('should return "manual" by default when no config exists', () => {
const s: UserState = merge(initialState, {
settings: {},
});
const result = toolInterventionSelectors.approvalMode(s as UserStore);
expect(result).toBe('manual');
});
it('should return "auto-run" when configured', () => {
const s: UserState = merge(initialState, {
settings: {
tool: {
humanIntervention: {
approvalMode: 'auto-run',
},
},
},
});
const result = toolInterventionSelectors.approvalMode(s as UserStore);
expect(result).toBe('auto-run');
});
it('should return "allow-list" when configured', () => {
const s: UserState = merge(initialState, {
settings: {
tool: {
humanIntervention: {
approvalMode: 'allow-list',
},
},
},
});
const result = toolInterventionSelectors.approvalMode(s as UserStore);
expect(result).toBe('allow-list');
});
it('should return "manual" when configured', () => {
const s: UserState = merge(initialState, {
settings: {
tool: {
humanIntervention: {
approvalMode: 'manual',
},
},
},
});
const result = toolInterventionSelectors.approvalMode(s as UserStore);
expect(result).toBe('manual');
});
it('should fallback to "auto-run" when approvalMode is "headless"', () => {
const s: UserState = merge(initialState, {
settings: {
tool: {
humanIntervention: {
approvalMode: 'headless' as any,
},
},
},
});
const result = toolInterventionSelectors.approvalMode(s as UserStore);
// headless is for backend async tasks only, UI should show auto-run
expect(result).toBe('auto-run');
});
});
describe('allowList', () => {
it('should return empty array by default', () => {
const s: UserState = merge(initialState, {
settings: {},
});
const result = toolInterventionSelectors.allowList(s as UserStore);
expect(result).toEqual([]);
});
it('should return configured allowList', () => {
const allowList = ['bash/bash', 'web-search/search'];
const s: UserState = merge(initialState, {
settings: {
tool: {
humanIntervention: {
allowList,
},
},
},
});
const result = toolInterventionSelectors.allowList(s as UserStore);
expect(result).toEqual(allowList);
});
});
describe('config', () => {
it('should return empty object by default', () => {
const s: UserState = merge(initialState, {
settings: {},
});
const result = toolInterventionSelectors.config(s as UserStore);
expect(result).toEqual({});
});
it('should return full humanIntervention config', () => {
const config = {
approvalMode: 'allow-list' as const,
allowList: ['bash/bash'],
};
const s: UserState = merge(initialState, {
settings: {
tool: {
humanIntervention: config,
},
},
});
const result = toolInterventionSelectors.config(s as UserStore);
expect(result).toEqual(config);
});
});
});
@@ -2,10 +2,19 @@ import type { UserStore } from '@/store/user';
import { currentSettings } from './settings';
/**
* User-selectable approval modes (excludes 'headless' which is for backend async tasks only)
*/
type UserApprovalMode = 'auto-run' | 'allow-list' | 'manual';
const humanInterventionConfig = (s: UserStore) => currentSettings(s).tool?.humanIntervention || {};
const interventionApprovalMode = (s: UserStore) =>
currentSettings(s).tool?.humanIntervention?.approvalMode || 'manual';
const interventionApprovalMode = (s: UserStore): UserApprovalMode => {
const mode = currentSettings(s).tool?.humanIntervention?.approvalMode;
// Filter out 'headless' mode as it's not user-selectable (fallback to auto-run as similar behavior)
if (mode === 'headless') return 'auto-run';
return mode || 'manual';
};
const interventionAllowList = (s: UserStore) =>
currentSettings(s).tool?.humanIntervention?.allowList || [];