Repository navigation
Complete Frontend Tool Invocation Flow Analysis
The AutoAi framework supports frontend tool invocation mechanism, allowing AI models to call browser-side JavaScript functions, implementing page interaction, data storage, user operations, and other functions.
- Ready to Use: 8 common built-in tools automatically available
- Flexible Extension: Support custom tools and override built-in tools
- Unified Interface: Completely consistent with backend tool usage
- Asynchronous Communication: Realize frontend-backend asynchronous communication via SSE
- Type Safety: Complete parameter types and validation
┌─────────────────────────────────────────────────────────────────┐
│ Complete Frontend Tool Invocation Flow │
└─────────────────────────────────────────────────────────────────┘
1️⃣ Frontend Tool Registration
↓
2️⃣ Send to Backend
↓
3️⃣ Backend Builds Tool List
↓
4️⃣ System Prompt Generation
↓
5️⃣ Pass to LLM (tools parameter)
↓
6️⃣ LLM Returns tool_calls
↓
7️⃣ Backend Identifies and Executes Frontend Tool
↓
8️⃣ SSE Notify Frontend
↓
9️⃣ Frontend Executes Tool
↓
🔟 Return Result to Backend
↓
1️⃣1️⃣ Backend Returns to LLM
Location: autoai-demo-web/src/main/resources/static/index.html:364-629
frontendTools: [
{
name: 'getLocalStorage',
description: 'Get data from localStorage',
parameters: {
type: 'object',
properties: {
key: { type: 'string', description: 'Stored key name' },
parseJson: { type: 'boolean', description: 'Whether to parse as JSON' }
},
required: ['key']
},
fn: async (args) => {
// Actual execution logic
const value = localStorage.getItem(args.key);
return value;
}
},
// ... more tools
]Key Points:
- Frontend defines tool name, description, parameters
- Provides fn function for actual execution
- Follows OpenAI Function Calling specification
Location: autoai-core/src/main/resources/static/auto-ai/autoai-chat.js:55-62
async chatStream(messages, signal, onChunk, onComplete, onError, frontendTools) {
const payload = {
messages,
stream: true,
sessionId: this.sessionId,
environment_context: this.getEnvironmentContext(),
frontend_tools: frontendTools || [] // 👈 Frontend tool definitions
};
const response = await fetch(`${this.baseUrl}/v1/chat/stream`, {
method: "POST",
headers: this.getRequestHeaders(),
body: JSON.stringify(payload),
signal: signal
});
// ...
}Key Points:
-
frontend_toolsfield passes frontend tool definitions - Format:
ToolSpec[]
Location: ReActEngine.java:141-142
List<ToolSpec> toolSpecs = buildToolSpecs(request); // Build tool list
applySystemPrompt(session, toolSpecs, request.getEnvironmentContext(), request);Location: ReActEngine.java:1695-1711 (buildToolSpecs method)
private List<ToolSpec> buildToolSpecs(ChatCompletionRequest request) {
List<ToolSpec> specs = new ArrayList<>();
// 1. Add backend tool details
specs.add(buildToolDetailSpec());
// 2. Add all backend registered tools
for (ToolSummary summary : toolRegistry.listSummaries()) {
ToolFunctionSpec function = new ToolFunctionSpec();
function.setName(summary.getName());
function.setDescription(summary.getDescription());
Optional<ToolDetail> detail = toolRegistry.getDetail(summary.getName());
if (detail.isPresent()) {
Map<String, Object> basicSchema = buildBasicSchema(detail.get());
function.setParameters(basicSchema);
}
specs.add(new ToolSpec(function));
}
// 3. Add frontend tool definitions 👈 Key step
if (request.getFrontendTools() != null && !request.getFrontendTools().isEmpty()) {
specs.addAll(request.getFrontendTools()); // Directly add frontend tools
}
return specs;
}Key Points:
- Frontend tools are directly added to
toolSpecslist - Include complete parameter schema definitions
Location: ReActEngine.java:1468-1504
private String buildSystemPrompt(boolean detailed, List<ToolSpec> toolSpecs, ChatCompletionRequest request) {
StringBuilder toolDescriptions = new StringBuilder();
for (ToolSpec spec : toolSpecs) {
if (spec.getFunction() == null) {
continue;
}
ToolFunctionSpec function = spec.getFunction();
String name = function.getName();
// 👇 Key: Filter out frontend tools
if (request.getFrontendTools() != null) {
boolean isFrontendTool = request.getFrontendTools().stream()
.anyMatch(ft -> name.equals(ft.getFunction().getName()));
if (isFrontendTool) {
continue; // Skip frontend tools, not listed in system prompt
}
}
String desc = function.getDescription();
toolDescriptions.append("- ").append(name).append(": ").append(desc);
// ... Add examples, etc.
}
return buildChineseSystemPrompt(toolDescriptions);
}Key Points:
⚠️ Frontend tools are filtered out, not in system prompt's "Available Tools" list- This causes AI to not know about frontend tools in text format
Location: ReActEngine.java:1656-1668 (copyRequest method)
private ChatCompletionRequest copyRequest(ChatCompletionRequest original, List<ChatMessage> messages,
List<ToolSpec> toolSpecs, String modelName) {
ChatCompletionRequest request = new ChatCompletionRequest();
request.setModel(modelName);
request.setMessages(messages);
request.setTools(toolSpecs); // 👈 Includes frontend tool complete definitions
request.setToolChoice(toolChoice == null ? "auto" : toolChoice);
request.setTemperature(original.getTemperature());
request.setMaxTokens(original.getMaxTokens());
request.setStream(original.getStream());
return request;
}Key Points:
- ✅
request.setTools(toolSpecs)- Frontend tool definitions are passed to LLM via this parameter - Follows OpenAI Function Calling specification
Expected Format:
{
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "getAllCookies",
"arguments": "{\"key\":\"value\"}"
}
}
]
}Key Points:
- LLM should call frontend tools via
tool_callsformat - Not text format
ACTION: getAllCookies()
Location: ReActEngine.java:626-666 (executeToolCall method)
private String executeToolCall(ToolCall toolCall, RequestContext requestContext,
ChatCompletionRequest request, TypedStreamCallback typedCallback,
String sessionId) {
ToolCallFunction function = toolCall.getFunction();
String name = function.getName();
// 1. Check if it's frontend tool
boolean isFrontendTool = request.getFrontendTools() != null &&
request.getFrontendTools().stream()
.anyMatch(spec -> name.equals(spec.getFunction().getName()));
if (isFrontendTool) {
// 2. Execute frontend tool
try {
return frontendToolInvoker.invoke(toolCall, sessionId, typedCallback);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "❌ Tool call failed: Call interrupted";
} catch (Exception e) {
return "❌ Tool call failed: " + e.getMessage();
}
}
// 3. Otherwise execute backend tool
// ...
}Key Points:
- Determine if it's frontend tool via
request.getFrontendTools() - Call
FrontendToolInvoker.invoke()to execute
Location: FrontendToolInvoker.java:93-116
private void sendToolCallNotification(ToolCall toolCall, String callId, TypedStreamCallback typedCallback) {
try {
// Build notification message
Map<String, Object> notification = new LinkedHashMap<>();
notification.put("type", "FRONTEND_TOOL_CALL");
notification.put("callId", callId);
notification.put("toolCall", toolCall);
// Convert to JSON string
String jsonStr = objectMapper.writeValueAsString(notification);
// Send via SSE
typedCallback.onTypedChunk(ContentType.ACTION, "[Frontend Tool Call] " + jsonStr);
} catch (Exception e) {
logger.error("Failed to send frontend tool call notification: {}", e.getMessage());
}
}SSE Message Format:
data: {"type":"action", "content": "[Frontend Tool Call] {\"type\":\"FRONTEND_TOOL_CALL\",\"callId\":\"xxx\",\"toolCall\":{...}}", "taskId": null}
Key Points:
- Format:
[Frontend Tool Call] {json} - Frontend needs to parse this message
Location: autoai-chat.js:1095-1108
// In onChunk callback
if (content && content.includes('[Frontend Tool Call]')) {
try {
const jsonStr = content.replace('[Frontend Tool Call]', '').trim();
const notification = JSON.parse(jsonStr);
if (notification.type === 'FRONTEND_TOOL_CALL') {
// Handle frontend tool call
this.handleFrontendToolCall(notification.toolCall, notification.callId);
return; // Don't show tool call notification, only execute tool
}
} catch (e) {
console.error('Failed to parse frontend tool call notification:', e);
}
}Location: autoai-chat.js:236-252 (handleFrontendToolCall)
async handleFrontendToolCall(toolCall, callId) {
const toolName = toolCall.function?.name;
const args = toolCall.function?.arguments;
// Find tool from registered tools
const tool = this.frontendTools.get(toolName);
if (!tool) {
console.error(`Frontend tool not found: ${toolName}`);
return this.sendToolResult(callId, null, "Tool not found", true);
}
try {
// Parse parameters and execute
const parsedArgs = typeof args === 'string' ? JSON.parse(args) : args;
const result = await tool.fn(parsedArgs);
this.sendToolResult(callId, result, null, false);
} catch (error) {
this.sendToolResult(callId, null, error.message, true);
}
}Key Points:
- Frontend finds tool from
this.frontendToolsMap - Call tool's
fnfunction to execute - Return result via
sendToolResult
Location: autoai-chat.js:257-278
async sendToolResult(callId, result, error, isError) {
const response = await fetch(`${this.baseUrl}/v1/chat/tool-result`, {
method: 'POST',
headers: this.client.getRequestHeaders(),
body: JSON.stringify({
sessionId: this.client.sessionId,
toolCall: {
callId,
result,
error,
isError
}
})
});
if (!response.ok) {
console.error('Failed to send tool result:', response.status);
}
}Request Format:
POST /v1/chat/tool-result
{
"sessionId": "xxx",
"toolCall": {
"callId": "call_abc123",
"result": {...},
"error": null,
"isError": false
}
}Location: AutoAiStreamController.java:128-159
@PostMapping("/chat/tool-result")
public ResponseEntity<Map<String, Object>> receiveToolResult(@RequestBody Map<String, Object> payload) {
String sessionId = (String) payload.get("sessionId");
Map<String, Object> toolCallData = (Map<String, Object>) payload.get("toolCall");
String callId = (String) toolCallData.get("callId");
Object result = toolCallData.get("result");
String error = (String) toolCallData.get("error");
boolean isError = (Boolean) toolCallData.get("isError");
// Complete tool call, set result
boolean success = frontendToolManager.completeToolCall(sessionId, callId, result, error, isError);
Map<String, Object> response = new HashMap<>();
response.put("success", success);
if (success) {
response.put("message", "Tool result received");
}
return ResponseEntity.ok(response);
}Location: FrontendToolManager.java:91-122 (completeToolCall)
public boolean completeToolCall(String sessionId, String callId, Object result, String error, boolean isError) {
String key = sessionId + ":" + callId;
CompletableFuture<String> future = pendingToolCalls.remove(key);
if (future == null) {
logger.warn("Received result for unknown tool call: sessionId={}, callId={}", sessionId, callId);
return false;
}
try {
String resultStr;
if (isError) {
resultStr = "❌ Tool call failed: " + (error != null ? error : "Unknown error");
} else {
if (result == null) {
resultStr = "✅ Tool call succeeded: null";
} else if (result instanceof String) {
resultStr = "✅ Tool call succeeded: " + result;
} else {
resultStr = "✅ Tool call succeeded: " + objectMapper.writeValueAsString(result);
}
}
future.complete(resultStr); // 👈 Unblock, return result
return true;
} catch (Exception e) {
logger.error("Failed to set tool call result: {}", e.getMessage());
future.completeExceptionally(e);
return false;
}
}Location: FrontendToolInvoker.java:55-72 (invoke method)
public String invoke(ToolCall toolCall, String sessionId, TypedStreamCallback typedCallback)
throws InterruptedException {
// 1. Register tool call, get callId
String callId = frontendToolManager.registerToolCall(sessionId, toolCall);
// 2. Send tool call request to frontend via SSE
if (typedCallback != null) {
sendToolCallNotification(toolCall, callId, typedCallback);
}
// 3. Wait for frontend to return result (block wait, max 30 seconds)👈 Blocking point
String result = frontendToolManager.waitForResult(sessionId, callId);
return result;
}Key Points:
-
CompletableFuture.get()blocks waiting for frontend return - Max wait 30 seconds
- After frontend returns,
future.complete(resultStr)unblocks - Result returned to ReActEngine, then returned to LLM
// Frontend tool definition
ToolSpec {
function: ToolFunctionSpec {
name: "getAllCookies",
description: "Get all page cookies",
parameters: {
type: "object",
properties: {...},
required: [...]
}
}
}
// Tool call
ToolCall {
id: "call_abc123",
type: "function",
function: {
name: "getAllCookies",
arguments: "{\"key\":\"value\"}"
}
}User FrontendJS BackendReActEngine LLM FrontendToolManager
│ │ │ │ │
│ Register Tool │ │ │ │
│──────────────>│ │ │ │
│ │ │ │ │
│ Send Message │ │ │ │
│──────────────>│ frontend_tools │ │ │
│ │────────────────────>│ │ │
│ │ │ Build toolSpecs │ │
│ │ │ Generate system prompt│ │
│ │ │ setTools(specs) │ │
│ │ │─────────────────>│ │
│ │ │ │ │
│ │ │ Return tool_calls │ │
│ │ │<─────────────────│ │
│ │ │ │ │
│ │ │ Identify frontend tool│ │
│ │ │ registerToolCall │ │
│ │ │───────────────────>│ │
│ │ │ │ Save Future │
│ │ │<───────────────────│ │
│ │ │ │ │
│ SSE Notify │ │ │ │
│<─────────────│ [Frontend Tool Call] │ │ │
│ │ │ │ │
│ Execute Tool │ │ │ │
│ getAllCookies│ │ │ │
│ Return Result │ │ │ │
│──────────────>│ POST /tool-result │ │ │
│ │────────────────────>│ │ │
│ │ │ completeToolCall │ │
│ │ │───────────────────>│ │
│ │ │ │ future.complete() │
│ │ │<───────────────────│ │
│ │ │ Return result │ │
│ │ │ │ │
1. Frontend registers tools
└─> frontendTools array
2. Send to backend
└─> frontend_tools field
3. Backend builds toolSpecs
└─> Include backend tools + frontend tools
4. Generate system prompt
└─> Current: Filter out frontend tools
└─> Issue: AI doesn't know about frontend tools
5. Pass to LLM
└─> setTools(toolSpecs) ✅ Frontend tools in tools parameter
6. LLM returns
└─> tool_calls format ✅
└─> Or text format ❌
7. Backend executes
└─> Identify frontend tool
└─> Call FrontendToolInvoker
8. SSE notify frontend
└─> [Frontend Tool Call] {json}
9. Frontend executes
└─> Find tool and execute fn
10. Return result
└─> POST /v1/chat/tool-result
11. Backend receives
└─> completeToolCall
└─> future.complete()
└─> Unblock
12. Return to LLM
└─> Continue conversation