diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java index 65db80509..d1c4d3fc6 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java @@ -555,6 +555,16 @@ public void createToolGroup(String groupName, String description) { groupManager.createToolGroup(groupName, description); } + /** + * Add a tool to a group. + * + * @param toolName Tool name + * @param groupName Group name + */ + public void addToolToGroup(String toolName, String groupName) { + groupManager.addToolToGroup(toolName, groupName); + } + /** * Update the activation status of tool groups. * diff --git a/agentscope-examples/agui/src/main/resources/static/index.html b/agentscope-examples/agui/src/main/resources/static/index.html index 97c72c425..e97e5fc47 100644 --- a/agentscope-examples/agui/src/main/resources/static/index.html +++ b/agentscope-examples/agui/src/main/resources/static/index.html @@ -278,6 +278,173 @@ .status-dot.disconnected { background: var(--accent-red); } + + /* Frontend Tools Panel Styles */ + .tools-panel { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + margin-bottom: 20px; + overflow: hidden; + } + + .tools-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: rgba(122, 162, 247, 0.1); + cursor: pointer; + user-select: none; + } + + .tools-header h3 { + font-size: 0.95rem; + font-weight: 600; + color: var(--accent-blue); + margin: 0; + } + + .icon-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.2rem; + cursor: pointer; + padding: 4px 8px; + transition: transform 0.2s; + } + + .icon-btn:hover { + color: var(--text-primary); + } + + .icon-btn.collapsed { + transform: rotate(-90deg); + } + + .tools-content { + padding: 16px; + border-top: 1px solid var(--border-color); + } + + .tools-content.collapsed { + display: none; + } + + .preset-tools { + margin-bottom: 16px; + } + + .preset-tools label { + display: block; + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: 8px; + } + + .preset-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 6px 12px; + border-radius: 6px; + margin-right: 8px; + margin-bottom: 8px; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s; + } + + .preset-btn:hover { + background: var(--accent-blue); + border-color: var(--accent-blue); + } + + .tools-list { + margin-bottom: 12px; + } + + .tool-item { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + margin-bottom: 8px; + position: relative; + } + + .tool-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + } + + .tool-name { + font-weight: 600; + color: var(--accent-green); + font-size: 0.9rem; + } + + .remove-tool-btn { + background: none; + border: none; + color: var(--accent-red); + cursor: pointer; + font-size: 1.1rem; + padding: 2px 6px; + } + + .tool-description { + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: 8px; + } + + .tool-params-preview { + font-size: 0.75rem; + color: var(--text-secondary); + background: var(--bg-secondary); + padding: 6px 8px; + border-radius: 4px; + font-family: monospace; + overflow-x: auto; + } + + .secondary-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s; + } + + .secondary-btn:hover { + background: var(--border-color); + } + + .no-tools { + color: var(--text-secondary); + font-size: 0.85rem; + font-style: italic; + padding: 12px; + text-align: center; + } + + .tool-result { + background: rgba(158, 206, 106, 0.1); + border-left: 3px solid var(--accent-green); + padding: 8px 12px; + margin: 8px 0; + border-radius: 4px; + font-family: monospace; + font-size: 0.8rem; + white-space: pre-wrap; + } @@ -292,6 +459,29 @@

AgentScope AG-UI Demo

Ready + +
+
+

🔧 Frontend Tools (0)

+ +
+
+
+ + + + + + +
+ +
+
No frontend tools added. Click buttons above or add custom tool.
+
+ +
+
+
@@ -308,6 +498,7 @@

AgentScope AG-UI Demo

+ diff --git a/agentscope-examples/agui/src/main/resources/static/js/frontend-tools.js b/agentscope-examples/agui/src/main/resources/static/js/frontend-tools.js new file mode 100644 index 000000000..e833dce61 --- /dev/null +++ b/agentscope-examples/agui/src/main/resources/static/js/frontend-tools.js @@ -0,0 +1,396 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Frontend Tools Manager + * + * Manages frontend tool definitions and execution for the AG-UI protocol. + * This module allows defining tools that execute in the browser and return + * results to the AI agent. + */ + +/** + * Preset tool definitions + */ +const PRESET_TOOLS = { + 'browser-info': { + name: 'get_browser_info', + description: 'Get information about the current browser including user agent, screen size, language, and current URL', + parameters: { + type: 'object', + properties: {}, + required: [] + } + }, + 'local-storage': { + name: 'search_local_storage', + description: 'Search browser local storage for keys matching a pattern', + parameters: { + type: 'object', + properties: { + key_pattern: { + type: 'string', + description: 'Pattern to search for in local storage keys (case-insensitive substring match)' + } + }, + required: ['key_pattern'] + } + }, + 'clipboard': { + name: 'read_clipboard', + description: 'Read text content from the system clipboard (requires user permission)', + parameters: { + type: 'object', + properties: {}, + required: [] + } + }, + 'current-time': { + name: 'get_current_time', + description: 'Get the current date and time from the client device', + parameters: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Time format: "iso", "locale", or "timestamp"', + enum: ['iso', 'locale', 'timestamp'] + } + } + } + }, + 'page-info': { + name: 'get_page_info', + description: 'Get information about the current webpage including title, URL, and meta description', + parameters: { + type: 'object', + properties: {}, + required: [] + } + } +}; + +/** + * Tool execution handlers + */ +const TOOL_HANDLERS = { + get_browser_info: async (args) => { + return { + user_agent: navigator.userAgent, + platform: navigator.platform, + language: navigator.language, + languages: navigator.languages, + screen: { + width: screen.width, + height: screen.height, + availWidth: screen.availWidth, + availHeight: screen.availHeight, + colorDepth: screen.colorDepth + }, + window: { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight + }, + url: window.location.href, + hostname: window.location.hostname, + online: navigator.onLine, + cookie_enabled: navigator.cookieEnabled, + touch_support: 'ontouchstart' in window || navigator.maxTouchPoints > 0 + }; + }, + + search_local_storage: async (args) => { + const pattern = args.key_pattern?.toLowerCase() || ''; + const results = {}; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!pattern || key.toLowerCase().includes(pattern)) { + try { + const value = localStorage.getItem(key); + // Try to parse as JSON, otherwise keep as string + try { + results[key] = JSON.parse(value); + } catch { + results[key] = value; + } + } catch (e) { + results[key] = `[Error reading: ${e.message}]`; + } + } + } + + return { + pattern: pattern, + match_count: Object.keys(results).length, + matches: results + }; + }, + + read_clipboard: async (args) => { + try { + // Check if clipboard API is available + if (!navigator.clipboard) { + throw new Error('Clipboard API not available. Make sure you are on HTTPS.'); + } + + const text = await navigator.clipboard.readText(); + return { + success: true, + content: text, + length: text.length + }; + } catch (err) { + return { + success: false, + error: err.message, + hint: 'User may need to grant clipboard permission or interact with the page first' + }; + } + }, + + get_current_time: async (args) => { + const now = new Date(); + const format = args.format || 'iso'; + + let time; + switch (format) { + case 'timestamp': + time = now.getTime(); + break; + case 'locale': + time = now.toLocaleString(); + break; + case 'iso': + default: + time = now.toISOString(); + } + + return { + time: time, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + timezone_offset: now.getTimezoneOffset(), + format: format + }; + }, + + get_page_info: async (args) => { + const metaDescription = document.querySelector('meta[name="description"]')?.content || ''; + const metaKeywords = document.querySelector('meta[name="keywords"]')?.content || ''; + + return { + title: document.title, + url: window.location.href, + description: metaDescription, + keywords: metaKeywords, + referrer: document.referrer || null, + charset: document.characterSet, + last_modified: document.lastModified + }; + } +}; + +/** + * Frontend Tools Manager Class + */ +class FrontendToolsManager { + constructor() { + this.tools = []; + this.pendingCalls = new Map(); + this.pendingResults = []; + } + + /** + * Get all defined tools + * @returns {Array} Array of tool definitions + */ + getTools() { + return this.tools; + } + + /** + * Add a custom tool + * @param {Object} tool - Tool definition + * @param {string} tool.name - Tool name + * @param {string} tool.description - Tool description + * @param {Object} tool.parameters - JSON Schema parameters + * @returns {boolean} True if added successfully + */ + addTool(tool) { + // Validate tool + if (!tool.name || !tool.description) { + console.error('Tool must have name and description'); + return false; + } + + // Check for duplicates + if (this.tools.some(t => t.name === tool.name)) { + console.warn(`Tool ${tool.name} already exists, skipping`); + return false; + } + + // Ensure parameters has correct structure + const toolDef = { + name: tool.name, + description: tool.description, + parameters: tool.parameters || { type: 'object', properties: {} } + }; + + this.tools.push(toolDef); + console.log(`Added frontend tool: ${tool.name}`); + return true; + } + + /** + * Add a preset tool + * @param {string} presetName - Name of the preset + * @returns {boolean} True if added successfully + */ + addPresetTool(presetName) { + const preset = PRESET_TOOLS[presetName]; + if (!preset) { + console.error(`Unknown preset: ${presetName}`); + return false; + } + + return this.addTool(preset); + } + + /** + * Remove a tool by index + * @param {number} index - Tool index + */ + removeTool(index) { + if (index >= 0 && index < this.tools.length) { + const removed = this.tools.splice(index, 1); + console.log(`Removed frontend tool: ${removed[0].name}`); + } + } + + /** + * Check if a tool exists + * @param {string} toolName - Tool name + * @returns {boolean} + */ + hasTool(toolName) { + return this.tools.some(t => t.name === toolName); + } + + /** + * Execute a tool + * @param {string} toolName - Name of the tool to execute + * @param {Object} args - Tool arguments + * @returns {Promise} Tool execution result + */ + async executeTool(toolName, args = {}) { + const handler = TOOL_HANDLERS[toolName]; + if (!handler) { + throw new Error(`No handler found for tool: ${toolName}`); + } + + console.log(`Executing frontend tool: ${toolName}`, args); + + try { + const result = await handler(args); + return { + success: true, + output: result + }; + } catch (error) { + console.error(`Error executing tool ${toolName}:`, error); + return { + success: false, + error: error.message + }; + } + } + + /** + * Add a pending tool call + * @param {string} toolCallId - Tool call ID + * @param {string} toolName - Tool name + */ + addPendingCall(toolCallId, toolName) { + this.pendingCalls.set(toolCallId, { toolCallId, toolName, args: '' }); + } + + /** + * Append args to a pending tool call + * @param {string} toolCallId - Tool call ID + * @param {string} delta - Args delta + */ + appendToolCallArgs(toolCallId, delta) { + const call = this.pendingCalls.get(toolCallId); + if (call) { + call.args += delta; + } + } + + /** + * Get a pending tool call + * @param {string} toolCallId - Tool call ID + * @returns {Object|null} + */ + getPendingCall(toolCallId) { + const call = this.pendingCalls.get(toolCallId); + if (!call) return null; + + // Parse args if available + let parsedArgs = {}; + if (call.args) { + try { + parsedArgs = JSON.parse(call.args); + } catch (e) { + console.warn(`Failed to parse tool call args for ${toolCallId}:`, e); + } + } + + return { ...call, parsedArgs }; + } + + /** + * Add a pending result to be sent in next request + * @param {string} toolCallId - Tool call ID + * @param {Object} result - Tool execution result + */ + addPendingResult(toolCallId, result) { + this.pendingResults.push({ + toolCallId, + ...result + }); + } + + /** + * Get all pending results + * @returns {Array} + */ + getPendingResults() { + return this.pendingResults; + } + + /** + * Clear pending results + */ + clearPendingResults() { + this.pendingResults = []; + this.pendingCalls.clear(); + } +} + +// Export for use in other scripts +if (typeof module !== 'undefined' && module.exports) { + module.exports = { FrontendToolsManager, PRESET_TOOLS, TOOL_HANDLERS }; +} \ No newline at end of file diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java index 54c890492..502e2a320 100644 --- a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java @@ -15,12 +15,17 @@ */ package io.agentscope.core.agui.processor; +import io.agentscope.core.ReActAgent; import io.agentscope.core.agent.Agent; import io.agentscope.core.agui.adapter.AguiAdapterConfig; import io.agentscope.core.agui.adapter.AguiAgentAdapter; +import io.agentscope.core.agui.converter.AguiToolConverter; import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.RunAgentInput; +import io.agentscope.core.agui.model.ToolMergeMode; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.tool.Toolkit; import java.util.List; import java.util.Objects; import org.slf4j.Logger; @@ -56,9 +61,11 @@ public class AguiRequestProcessor { private static final Logger logger = LoggerFactory.getLogger(AguiRequestProcessor.class); + private static final String TOOL_GROUP_NAME = "agui_tools_group"; private final AgentResolver agentResolver; private final AguiAdapterConfig config; + private final AguiToolConverter toolConverter = new AguiToolConverter(); private AguiRequestProcessor(Builder builder) { this.agentResolver = @@ -102,6 +109,9 @@ public ProcessResult process(RunAgentInput input, String headerAgentId, String p effectiveInput = extractLatestUserMessage(input); } + // Merge tools for agent + mergeToolsForAgent(agent, input); + // Create adapter and run AguiAgentAdapter adapter = new AguiAgentAdapter(agent, config); Flux events = adapter.run(effectiveInput); @@ -109,6 +119,67 @@ public ProcessResult process(RunAgentInput input, String headerAgentId, String p return new ProcessResult(agent, events); } + /** + * Merges frontend tools into the agent's toolkit based on configuration. + * + *

This method handles tool merging according to the configured + * {@link AguiAdapterConfig#getToolMergeMode()}: + *

    + *
  1. AGENT_ONLY: Keep agent's existing tools, ignore frontend tools
  2. + *
  3. FRONTEND_ONLY: Replace all agent tools with frontend tools
  4. + *
  5. MERGE_FRONTEND_PRIORITY: Merge both, frontend tools take precedence on conflicts
  6. + *
+ * + *

Note: Only {@link ReActAgent} instances are affected; other agent types are ignored. + * + * @param agent the agent to configure tools for + * @param input the request input containing frontend tool schemas + */ + private void mergeToolsForAgent(Agent agent, RunAgentInput input) { + if (!(agent instanceof ReActAgent reActAgent)) { + return; + } + + Toolkit toolkit = reActAgent.getToolkit(); + List toolSchemas = toolConverter.toToolSchemaList(input.getTools()); + + // ignore frontend tools + if (ToolMergeMode.AGENT_ONLY == config.getToolMergeMode()) { + return; + } + + switch (config.getToolMergeMode()) { + case FRONTEND_ONLY -> { + + // remove all tool + toolkit.removeToolGroups(toolkit.getActiveGroups()); + toolkit.getToolNames().forEach(toolkit::removeTool); + if (toolSchemas.isEmpty()) { + return; + } + + bindToolGroup(toolkit, toolSchemas); + } + case MERGE_FRONTEND_PRIORITY -> { + bindToolGroup(toolkit, toolSchemas); + } + } + } + + private void bindToolGroup(Toolkit toolkit, List toolSchemas) { + + if (toolkit.getToolGroup(TOOL_GROUP_NAME) == null) { + toolkit.createToolGroup(TOOL_GROUP_NAME, "Tools for AG-UI", true); + } + + if (!toolSchemas.isEmpty()) { + toolkit.registerSchemas(toolSchemas); + for (ToolSchema toolSchema : toolSchemas) { + toolkit.addToolToGroup(TOOL_GROUP_NAME, toolSchema.getName()); + } + } + } + /** * Resolve the agent ID from multiple sources. * @@ -177,7 +248,7 @@ public RunAgentInput extractLatestUserMessage(RunAgentInput input) { AguiMessage lastUserMessage = null; for (int i = messages.size() - 1; i >= 0; i--) { AguiMessage msg = messages.get(i); - if ("user".equalsIgnoreCase(msg.getRole())) { + if ("user".equalsIgnoreCase(msg.getRole()) || "tool".equalsIgnoreCase(msg.getRole())) { lastUserMessage = msg; break; }