Skip to content

Commit 836395f

Browse files
committed
feat(eko): add human interaction support and fix session issue
Implement complete human interaction system for AI agent: - Add four interaction callbacks (confirm/input/select/help) in EkoService - Create HumanInteractionCard component for user interactions - Implement toolId to requestId mapping to resolve response matching - Add human-response IPC channel for frontend-backend communication - Integrate interaction cards into agent execution flow Fix session execution bug: - Clear ekoRequest state in finally block after task completion - Clear ekoRequest on task termination to prevent "Error: cancle" - Enable multiple consecutive sessions without restart Dependencies: - Upgrade @jarvis-agent/core to 0.1.4 - Upgrade @jarvis-agent/electron to 0.1.8 The human interaction feature allows AI to request user assistance for tasks like login, captcha solving, or manual operations. Cards are embedded in the message flow for seamless user experience.
1 parent f005c5a commit 836395f

File tree

10 files changed

+616
-22
lines changed

10 files changed

+616
-22
lines changed

electron/main/ipc/eko-handlers.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ipcMain } from "electron";
22
import { windowContextManager } from "../services/window-context-manager";
3+
import type { HumanResponseMessage } from "../../../src/models/human-interaction";
34

45
/**
56
* Register all Eko service related IPC handlers
@@ -76,5 +77,21 @@ export function registerEkoHandlers() {
7677
}
7778
});
7879

80+
// Handle human interaction response
81+
ipcMain.handle('eko:human-response', async (event, response: HumanResponseMessage) => {
82+
try {
83+
console.log('IPC eko:human-response received:', response);
84+
const context = windowContextManager.getContext(event.sender.id);
85+
if (!context || !context.ekoService) {
86+
throw new Error('EkoService not found for this window');
87+
}
88+
const result = context.ekoService.handleHumanResponse(response);
89+
return { success: result };
90+
} catch (error: any) {
91+
console.error('IPC eko:human-response error:', error);
92+
throw error;
93+
}
94+
});
95+
7996
console.log('[IPC] Eko service handlers registered');
8097
}

electron/main/services/eko-service.ts

Lines changed: 224 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { Eko, Log, SimpleSseMcpClient, type LLMs, type StreamCallbackMessage } from "@jarvis-agent/core";
1+
import { Eko, Log, SimpleSseMcpClient, type LLMs, type StreamCallbackMessage, type AgentContext } from "@jarvis-agent/core";
22
import { BrowserAgent, FileAgent } from "@jarvis-agent/electron";
33
import type { EkoResult } from "@jarvis-agent/core/types";
44
import { BrowserWindow, WebContentsView, app } from "electron";
55
import path from "node:path";
6+
import { randomUUID } from "node:crypto";
67
import { ConfigManager } from "../utils/config-manager";
8+
import type { HumanRequestMessage, HumanResponseMessage, HumanInteractionContext } from "../../../src/models/human-interaction";
79

810
export class EkoService {
911
private eko: Eko | null = null;
@@ -12,6 +14,18 @@ export class EkoService {
1214
private mcpClient!: SimpleSseMcpClient;
1315
private agents!: any[];
1416

17+
// Store pending human interaction requests
18+
private pendingHumanRequests = new Map<string, {
19+
resolve: (value: any) => void;
20+
reject: (reason?: any) => void;
21+
}>();
22+
23+
// Map toolId to requestId for human interactions
24+
private toolIdToRequestId = new Map<string, string>();
25+
26+
// Store current human_interact toolId
27+
private currentHumanInteractToolId: string | null = null;
28+
1529
constructor(mainWindow: BrowserWindow, detailView: WebContentsView) {
1630
this.mainWindow = mainWindow;
1731
this.detailView = detailView;
@@ -32,6 +46,12 @@ export class EkoService {
3246
return Promise.resolve();
3347
}
3448

49+
// Capture human_interact tool's toolId when tool is being used
50+
if (message.type === 'tool_use' && message.toolName === 'human_interact' && message.toolId) {
51+
this.currentHumanInteractToolId = message.toolId;
52+
Log.info('Captured human_interact toolId:', message.toolId);
53+
}
54+
3555
return new Promise((resolve) => {
3656
// Send stream message to renderer process via IPC
3757
this.mainWindow.webContents.send('eko-stream-message', message);
@@ -72,10 +92,68 @@ export class EkoService {
7292
} else {
7393
resolve();
7494
}
75-
})
95+
})
96+
},
97+
98+
// Human interaction callbacks
99+
onHumanConfirm: async (agentContext: AgentContext, prompt: string): Promise<boolean> => {
100+
const result = await this.requestHumanInteraction(agentContext, {
101+
interactType: 'confirm',
102+
prompt
103+
});
104+
return Boolean(result);
105+
},
106+
107+
onHumanInput: async (agentContext: AgentContext, prompt: string): Promise<string> => {
108+
const result = await this.requestHumanInteraction(agentContext, {
109+
interactType: 'input',
110+
prompt
111+
});
112+
return String(result ?? '');
113+
},
114+
115+
onHumanSelect: async (
116+
agentContext: AgentContext,
117+
prompt: string,
118+
options: string[],
119+
multiple: boolean
120+
): Promise<string[]> => {
121+
const result = await this.requestHumanInteraction(agentContext, {
122+
interactType: 'select',
123+
prompt,
124+
selectOptions: options,
125+
selectMultiple: multiple
126+
});
127+
return Array.isArray(result) ? result : [];
76128
},
77-
onHuman: (message: any) => {
78-
console.log('EkoService human callback:', message);
129+
130+
onHumanHelp: async (
131+
agentContext: AgentContext,
132+
helpType: 'request_login' | 'request_assistance',
133+
prompt: string
134+
): Promise<boolean> => {
135+
// Get current page information for context
136+
let context: HumanInteractionContext | undefined;
137+
try {
138+
const url = this.detailView.webContents.getURL();
139+
if (url && url.startsWith('http')) {
140+
const hostname = new URL(url).hostname;
141+
context = {
142+
siteName: hostname,
143+
actionUrl: url
144+
};
145+
}
146+
} catch (error) {
147+
Log.error('Failed to get URL for human help context:', error);
148+
}
149+
150+
const result = await this.requestHumanInteraction(agentContext, {
151+
interactType: 'request_help',
152+
prompt,
153+
helpType,
154+
context
155+
});
156+
return Boolean(result);
79157
}
80158
};
81159
}
@@ -142,7 +220,7 @@ export class EkoService {
142220
// Abort all running tasks before reloading
143221
if (this.eko) {
144222
const allTaskIds = this.eko.getAllTaskId();
145-
allTaskIds.forEach(taskId => {
223+
allTaskIds.forEach((taskId: any) => {
146224
try {
147225
this.eko!.abortTask(taskId, 'config-reload');
148226
} catch (error) {
@@ -151,6 +229,9 @@ export class EkoService {
151229
});
152230
}
153231

232+
// Reject all pending human interactions
233+
this.rejectAllHumanRequests(new Error('EkoService configuration reloaded'));
234+
154235
// Get new LLMs configuration
155236
const configManager = ConfigManager.getInstance();
156237
const llms: LLMs = configManager.getLLMsConfig();
@@ -318,17 +399,153 @@ export class EkoService {
318399
}
319400

320401
const allTaskIds = this.eko.getAllTaskId();
321-
const abortPromises = allTaskIds.map(taskId => this.eko!.abortTask(taskId, 'window-closing'));
402+
const abortPromises = allTaskIds.map((taskId: any) => this.eko!.abortTask(taskId, 'window-closing'));
322403

323404
await Promise.all(abortPromises);
405+
406+
// Reject all pending human interactions (also clears toolId mappings)
407+
this.rejectAllHumanRequests(new Error('All tasks aborted'));
408+
324409
Log.info('All tasks aborted');
325410
}
326411

412+
/**
413+
* Request human interaction
414+
* Sends interaction request to frontend and waits for user response
415+
*/
416+
private requestHumanInteraction(
417+
agentContext: AgentContext,
418+
payload: Omit<HumanRequestMessage, 'type' | 'requestId' | 'timestamp'>
419+
): Promise<any> {
420+
const requestId = randomUUID();
421+
const message: HumanRequestMessage = {
422+
type: 'human_interaction',
423+
requestId,
424+
taskId: agentContext?.context?.taskId,
425+
agentName: agentContext?.agent?.name,
426+
timestamp: new Date(),
427+
...payload
428+
};
429+
430+
return new Promise((resolve, reject) => {
431+
// Store promise resolver/rejector
432+
this.pendingHumanRequests.set(requestId, { resolve, reject });
433+
434+
// Map toolId to requestId for frontend response matching
435+
if (this.currentHumanInteractToolId) {
436+
this.toolIdToRequestId.set(this.currentHumanInteractToolId, requestId);
437+
Log.info('Mapped toolId to requestId:', {
438+
toolId: this.currentHumanInteractToolId,
439+
requestId
440+
});
441+
// Clear after mapping
442+
this.currentHumanInteractToolId = null;
443+
}
444+
445+
// Listen for task abort signal
446+
const controllerSignal = agentContext?.context?.controller?.signal;
447+
if (controllerSignal) {
448+
controllerSignal.addEventListener('abort', () => {
449+
this.pendingHumanRequests.delete(requestId);
450+
reject(new Error('Task aborted during human interaction'));
451+
});
452+
}
453+
454+
// Send request to frontend as a special message
455+
if (!this.mainWindow || this.mainWindow.isDestroyed()) {
456+
this.pendingHumanRequests.delete(requestId);
457+
reject(new Error('Main window destroyed, cannot request human interaction'));
458+
return;
459+
}
460+
461+
Log.info('Requesting human interaction:', { requestId, interactType: payload.interactType, prompt: payload.prompt });
462+
this.mainWindow.webContents.send('eko-stream-message', message);
463+
});
464+
}
465+
466+
/**
467+
* Handle human response from frontend
468+
* Called via IPC when user completes interaction
469+
*/
470+
public handleHumanResponse(response: HumanResponseMessage): boolean {
471+
Log.info('Received human response:', response);
472+
473+
// First try direct requestId match
474+
let pending = this.pendingHumanRequests.get(response.requestId);
475+
let actualRequestId = response.requestId;
476+
477+
// If not found, try to find via toolId mapping (frontend might send toolId as requestId)
478+
if (!pending) {
479+
const mappedRequestId = this.toolIdToRequestId.get(response.requestId);
480+
if (mappedRequestId) {
481+
pending = this.pendingHumanRequests.get(mappedRequestId);
482+
actualRequestId = mappedRequestId;
483+
Log.info('Found requestId via toolId mapping:', {
484+
toolId: response.requestId,
485+
actualRequestId: mappedRequestId
486+
});
487+
}
488+
}
489+
490+
if (!pending) {
491+
Log.error(`Human interaction request ${response.requestId} not found or already processed`);
492+
return false;
493+
}
494+
495+
// Clean up both maps
496+
this.pendingHumanRequests.delete(actualRequestId);
497+
this.toolIdToRequestId.delete(response.requestId);
498+
499+
if (response.success) {
500+
// Resolve promise, AI continues execution
501+
pending.resolve(response.result);
502+
503+
// Send result message to frontend to update card state
504+
if (!this.mainWindow || this.mainWindow.isDestroyed()) {
505+
Log.warn('Main window destroyed, cannot send interaction result');
506+
return true;
507+
}
508+
509+
this.mainWindow.webContents.send('eko-stream-message', {
510+
type: 'human_interaction_result',
511+
requestId: response.requestId,
512+
result: response.result,
513+
timestamp: new Date()
514+
});
515+
} else {
516+
// Reject promise, AI handles error
517+
pending.reject(new Error(response.error || 'Human interaction cancelled'));
518+
}
519+
520+
return true;
521+
}
522+
523+
/**
524+
* Reject all pending human interaction requests
525+
* Used when config reloads or service shuts down
526+
*/
527+
private rejectAllHumanRequests(error: Error): void {
528+
if (this.pendingHumanRequests.size === 0) {
529+
return;
530+
}
531+
532+
Log.info(`Rejecting ${this.pendingHumanRequests.size} pending human interaction requests`);
533+
534+
for (const pending of this.pendingHumanRequests.values()) {
535+
pending.reject(error);
536+
}
537+
538+
this.pendingHumanRequests.clear();
539+
this.toolIdToRequestId.clear();
540+
this.currentHumanInteractToolId = null;
541+
}
542+
327543
/**
328544
* Destroy service
329545
*/
330546
destroy() {
331-
console.log('EkoService destroyed');
547+
Log.info('EkoService destroyed');
548+
this.rejectAllHumanRequests(new Error('EkoService destroyed'));
332549
this.eko = null;
333550
}
334551
}

electron/main/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export default defineConfig({
3030
'node:child_process',
3131
'node:process',
3232
'node:buffer',
33+
'node:crypto',
3334
'fs',
3435
'fs/promises',
3536
'path',

electron/preload/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ const api = {
4040
ekoCancelTask: (taskId: string) => ipcRenderer.invoke('eko:cancel-task', taskId),
4141
onEkoStreamMessage: (callback: (message: any) => void) => ipcRenderer.on('eko-stream-message', (_, message) => callback(message)),
4242

43+
// Human interaction APIs
44+
sendHumanResponse: (response: any) => ipcRenderer.invoke('eko:human-response', response),
45+
4346
// Model configuration APIs
4447
getUserModelConfigs: () => ipcRenderer.invoke('config:get-user-configs'),
4548
saveUserModelConfigs: (configs: any) => ipcRenderer.invoke('config:save-user-configs', configs),

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
"dependencies": {
3030
"@ant-design/cssinjs": "^1.23.0",
3131
"@ant-design/icons": "5.x",
32-
"@jarvis-agent/core": "^0.1.3",
33-
"@jarvis-agent/electron": "^0.1.7",
32+
"@jarvis-agent/core": "^0.1.4",
33+
"@jarvis-agent/electron": "^0.1.8",
3434
"@jest/globals": "^30.1.2",
3535
"@react-spring/web": "^10.0.1",
3636
"antd": "^5.26.5",

0 commit comments

Comments
 (0)