Skip to content

Commit 98cf57b

Browse files
authored
✨ feat: support client tasks mode (lobehub#11666)
* fix exec client task fix isSuccess support client tasks add tests refactor to support client task mode fix race mode * improve * improve notebook system prompts * fix back actionicon * improve * fix create client thread data * fix messages service and model * add Client task mode * fix client task thread * fix isolation thead display * fix client task mode * refactor * client task mode * improve loading * improve processing state * improve loading state * refactor usage display * fix result * improve * more concurrency * more concurrency
1 parent 32c0623 commit 98cf57b

File tree

52 files changed

+2451
-194
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+2451
-194
lines changed

locales/en-US/chat.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@
204204
"noSelectedAgents": "No members selected yet",
205205
"openInNewWindow": "Open in New Window",
206206
"operation.execAgentRuntime": "Preparing response",
207+
"operation.execClientTask": "Executing task",
207208
"operation.sendMessage": "Sending message",
208209
"owner": "Group owner",
209210
"pageCopilot.title": "Page Agent",
@@ -322,13 +323,17 @@
322323
"tab.profile": "Agent Profile",
323324
"tab.search": "Search",
324325
"task.activity.calling": "Calling Skill...",
326+
"task.activity.clientExecuting": "Executing locally...",
325327
"task.activity.generating": "Generating response...",
326328
"task.activity.gotResult": "Tool result received",
327329
"task.activity.toolCalling": "Calling {{toolName}}...",
328330
"task.activity.toolResult": "{{toolName}} result received",
329331
"task.batchTasks": "{{count}} Batch Subtasks",
332+
"task.instruction": "Task Instruction",
333+
"task.intermediateSteps": "{{count}} intermediate steps",
334+
"task.metrics.duration": "(took {{duration}})",
330335
"task.metrics.stepsShort": "steps",
331-
"task.metrics.toolCallsShort": "tool uses",
336+
"task.metrics.toolCallsShort": "skill uses",
332337
"task.status.cancelled": "Task Cancelled",
333338
"task.status.failed": "Task Failed",
334339
"task.status.initializing": "Initializing task...",

locales/zh-CN/chat.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@
204204
"noSelectedAgents": "还未选择成员",
205205
"openInNewWindow": "在新窗口打开",
206206
"operation.execAgentRuntime": "准备响应中",
207+
"operation.execClientTask": "执行任务中",
207208
"operation.sendMessage": "消息发送中",
208209
"owner": "群主",
209210
"pageCopilot.title": "文稿助理",
@@ -322,11 +323,15 @@
322323
"tab.profile": "助理档案",
323324
"tab.search": "搜索",
324325
"task.activity.calling": "正在调用技能…",
326+
"task.activity.clientExecuting": "本地执行中…",
325327
"task.activity.generating": "正在生成回复…",
326328
"task.activity.gotResult": "已获取技能结果",
327329
"task.activity.toolCalling": "正在调用 {{toolName}}…",
328330
"task.activity.toolResult": "已获取 {{toolName}} 结果",
329331
"task.batchTasks": "{{count}} 个批量子任务",
332+
"task.instruction": "任务说明",
333+
"task.intermediateSteps": "{{count}} 个中间步骤",
334+
"task.metrics.duration": "(用时 {{duration}})",
330335
"task.metrics.stepsShort": "",
331336
"task.metrics.toolCallsShort": "次技能调用",
332337
"task.status.cancelled": "任务已取消",

packages/agent-runtime/src/agents/GeneralChatAgent.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,30 @@ export class GeneralChatAgent implements Agent {
359359
type: 'exec_tasks',
360360
};
361361
}
362+
363+
// GTD client-side async task (single, desktop only)
364+
if (stateType === 'execClientTask') {
365+
const { parentMessageId: execParentId, task } = data.state as {
366+
parentMessageId: string;
367+
task: any;
368+
};
369+
return {
370+
payload: { parentMessageId: execParentId, task },
371+
type: 'exec_client_task',
372+
};
373+
}
374+
375+
// GTD client-side async tasks (multiple, desktop only)
376+
if (stateType === 'execClientTasks') {
377+
const { parentMessageId: execParentId, tasks } = data.state as {
378+
parentMessageId: string;
379+
tasks: any[];
380+
};
381+
return {
382+
payload: { parentMessageId: execParentId, tasks },
383+
type: 'exec_client_tasks',
384+
};
385+
}
362386
}
363387

364388
// Check if there are still pending tool messages waiting for approval

packages/agent-runtime/src/agents/__tests__/GeneralChatAgent.test.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,216 @@ describe('GeneralChatAgent', () => {
368368
});
369369

370370
describe('tool_result phase', () => {
371+
describe('GTD async tasks', () => {
372+
it('should return exec_task for single async task (execTask)', async () => {
373+
const agent = new GeneralChatAgent({
374+
agentConfig: { maxSteps: 100 },
375+
operationId: 'test-session',
376+
modelRuntimeConfig: mockModelRuntimeConfig,
377+
});
378+
379+
const state = createMockState();
380+
const context = createMockContext('tool_result', {
381+
parentMessageId: 'tool-msg-1',
382+
stop: true,
383+
data: {
384+
state: {
385+
type: 'execTask',
386+
parentMessageId: 'exec-parent-msg',
387+
task: { instruction: 'Do something async', timeout: 30000 },
388+
},
389+
},
390+
});
391+
392+
const result = await agent.runner(context, state);
393+
394+
expect(result).toEqual({
395+
type: 'exec_task',
396+
payload: {
397+
parentMessageId: 'exec-parent-msg',
398+
task: { instruction: 'Do something async', timeout: 30000 },
399+
},
400+
});
401+
});
402+
403+
it('should return exec_tasks for multiple async tasks (execTasks)', async () => {
404+
const agent = new GeneralChatAgent({
405+
agentConfig: { maxSteps: 100 },
406+
operationId: 'test-session',
407+
modelRuntimeConfig: mockModelRuntimeConfig,
408+
});
409+
410+
const state = createMockState();
411+
const tasks = [
412+
{ instruction: 'Task 1', timeout: 30000 },
413+
{ instruction: 'Task 2', timeout: 30000 },
414+
];
415+
const context = createMockContext('tool_result', {
416+
parentMessageId: 'tool-msg-1',
417+
stop: true,
418+
data: {
419+
state: {
420+
type: 'execTasks',
421+
parentMessageId: 'exec-parent-msg',
422+
tasks,
423+
},
424+
},
425+
});
426+
427+
const result = await agent.runner(context, state);
428+
429+
expect(result).toEqual({
430+
type: 'exec_tasks',
431+
payload: {
432+
parentMessageId: 'exec-parent-msg',
433+
tasks,
434+
},
435+
});
436+
});
437+
438+
it('should return exec_client_task for single client-side async task (execClientTask)', async () => {
439+
const agent = new GeneralChatAgent({
440+
agentConfig: { maxSteps: 100 },
441+
operationId: 'test-session',
442+
modelRuntimeConfig: mockModelRuntimeConfig,
443+
});
444+
445+
const state = createMockState();
446+
const context = createMockContext('tool_result', {
447+
parentMessageId: 'tool-msg-1',
448+
stop: true,
449+
data: {
450+
state: {
451+
type: 'execClientTask',
452+
parentMessageId: 'exec-parent-msg',
453+
task: { type: 'localFile', path: '/path/to/file' },
454+
},
455+
},
456+
});
457+
458+
const result = await agent.runner(context, state);
459+
460+
expect(result).toEqual({
461+
type: 'exec_client_task',
462+
payload: {
463+
parentMessageId: 'exec-parent-msg',
464+
task: { type: 'localFile', path: '/path/to/file' },
465+
},
466+
});
467+
});
468+
469+
it('should return exec_client_tasks for multiple client-side async tasks (execClientTasks)', async () => {
470+
const agent = new GeneralChatAgent({
471+
agentConfig: { maxSteps: 100 },
472+
operationId: 'test-session',
473+
modelRuntimeConfig: mockModelRuntimeConfig,
474+
});
475+
476+
const state = createMockState();
477+
const tasks = [
478+
{ type: 'localFile', path: '/path/to/file1' },
479+
{ type: 'localFile', path: '/path/to/file2' },
480+
];
481+
const context = createMockContext('tool_result', {
482+
parentMessageId: 'tool-msg-1',
483+
stop: true,
484+
data: {
485+
state: {
486+
type: 'execClientTasks',
487+
parentMessageId: 'exec-parent-msg',
488+
tasks,
489+
},
490+
},
491+
});
492+
493+
const result = await agent.runner(context, state);
494+
495+
expect(result).toEqual({
496+
type: 'exec_client_tasks',
497+
payload: {
498+
parentMessageId: 'exec-parent-msg',
499+
tasks,
500+
},
501+
});
502+
});
503+
504+
it('should not trigger exec_task when stop is false', async () => {
505+
const agent = new GeneralChatAgent({
506+
agentConfig: { maxSteps: 100 },
507+
operationId: 'test-session',
508+
modelRuntimeConfig: mockModelRuntimeConfig,
509+
});
510+
511+
const state = createMockState({
512+
messages: [
513+
{ role: 'user', content: 'Hello' },
514+
{ role: 'assistant', content: '' },
515+
{ role: 'tool', content: 'Result', tool_call_id: 'call-1' },
516+
] as any,
517+
});
518+
const context = createMockContext('tool_result', {
519+
parentMessageId: 'tool-msg-1',
520+
stop: false, // stop is false, should not trigger exec_task
521+
data: {
522+
state: {
523+
type: 'execTask',
524+
parentMessageId: 'exec-parent-msg',
525+
task: { instruction: 'Do something async' },
526+
},
527+
},
528+
});
529+
530+
const result = await agent.runner(context, state);
531+
532+
// Should return call_llm instead of exec_task
533+
expect(result).toEqual({
534+
type: 'call_llm',
535+
payload: {
536+
messages: state.messages,
537+
model: 'gpt-4o-mini',
538+
parentMessageId: 'tool-msg-1',
539+
provider: 'openai',
540+
tools: undefined,
541+
},
542+
});
543+
});
544+
545+
it('should not trigger exec_task when data.state is undefined', async () => {
546+
const agent = new GeneralChatAgent({
547+
agentConfig: { maxSteps: 100 },
548+
operationId: 'test-session',
549+
modelRuntimeConfig: mockModelRuntimeConfig,
550+
});
551+
552+
const state = createMockState({
553+
messages: [
554+
{ role: 'user', content: 'Hello' },
555+
{ role: 'assistant', content: '' },
556+
{ role: 'tool', content: 'Result', tool_call_id: 'call-1' },
557+
] as any,
558+
});
559+
const context = createMockContext('tool_result', {
560+
parentMessageId: 'tool-msg-1',
561+
stop: true,
562+
data: {}, // No state property
563+
});
564+
565+
const result = await agent.runner(context, state);
566+
567+
// Should return call_llm instead of exec_task
568+
expect(result).toEqual({
569+
type: 'call_llm',
570+
payload: {
571+
messages: state.messages,
572+
model: 'gpt-4o-mini',
573+
parentMessageId: 'tool-msg-1',
574+
provider: 'openai',
575+
tools: undefined,
576+
},
577+
});
578+
});
579+
});
580+
371581
it('should return call_llm when no pending tools', async () => {
372582
const agent = new GeneralChatAgent({
373583
agentConfig: { maxSteps: 100 },

packages/agent-runtime/src/types/instruction.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,12 +236,26 @@ export interface ExecTaskItem {
236236
inheritMessages?: boolean;
237237
/** Detailed instruction/prompt for the task execution */
238238
instruction: string;
239+
/**
240+
* Whether to execute the task on the client side (desktop only).
241+
* When true and running on desktop, the task will be executed locally
242+
* with access to local tools (file system, shell commands, etc.).
243+
*
244+
* IMPORTANT: This MUST be set to true when the task requires:
245+
* - Reading/writing local files via `local-system` tool
246+
* - Executing shell commands
247+
* - Any other desktop-only local tool operations
248+
*
249+
* If not specified or false, the task runs on the server (default behavior).
250+
* On non-desktop platforms (web), this flag is ignored and tasks always run on server.
251+
*/
252+
runInClient?: boolean;
239253
/** Timeout in milliseconds (optional, default 30 minutes) */
240254
timeout?: number;
241255
}
242256

243257
/**
244-
* Instruction to execute a single async task
258+
* Instruction to execute a single async task (server-side)
245259
*/
246260
export interface AgentInstructionExecTask {
247261
payload: {
@@ -254,7 +268,7 @@ export interface AgentInstructionExecTask {
254268
}
255269

256270
/**
257-
* Instruction to execute multiple async tasks in parallel
271+
* Instruction to execute multiple async tasks in parallel (server-side)
258272
*/
259273
export interface AgentInstructionExecTasks {
260274
payload: {
@@ -266,6 +280,34 @@ export interface AgentInstructionExecTasks {
266280
type: 'exec_tasks';
267281
}
268282

283+
/**
284+
* Instruction to execute a single async task on the client (desktop only)
285+
* Used when task requires local tools like file system or shell commands
286+
*/
287+
export interface AgentInstructionExecClientTask {
288+
payload: {
289+
/** Parent message ID (tool message that triggered the task) */
290+
parentMessageId: string;
291+
/** Task to execute */
292+
task: ExecTaskItem;
293+
};
294+
type: 'exec_client_task';
295+
}
296+
297+
/**
298+
* Instruction to execute multiple async tasks on the client in parallel (desktop only)
299+
* Used when tasks require local tools like file system or shell commands
300+
*/
301+
export interface AgentInstructionExecClientTasks {
302+
payload: {
303+
/** Parent message ID (tool message that triggered the tasks) */
304+
parentMessageId: string;
305+
/** Array of tasks to execute */
306+
tasks: ExecTaskItem[];
307+
};
308+
type: 'exec_client_tasks';
309+
}
310+
269311
/**
270312
* Payload for task_result phase (single task)
271313
*/
@@ -318,6 +360,8 @@ export type AgentInstruction =
318360
| AgentInstructionCallToolsBatch
319361
| AgentInstructionExecTask
320362
| AgentInstructionExecTasks
363+
| AgentInstructionExecClientTask
364+
| AgentInstructionExecClientTasks
321365
| AgentInstructionRequestHumanPrompt
322366
| AgentInstructionRequestHumanSelect
323367
| AgentInstructionRequestHumanApprove
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';

0 commit comments

Comments
 (0)