mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebd4bbaf0f | |||
| 83ff13680e | |||
| d2bb7d1ad8 | |||
| 26c1d09411 | |||
| a24cb4f9c9 | |||
| 5975a45666 | |||
| ea6900b44c | |||
| 78a7274172 | |||
| 63fc0f3ed4 | |||
| 3c8c478846 |
@@ -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>;
|
||||
}
|
||||
-23
@@ -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 {
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
Reference in New Issue
Block a user