mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-19 00:11:20 +03:00
feat(gns3-copilot): add get_gns3_server_host utility and integrate into Nornir tools
- Add `get_gns3_server_host()` function to `connector_factory.py` for extracting GNS3 server hostname from controller, config, or default URL
- Export new function in `__init__.py` for public API access
- Replace `os.getenv("GNS3_SERVER_HOST", "127.0.0.1")` calls with `get_gns3_server_host()` in Nornir configuration tools (`config_tools_nornir.py`, `display_tools_nornir.py`)
- Ensures consistent host detection across tools using the same priority logic as `get_gns3_connector`
This commit is contained in:
parent
8917ef39c9
commit
cabcf4f1cb
@ -27,7 +27,11 @@ Main functions:
|
|||||||
- get_gns3_connector_with_llm_config: Factory function to create connector AND retrieve LLM config
|
- get_gns3_connector_with_llm_config: Factory function to create connector AND retrieve LLM config
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .connector_factory import get_gns3_connector, get_gns3_connector_with_llm_config
|
from .connector_factory import (
|
||||||
|
get_gns3_connector,
|
||||||
|
get_gns3_connector_with_llm_config,
|
||||||
|
get_gns3_server_host,
|
||||||
|
)
|
||||||
from .custom_gns3fy import (
|
from .custom_gns3fy import (
|
||||||
CONSOLE_TYPES,
|
CONSOLE_TYPES,
|
||||||
LINK_TYPES,
|
LINK_TYPES,
|
||||||
@ -93,6 +97,7 @@ __all__ = [
|
|||||||
"GNS3UpdateDrawingTool",
|
"GNS3UpdateDrawingTool",
|
||||||
"get_gns3_connector",
|
"get_gns3_connector",
|
||||||
"get_gns3_connector_with_llm_config",
|
"get_gns3_connector_with_llm_config",
|
||||||
|
"get_gns3_server_host",
|
||||||
"add_file_to_index",
|
"add_file_to_index",
|
||||||
"get_file_list",
|
"get_file_list",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -298,3 +298,40 @@ def _detect_url_for_api() -> Optional[str]:
|
|||||||
|
|
||||||
# Fallback
|
# Fallback
|
||||||
return DEFAULT_GNS3_URL
|
return DEFAULT_GNS3_URL
|
||||||
|
|
||||||
|
|
||||||
|
def get_gns3_server_host() -> str:
|
||||||
|
"""
|
||||||
|
Get GNS3 server hostname from Controller or Config.
|
||||||
|
|
||||||
|
This is a convenience function for extracting the hostname only,
|
||||||
|
useful for Nornir tools that need the GNS3 server address.
|
||||||
|
|
||||||
|
Uses the same priority order as get_gns3_connector:
|
||||||
|
1. Controller.instance().compute("local")
|
||||||
|
2. Config.instance().settings.Server
|
||||||
|
3. Fallback to DEFAULT_GNS3_URL host
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hostname/IP address string
|
||||||
|
|
||||||
|
Example:
|
||||||
|
from gns3_copilot.gns3_client import get_gns3_server_host
|
||||||
|
|
||||||
|
host = get_gns3_server_host()
|
||||||
|
print(f"GNS3 server host: {host}")
|
||||||
|
"""
|
||||||
|
url = _detect_url_for_api()
|
||||||
|
|
||||||
|
# Extract host from URL
|
||||||
|
# URL format: protocol://host:port
|
||||||
|
try:
|
||||||
|
# Remove protocol prefix
|
||||||
|
host_part = url.split("://")[1]
|
||||||
|
# Extract host (before the port)
|
||||||
|
host = host_part.split(":")[0]
|
||||||
|
logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url)
|
||||||
|
return host
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to extract host from URL %s: %s, using fallback", url, e)
|
||||||
|
return DEFAULT_GNS3_URL.split("://")[1].split(":")[0]
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from gns3_copilot.gns3_client import get_gns3_server_host
|
||||||
|
|
||||||
from langchain.tools import BaseTool
|
from langchain.tools import BaseTool
|
||||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||||
from netmiko.exceptions import ReadTimeout
|
from netmiko.exceptions import ReadTimeout
|
||||||
@ -33,7 +35,7 @@ def _get_nornir_groups_config() -> dict[str, dict[str, Any]]:
|
|||||||
return {
|
return {
|
||||||
"cisco_IOSv_telnet": {
|
"cisco_IOSv_telnet": {
|
||||||
"platform": "cisco_ios",
|
"platform": "cisco_ios",
|
||||||
"hostname": os.getenv("GNS3_SERVER_HOST", "127.0.0.1"),
|
"hostname": get_gns3_server_host(),
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"username": "",
|
"username": "",
|
||||||
"password": "",
|
"password": "",
|
||||||
@ -371,7 +373,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
|||||||
defaults = _get_nornir_defaults()
|
defaults = _get_nornir_defaults()
|
||||||
|
|
||||||
# Log nornir account information
|
# Log nornir account information
|
||||||
gns3_host = os.getenv("GNS3_SERVER_HOST", "127.0.0.1")
|
gns3_host = get_gns3_server_host()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Initializing Nornir with account: host=%s, platform=%s, timeout=%d",
|
"Initializing Nornir with account: host=%s, platform=%s, timeout=%d",
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import os
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from gns3_copilot.gns3_client import get_gns3_server_host
|
||||||
|
|
||||||
from langchain.tools import BaseTool
|
from langchain.tools import BaseTool
|
||||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||||
from netmiko.exceptions import ReadTimeout
|
from netmiko.exceptions import ReadTimeout
|
||||||
@ -34,7 +36,7 @@ def _get_nornir_groups_config() -> dict[str, dict[str, Any]]:
|
|||||||
return {
|
return {
|
||||||
"cisco_IOSv_telnet": {
|
"cisco_IOSv_telnet": {
|
||||||
"platform": "cisco_ios",
|
"platform": "cisco_ios",
|
||||||
"hostname": os.getenv("GNS3_SERVER_HOST", "127.0.0.1"),
|
"hostname": get_gns3_server_host(),
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"username": "",
|
"username": "",
|
||||||
"password": "",
|
"password": "",
|
||||||
@ -385,7 +387,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
|||||||
defaults = _get_nornir_defaults()
|
defaults = _get_nornir_defaults()
|
||||||
|
|
||||||
# Log nornir account information
|
# Log nornir account information
|
||||||
gns3_host = os.getenv("GNS3_SERVER_HOST", "127.0.0.1")
|
gns3_host = get_gns3_server_host()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Initializing Nornir with account: host=%s, platform=%s, timeout=%d",
|
"Initializing Nornir with account: host=%s, platform=%s, timeout=%d",
|
||||||
|
|||||||
@ -1,657 +0,0 @@
|
|||||||
"""
|
|
||||||
This module uses Nornir + Netmiko to batch execute Linux commands on GNS3 topology devices
|
|
||||||
via Telnet console.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from langchain.tools import BaseTool
|
|
||||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
|
||||||
from nornir import InitNornir
|
|
||||||
from nornir.core import Nornir
|
|
||||||
from nornir.core.task import AggregatedResult, Result, Task
|
|
||||||
from nornir_netmiko.tasks import netmiko_send_command
|
|
||||||
|
|
||||||
from gns3_copilot.utils import get_device_ports_from_topology
|
|
||||||
|
|
||||||
# config log
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# Local Nornir configuration functions for Linux Telnet devices
|
|
||||||
def _get_nornir_defaults() -> dict[str, Any]:
|
|
||||||
"""Get Nornir default configuration for Linux Telnet."""
|
|
||||||
return {"data": {"location": "gns3"}}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_nornir_groups_config() -> dict[str, dict[str, Any]]:
|
|
||||||
"""Get Nornir groups configuration for Linux Telnet devices."""
|
|
||||||
return {
|
|
||||||
"linux_telnet": {
|
|
||||||
"platform": "linux",
|
|
||||||
"hostname": os.getenv("GNS3_SERVER_HOST", "127.0.0.1"),
|
|
||||||
"timeout": 120,
|
|
||||||
"username": os.getenv("LINUX_TELNET_USERNAME", ""),
|
|
||||||
"password": os.getenv("LINUX_TELNET_PASSWORD", ""),
|
|
||||||
"connection_options": {
|
|
||||||
"netmiko": {
|
|
||||||
"platform": "linux",
|
|
||||||
"extras": {
|
|
||||||
"device_type": "generic_telnet",
|
|
||||||
"global_delay_factor": 3,
|
|
||||||
"timeout": 120,
|
|
||||||
"fast_cli": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_nornir_group(group_name: str = "linux_telnet") -> dict[str, Any]:
|
|
||||||
"""Get Nornir group configuration for a specific group."""
|
|
||||||
all_groups = _get_nornir_groups_config()
|
|
||||||
return all_groups.get(group_name, {})
|
|
||||||
|
|
||||||
|
|
||||||
class LinuxTelnetBatchTool(BaseTool):
|
|
||||||
"""
|
|
||||||
A tool to execute commands on Linux devices via Telnet console in GNS3 labs.
|
|
||||||
|
|
||||||
**CRITICAL: NON-INTERACTIVE ONLY**
|
|
||||||
|
|
||||||
This tool ONLY supports non-interactive commands that exit immediately.
|
|
||||||
Interactive commands will cause the tool to hang and fail.
|
|
||||||
|
|
||||||
**Strictly Prohibited (Interactive Commands):**
|
|
||||||
- Text editors: vi, vim, nano, emacs
|
|
||||||
- Interactive viewers: less, more
|
|
||||||
- Continuous monitors: top, htop, iotop (use top -b -n 1 instead)
|
|
||||||
- Interactive shells: bash, sh, python, REPL environments
|
|
||||||
- Commands requiring user input: passwd, chsh, interactive installers
|
|
||||||
- Any command with pagination that waits for user input
|
|
||||||
|
|
||||||
**Required: Non-Interactive Alternatives**
|
|
||||||
- INSTEAD OF: top → USE: top -b -n 1 (batch mode, single iteration)
|
|
||||||
- INSTEAD OF: vi file.txt → USE: cat file.txt or head file.txt
|
|
||||||
- INSTEAD OF: less file.txt → USE: cat file.txt or head -n 50 file.txt
|
|
||||||
- INSTEAD OF: ping host → USE: ping -c 4 host (limited count)
|
|
||||||
- INSTEAD OF: tail → USE: tail -n 20 (explicit line count)
|
|
||||||
|
|
||||||
**Allowed Command Types:**
|
|
||||||
- System info: uname -a, hostnamectl, cat /etc/os-release
|
|
||||||
- Network diagnostics: ip a, ip route, ping -c 4, traceroute -n
|
|
||||||
- Process listing: ps aux, ps aux --sort=-%mem | head -20
|
|
||||||
- Service status: systemctl status ssh --no-pager
|
|
||||||
- Log viewing: journalctl -u ssh --no-pager -n 50, cat /var/log/syslog | tail -50
|
|
||||||
- File operations: ls -la, cat, head, tail (with explicit limits), find, grep
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str = "linux_telnet_batch_commands"
|
|
||||||
description: str = """
|
|
||||||
**LINUX DIAGNOSTIC TOOL** - Execute non-interactive commands on Linux devices via Telnet.
|
|
||||||
|
|
||||||
**CRITICAL: NON-INTERACTIVE COMMANDS ONLY**
|
|
||||||
|
|
||||||
This tool will HANG and FAIL if you use interactive commands. All commands MUST exit immediately without user input.
|
|
||||||
|
|
||||||
**STRICTLY FORBIDDEN:**
|
|
||||||
❌ Text editors: vi, vim, nano, emacs
|
|
||||||
❌ Interactive pagers: less, more
|
|
||||||
❌ Continuous monitors: top, htop, iotop (unless using batch mode)
|
|
||||||
❌ Interactive shells: bash, sh, python, REPL
|
|
||||||
❌ User input commands: passwd, chsh, SSH with password prompts
|
|
||||||
❌ Any command that waits for keyboard input or pagination
|
|
||||||
|
|
||||||
**NON-INTERACTIVE ALTERNATIVES (USE THESE):**
|
|
||||||
✅ System info: uname -a, hostnamectl, cat /etc/os-release
|
|
||||||
✅ Network: ip a, ip route, ping -c 4 <host>, traceroute -n
|
|
||||||
✅ Processes: ps aux, ps aux --sort=-%mem | head -20
|
|
||||||
✅ Services: systemctl status <service> --no-pager
|
|
||||||
✅ Logs: journalctl -u <unit> --no-pager -n 50, tail -50 /var/log/syslog
|
|
||||||
✅ Files: cat, head -n 50, tail -n 20, grep, find
|
|
||||||
✅ Batch monitoring: top -b -n 1 (NOT interactive top)
|
|
||||||
|
|
||||||
**Input Format:**
|
|
||||||
{
|
|
||||||
"project_id": "f32ebf3d-ef8c-4910-b0d6-566ed828cd24",
|
|
||||||
"device_configs": [
|
|
||||||
{
|
|
||||||
"device_name": "debian01",
|
|
||||||
"commands": ["uname -a", "ip a", "ping -c 4 8.8.8.8"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"device_name": "ubuntu01",
|
|
||||||
"commands": ["hostnamectl", "systemctl status ssh --no-pager"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
**Important Notes:**
|
|
||||||
- ALL commands must use --no-pager flag when available
|
|
||||||
- Add explicit limits: head -n 50, tail -n 20, ping -c 4
|
|
||||||
- For testing: Execute server/client commands on one device at a time, NOT simultaneously
|
|
||||||
- This tool is for diagnostics and information gathering
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _run(
|
|
||||||
self,
|
|
||||||
tool_input: str | bytes | list[Any] | dict[str, Any],
|
|
||||||
run_manager: CallbackManagerForToolRun | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Execute non-interactive Linux commands on multiple devices via Telnet.
|
|
||||||
|
|
||||||
**CRITICAL:** All commands MUST be non-interactive and exit immediately.
|
|
||||||
Interactive commands (vi, top, less, etc.) will cause execution to hang.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tool_input: JSON string containing project_id and device_configs with commands
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of execution results for each Linux device
|
|
||||||
"""
|
|
||||||
# Log received input
|
|
||||||
logger.info("Received input: %s", tool_input)
|
|
||||||
|
|
||||||
# Validate input first (before checking credentials)
|
|
||||||
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
|
||||||
if (
|
|
||||||
isinstance(device_configs_list, list)
|
|
||||||
and len(device_configs_list) > 0
|
|
||||||
and "error" in device_configs_list[0]
|
|
||||||
):
|
|
||||||
return device_configs_list
|
|
||||||
|
|
||||||
# Check credentials only for valid inputs
|
|
||||||
linux_username = os.getenv("LINUX_TELNET_USERNAME", "")
|
|
||||||
linux_password = os.getenv("LINUX_TELNET_PASSWORD", "")
|
|
||||||
|
|
||||||
if not linux_username or not linux_password:
|
|
||||||
user_message = (
|
|
||||||
"Sorry, I can't proceed just yet.\n\n"
|
|
||||||
"You haven't configured the Linux login credentials (username and password) yet.\n"
|
|
||||||
"Please go to the **Settings** page and fill in the Linux username and password under the login credentials section.\n\n"
|
|
||||||
"Once you've saved them, just come back and say anything (like 'Done' or 'Configured'), "
|
|
||||||
"and I'll immediately continue with the task!\n\n"
|
|
||||||
"Need help finding the settings page? Let me know — happy to guide you!"
|
|
||||||
)
|
|
||||||
logger.warning(
|
|
||||||
"Linux login credentials not configured — user prompted to set them up"
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"error": user_message,
|
|
||||||
"action_required": "configure_linux_credentials",
|
|
||||||
"user_message": user_message, # optional, if your frontend uses a separate field
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Create a mapping of device names to their display commands
|
|
||||||
device_configs_map = self._configs_map(device_configs_list)
|
|
||||||
|
|
||||||
# Prepare device hosts data
|
|
||||||
try:
|
|
||||||
hosts_data = self._prepare_device_hosts_data(
|
|
||||||
device_configs_list, project_id
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error("Failed to prepare device hosts data: %s", e)
|
|
||||||
return [{"error": str(e)}]
|
|
||||||
|
|
||||||
# Initialize Nornir
|
|
||||||
try:
|
|
||||||
dynamic_nr = self._initialize_nornir(hosts_data)
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error("Failed to initialize Nornir: %s", e)
|
|
||||||
return [{"error": str(e)}]
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
# Execute login first, then commands
|
|
||||||
try:
|
|
||||||
# Step 1: Execute login for all devices
|
|
||||||
login_result = dynamic_nr.run(task=self._linux_telnet_login)
|
|
||||||
|
|
||||||
# Step 2: Check login results and execute commands for successful logins
|
|
||||||
successful_logins = []
|
|
||||||
failed_logins = []
|
|
||||||
|
|
||||||
for device_name, result in login_result.items():
|
|
||||||
if result.failed:
|
|
||||||
failed_logins.append(device_name)
|
|
||||||
logger.error(
|
|
||||||
"Device %s login failed: %s", device_name, result.result
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
successful_logins.append(device_name)
|
|
||||||
logger.info(
|
|
||||||
"Device %s login successful: %s", device_name, result.result
|
|
||||||
)
|
|
||||||
|
|
||||||
task_result: AggregatedResult | dict[str, Any]
|
|
||||||
|
|
||||||
# Step 3: Execute commands only for devices with successful login
|
|
||||||
if successful_logins:
|
|
||||||
# Filter device_configs_map to only include successfully logged in devices
|
|
||||||
filtered_device_configs_map = {
|
|
||||||
device_name: commands
|
|
||||||
for device_name, commands in device_configs_map.items()
|
|
||||||
if device_name in successful_logins
|
|
||||||
}
|
|
||||||
|
|
||||||
task_result = dynamic_nr.run(
|
|
||||||
task=self._run_all_device_configs_with_single_retry,
|
|
||||||
device_configs_map=filtered_device_configs_map,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
task_result = AggregatedResult("empty_command_execution")
|
|
||||||
|
|
||||||
# Step 4: Process results for all devices
|
|
||||||
results = self._process_task_results(
|
|
||||||
device_configs_list, hosts_data, task_result, login_result
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Overall execution failed
|
|
||||||
logger.error("Error executing display on all devices: %s", e)
|
|
||||||
return [{"error": f"Execution error: {str(e)}"}]
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Multiple device display execution completed. Results: %s",
|
|
||||||
json.dumps(results, indent=2, ensure_ascii=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def _linux_telnet_login(self, task: Task) -> Result:
|
|
||||||
"""
|
|
||||||
Smart Linux Telnet login: detect login status and only login when needed.
|
|
||||||
|
|
||||||
This method handles authentication for Linux Telnet sessions in GNS3 labs.
|
|
||||||
It detects whether a device is already logged in before attempting authentication.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
net_connect = task.host.get_connection("netmiko", task.nornir.config)
|
|
||||||
|
|
||||||
# Clear the buffer + press Enter several times to wake up the device.
|
|
||||||
net_connect.clear_buffer()
|
|
||||||
time.sleep(0.3)
|
|
||||||
net_connect.write_channel("\n\n")
|
|
||||||
|
|
||||||
# Read the device output (wait up to 10 seconds)
|
|
||||||
output = net_connect.read_channel_timing(read_timeout=10)
|
|
||||||
logger.info("Device %s initial output: %s", task.host.name, output)
|
|
||||||
|
|
||||||
# Check if output contains "login:" prompt
|
|
||||||
if re.search(r"(?i)(^|\n).{0,60}(debian\s+)?login:\s*$", output):
|
|
||||||
# Need to login, execute login process
|
|
||||||
logger.info(
|
|
||||||
"Device %s requires login - detected login prompt", task.host.name
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send username
|
|
||||||
net_connect.write_channel(f"{task.host.username}\n")
|
|
||||||
time.sleep(1)
|
|
||||||
output = net_connect.read_until_prompt_or_pattern(
|
|
||||||
"Password:", read_timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send password
|
|
||||||
net_connect.write_channel(f"{task.host.password}\n")
|
|
||||||
time.sleep(1)
|
|
||||||
output += net_connect.read_until_prompt_or_pattern(
|
|
||||||
r"[$#]", read_timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Device %s login successful", task.host.name)
|
|
||||||
return Result(host=task.host, result="Login successful")
|
|
||||||
|
|
||||||
# Already logged in, return directly
|
|
||||||
logger.info(
|
|
||||||
"Device %s already logged in - no login prompt detected", task.host.name
|
|
||||||
)
|
|
||||||
return Result(host=task.host, result="Already logged in")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Device %s login failed: %s", task.host.name, e)
|
|
||||||
return Result(host=task.host, result=f"Login failed: {str(e)}", failed=True)
|
|
||||||
|
|
||||||
def _run_all_device_configs_with_single_retry(
|
|
||||||
self, task: Task, device_configs_map: dict[str, list[str]]
|
|
||||||
) -> Result:
|
|
||||||
"""
|
|
||||||
Execute non-interactive commands one-by-one on a single Linux device.
|
|
||||||
|
|
||||||
**WARNING:** Each command MUST exit immediately without user input.
|
|
||||||
If any command hangs waiting for input, the entire execution will fail.
|
|
||||||
|
|
||||||
Optimized for generic_telnet with $ prompt and passwordless sudo.
|
|
||||||
"""
|
|
||||||
device_name = task.host.name
|
|
||||||
config_commands = device_configs_map.get(device_name, [])
|
|
||||||
|
|
||||||
if not config_commands:
|
|
||||||
return Result(host=task.host, result="No display commands to execute")
|
|
||||||
|
|
||||||
_outputs = {}
|
|
||||||
for _cmd in config_commands:
|
|
||||||
try:
|
|
||||||
# Use timing mode + increased delay_factor to ensure stability with $ prompt
|
|
||||||
# and passwordless sudo
|
|
||||||
_result = task.run(
|
|
||||||
task=netmiko_send_command,
|
|
||||||
command_string=_cmd,
|
|
||||||
use_timing=True,
|
|
||||||
delay_factor=3,
|
|
||||||
max_loops=5000,
|
|
||||||
)
|
|
||||||
_outputs[_cmd] = _result.result
|
|
||||||
except Exception as e:
|
|
||||||
_outputs[_cmd] = f"Command execution failed: {str(e)}"
|
|
||||||
|
|
||||||
return Result(host=task.host, result=_outputs)
|
|
||||||
|
|
||||||
def _validate_tool_input(
|
|
||||||
self, tool_input: str | bytes | list[Any] | dict[str, Any]
|
|
||||||
) -> tuple[list[dict[str, Any]], str | None]:
|
|
||||||
"""
|
|
||||||
Validate device display command input, handling both new and legacy input formats.
|
|
||||||
Supports new format with project_id and device_configs, as well as legacy array format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tool_input: The input received from the LangChain/LangGraph tool call.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple containing (device_configs_list, project_id) or (error_list, None)
|
|
||||||
"""
|
|
||||||
|
|
||||||
parsed_input = None
|
|
||||||
|
|
||||||
# Compatibility Check and Parsing ---
|
|
||||||
# Check if the input is a string (or bytes) which needs to be parsed.
|
|
||||||
if isinstance(tool_input, (str, bytes, bytearray)):
|
|
||||||
# Handle models (like potentially DeepSeek) that return a raw JSON string.
|
|
||||||
try:
|
|
||||||
parsed_input = json.loads(tool_input)
|
|
||||||
logger.info("Successfully parsed tool input from JSON string.")
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.error("Invalid JSON string received as tool input: %s", e)
|
|
||||||
return ([{"error": f"Invalid JSON string input from model: {e}"}], None)
|
|
||||||
else:
|
|
||||||
# Handle standard models (like GPT/OpenAI) where the framework
|
|
||||||
# has already parsed the JSON into a Python object (dict or list).
|
|
||||||
parsed_input = tool_input
|
|
||||||
logger.info(
|
|
||||||
"Using tool input directly as type: %s", type(parsed_input).__name__
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle new format: {"project_id": "...", "device_configs": [...]}
|
|
||||||
if isinstance(parsed_input, dict):
|
|
||||||
project_id = parsed_input.get("project_id")
|
|
||||||
device_configs = parsed_input.get("device_configs")
|
|
||||||
|
|
||||||
# Validate project_id
|
|
||||||
if not project_id:
|
|
||||||
error_msg = "Missing required 'project_id' field in input"
|
|
||||||
logger.error(error_msg)
|
|
||||||
return ([{"error": error_msg}], None)
|
|
||||||
|
|
||||||
if not self._validate_project_id(project_id):
|
|
||||||
error_msg = (
|
|
||||||
f"Invalid project_id format: {project_id}. Expected UUID format."
|
|
||||||
)
|
|
||||||
logger.error(error_msg)
|
|
||||||
return ([{"error": error_msg}], None)
|
|
||||||
|
|
||||||
# Validate device_configs
|
|
||||||
if not isinstance(device_configs, list):
|
|
||||||
error_msg = "'device_configs' must be an array"
|
|
||||||
logger.error(error_msg)
|
|
||||||
return ([{"error": error_msg}], None)
|
|
||||||
|
|
||||||
if not device_configs:
|
|
||||||
logger.warning("Device configs list is empty.")
|
|
||||||
return [], project_id
|
|
||||||
|
|
||||||
return device_configs, project_id
|
|
||||||
|
|
||||||
# Handle legacy format: [...]
|
|
||||||
elif isinstance(parsed_input, list):
|
|
||||||
logger.warning(
|
|
||||||
"Using legacy input format without project_id. Please use new format with project_id."
|
|
||||||
)
|
|
||||||
return parsed_input, None
|
|
||||||
|
|
||||||
else:
|
|
||||||
error_msg = (
|
|
||||||
"Tool input must be a JSON object with 'project_id' and 'device_configs' fields, "
|
|
||||||
f"or a legacy JSON array, but got {type(parsed_input).__name__}"
|
|
||||||
)
|
|
||||||
logger.error(error_msg)
|
|
||||||
return ([{"error": error_msg}], None)
|
|
||||||
|
|
||||||
def _validate_project_id(self, project_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Validate project_id format (UUID).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: The project ID to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if valid UUID format, False otherwise
|
|
||||||
"""
|
|
||||||
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
||||||
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
|
||||||
|
|
||||||
def _configs_map(
|
|
||||||
self, device_config_list: list[dict[str, Any]]
|
|
||||||
) -> dict[str, list[str]]:
|
|
||||||
"""Create a mapping of device names to their display commands."""
|
|
||||||
device_configs_map = {}
|
|
||||||
for device_config in device_config_list:
|
|
||||||
device_name = device_config["device_name"]
|
|
||||||
config_commands = device_config["commands"]
|
|
||||||
device_configs_map[device_name] = config_commands
|
|
||||||
|
|
||||||
return device_configs_map
|
|
||||||
|
|
||||||
def _prepare_device_hosts_data(
|
|
||||||
self, device_config_list: list[dict[str, Any]], project_id: str | None = None
|
|
||||||
) -> dict[str, dict[str, Any]]:
|
|
||||||
"""Prepare device hosts data from topology information."""
|
|
||||||
# Extract device names list
|
|
||||||
device_names = [
|
|
||||||
device_config["device_name"] for device_config in device_config_list
|
|
||||||
]
|
|
||||||
|
|
||||||
# Get device port information with project_id
|
|
||||||
hosts_data = get_device_ports_from_topology(device_names, project_id)
|
|
||||||
|
|
||||||
if not hosts_data:
|
|
||||||
error_msg = (
|
|
||||||
f"Failed to get device information from topology or no valid devices found. "
|
|
||||||
f"Project ID: {project_id}, Devices: {device_names}"
|
|
||||||
)
|
|
||||||
raise ValueError(error_msg)
|
|
||||||
|
|
||||||
# Force all devices to use linux_telnet group (compatible with generic_telnet)
|
|
||||||
for _, _host_info in hosts_data.items():
|
|
||||||
_host_info["groups"] = ["linux_telnet"]
|
|
||||||
|
|
||||||
# Check for missing devices
|
|
||||||
missing_devices = set(device_names) - set(hosts_data.keys())
|
|
||||||
if missing_devices:
|
|
||||||
logger.warning(
|
|
||||||
"Some devices not found in topology (Project ID: %s): %s",
|
|
||||||
project_id or "default",
|
|
||||||
missing_devices,
|
|
||||||
)
|
|
||||||
|
|
||||||
return hosts_data
|
|
||||||
|
|
||||||
def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir:
|
|
||||||
"""Initialize Nornir with the provided hosts data."""
|
|
||||||
try:
|
|
||||||
# Get latest environment configuration
|
|
||||||
groups_data = _get_nornir_group("linux_telnet")
|
|
||||||
defaults = _get_nornir_defaults()
|
|
||||||
|
|
||||||
return InitNornir(
|
|
||||||
inventory={
|
|
||||||
"plugin": "DictInventory",
|
|
||||||
"options": {
|
|
||||||
"hosts": hosts_data,
|
|
||||||
"groups": {"linux_telnet": groups_data},
|
|
||||||
"defaults": defaults,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
runner={
|
|
||||||
"plugin": "threaded",
|
|
||||||
"options": {"num_workers": 10},
|
|
||||||
},
|
|
||||||
logging={"enabled": False},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to initialize Nornir: %s", e)
|
|
||||||
raise ValueError(f"Failed to initialize Nornir: {e}") from e
|
|
||||||
|
|
||||||
def _process_task_results(
|
|
||||||
self,
|
|
||||||
device_configs_list: list[dict[str, Any]],
|
|
||||||
hosts_data: dict[str, dict[str, Any]],
|
|
||||||
task_result: AggregatedResult,
|
|
||||||
login_result: AggregatedResult | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Process task results and format them for return."""
|
|
||||||
results = []
|
|
||||||
|
|
||||||
for device_config in device_configs_list:
|
|
||||||
device_name = device_config["device_name"]
|
|
||||||
config_commands = device_config["commands"]
|
|
||||||
|
|
||||||
# Check if device is in topology
|
|
||||||
if device_name not in hosts_data:
|
|
||||||
device_result = {
|
|
||||||
"device_name": device_name,
|
|
||||||
"status": "failed",
|
|
||||||
"error": (
|
|
||||||
f"Device '{device_name}' not found in topology or missing console_port"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
results.append(device_result)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check login result first
|
|
||||||
if login_result and device_name in login_result:
|
|
||||||
login_status = login_result[device_name]
|
|
||||||
if login_status.failed:
|
|
||||||
device_result = {
|
|
||||||
"device_name": device_name,
|
|
||||||
"status": "failed",
|
|
||||||
"error": f"Login failed: {login_status.result}",
|
|
||||||
"login_status": login_status.result,
|
|
||||||
}
|
|
||||||
results.append(device_result)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if device has execution results
|
|
||||||
if device_name not in task_result:
|
|
||||||
device_result = {
|
|
||||||
"device_name": device_name,
|
|
||||||
"status": "failed",
|
|
||||||
"error": (f"Device '{device_name}' not found in task results"),
|
|
||||||
}
|
|
||||||
results.append(device_result)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Process execution results
|
|
||||||
multi_result = task_result[device_name]
|
|
||||||
device_result = {"device_name": device_name}
|
|
||||||
|
|
||||||
if multi_result[0].failed:
|
|
||||||
# Execution failed
|
|
||||||
device_result["status"] = "failed"
|
|
||||||
device_result["error"] = (
|
|
||||||
f"Command execution failed: {multi_result[0].result}"
|
|
||||||
)
|
|
||||||
device_result["output"] = multi_result[0].result
|
|
||||||
else:
|
|
||||||
# Execution successful
|
|
||||||
device_result["status"] = "success"
|
|
||||||
device_result["output"] = multi_result[0].result
|
|
||||||
device_result["config_commands"] = config_commands
|
|
||||||
|
|
||||||
# Add login status if available
|
|
||||||
if login_result and device_name in login_result:
|
|
||||||
device_result["login_status"] = login_result[device_name].result
|
|
||||||
|
|
||||||
results.append(device_result)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Example usage with new format
|
|
||||||
device_commands = json.dumps(
|
|
||||||
{
|
|
||||||
"project_id": "f32ebf3d-ef8c-4910-b0d6-566ed828cd24",
|
|
||||||
"device_configs": [
|
|
||||||
{
|
|
||||||
"device_name": "Debian12.6-1",
|
|
||||||
"commands": [
|
|
||||||
"uname -a",
|
|
||||||
"hostnamectl || hostname",
|
|
||||||
"cat /etc/os-release",
|
|
||||||
"whoami && id",
|
|
||||||
"id",
|
|
||||||
"pwd",
|
|
||||||
"top -b -n 1 | head -20",
|
|
||||||
"ip neigh show | grep -v REACHABLE | grep -v PERMANENT",
|
|
||||||
"ping -c 3 114.114.114.114",
|
|
||||||
"ps aux --sort=-%mem | head -15",
|
|
||||||
"journalctl -u ssh --no-pager -n 20",
|
|
||||||
'find /etc -name "*.conf" | head -10',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"device_name": "Debian12.6-2",
|
|
||||||
"commands": [
|
|
||||||
"uname -a",
|
|
||||||
"hostnamectl || hostname",
|
|
||||||
"cat /etc/os-release",
|
|
||||||
"whoami && id",
|
|
||||||
"id",
|
|
||||||
"pwd",
|
|
||||||
"top -b -n 1 | head -20",
|
|
||||||
"ip neigh show | grep -v REACHABLE | grep -v PERMANENT",
|
|
||||||
"ping -c 3 114.114.114.114",
|
|
||||||
"ps aux --sort=-%mem | head -15",
|
|
||||||
"journalctl -u ssh --no-pager -n 20",
|
|
||||||
'find /etc -name "*.conf" | head -10',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
exe_cmd = LinuxTelnetBatchTool()
|
|
||||||
|
|
||||||
failed_count = 0
|
|
||||||
|
|
||||||
for _i in range(0, 1):
|
|
||||||
exe_results = exe_cmd._run(tool_input=device_commands)
|
|
||||||
for exe_result in exe_results:
|
|
||||||
for exe_result in exe_results:
|
|
||||||
if exe_result.get("status") == "failed":
|
|
||||||
failed_count += 1
|
|
||||||
|
|
||||||
print("Execution results:")
|
|
||||||
print(json.dumps(exe_results, indent=2, ensure_ascii=False))
|
|
||||||
print(f"Failed Count: {failed_count}")
|
|
||||||
@ -15,6 +15,7 @@ from langchain.tools import BaseTool
|
|||||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||||
from telnetlib3 import Telnet
|
from telnetlib3 import Telnet
|
||||||
|
|
||||||
|
from gns3_copilot.gns3_client import get_gns3_server_host
|
||||||
from gns3_copilot.utils import get_device_ports_from_topology
|
from gns3_copilot.utils import get_device_ports_from_topology
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -358,8 +359,8 @@ class VPCSMultiCommands(BaseTool):
|
|||||||
list(device_ports.keys()),
|
list(device_ports.keys()),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get host IP from environment variable
|
# Get GNS3 server host from connector factory
|
||||||
gns3_host = os.getenv("GNS3_SERVER_HOST", "127.0.0.1")
|
gns3_host = get_gns3_server_host()
|
||||||
logger.info("Using GNS3 server host: %s", gns3_host)
|
logger.info("Using GNS3 server host: %s", gns3_host)
|
||||||
|
|
||||||
# Initialize results list (pre-allocate space for concurrent writes)
|
# Initialize results list (pre-allocate space for concurrent writes)
|
||||||
|
|||||||
@ -1,726 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# This file is part of FlowNet-Lab.
|
|
||||||
#
|
|
||||||
# FlowNet-Lab is free software: you can redistribute it and/or modify it
|
|
||||||
# under the terms of the GNU Affero General Public License as published by the
|
|
||||||
# Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
# option) any later version.
|
|
||||||
#
|
|
||||||
# FlowNet-Lab is distributed in the hope that it will be useful, but
|
|
||||||
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
|
|
||||||
# for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along
|
|
||||||
# with FlowNet-Lab. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
"""
|
|
||||||
Window Controller Tool - Frontend Proxy Tool
|
|
||||||
|
|
||||||
This tool acts as a proxy to frontend window operations.
|
|
||||||
It sends commands to the frontend via WebSocket and waits for execution results.
|
|
||||||
|
|
||||||
Architecture:
|
|
||||||
Agent → WindowControllerTool._arun() → WebSocket → Frontend
|
|
||||||
← (waits for future) ← execution result
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from typing import Optional, Type, Any
|
|
||||||
|
|
||||||
from langchain.tools import BaseTool
|
|
||||||
from langchain_core.callbacks import (
|
|
||||||
AsyncCallbackManagerForToolRun,
|
|
||||||
CallbackManagerForToolRun,
|
|
||||||
)
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from backend.core.session_manager import get_session_manager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# Window type configurations with minimal required parameters
|
|
||||||
# Size and position should be dynamically generated by the LLM based on context
|
|
||||||
WINDOW_CONFIGS = {
|
|
||||||
"ai_chat": {
|
|
||||||
"window_type": "ai_chat",
|
|
||||||
"title": "AI Chat",
|
|
||||||
"min_size": {"width": 400, "height": 500},
|
|
||||||
"description": "AI conversation interface for chatting with the assistant",
|
|
||||||
},
|
|
||||||
"network_topology": {
|
|
||||||
"window_type": "network_topology",
|
|
||||||
"title": "Network Topology",
|
|
||||||
"min_size": {"width": 600, "height": 400},
|
|
||||||
"description": "GNS3 network topology visualization view",
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"window_type": "terminal",
|
|
||||||
"title": "Terminal",
|
|
||||||
"min_size": {"width": 500, "height": 300},
|
|
||||||
"description": "Device terminal emulator for command-line access",
|
|
||||||
},
|
|
||||||
"calibre_books": {
|
|
||||||
"window_type": "calibre_books",
|
|
||||||
"title": "Calibre Books",
|
|
||||||
"min_size": {"width": 500, "height": 400},
|
|
||||||
"description": "Calibre ebook library browser",
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"window_type": "notes",
|
|
||||||
"title": "Notes",
|
|
||||||
"min_size": {"width": 300, "height": 300},
|
|
||||||
"description": "Note-taking and management interface",
|
|
||||||
},
|
|
||||||
"pdf_reader": {
|
|
||||||
"window_type": "pdf_reader",
|
|
||||||
"title": "PDF Reader",
|
|
||||||
"min_size": {"width": 500, "height": 400},
|
|
||||||
"description": "PDF document viewer and reader",
|
|
||||||
},
|
|
||||||
"gns3_projects": {
|
|
||||||
"window_type": "gns3_projects",
|
|
||||||
"title": "GNS3 Projects",
|
|
||||||
"min_size": {"width": 500, "height": 400},
|
|
||||||
"description": "GNS3 project selection and management",
|
|
||||||
},
|
|
||||||
"video_recorder": {
|
|
||||||
"window_type": "video_recorder",
|
|
||||||
"title": "Video Recorder",
|
|
||||||
"min_size": {"width": 400, "height": 300},
|
|
||||||
"description": "Screen recording tool",
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"window_type": "settings",
|
|
||||||
"title": "Settings",
|
|
||||||
"min_size": {"width": 400, "height": 300},
|
|
||||||
"description": "System settings and configuration",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
VALID_ACTIONS = {
|
|
||||||
"open": ["window_type"],
|
|
||||||
"close": ["window_title"],
|
|
||||||
"focus": ["window_title"],
|
|
||||||
"minimize": ["window_title"],
|
|
||||||
"maximize": ["window_title"],
|
|
||||||
"restore": ["window_title"],
|
|
||||||
"center": ["window_title"],
|
|
||||||
"move": ["window_title", "x", "y"],
|
|
||||||
"resize": ["window_title", "width", "height"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WindowControllerInput(BaseModel):
|
|
||||||
"""Input schema for WindowControllerTool."""
|
|
||||||
|
|
||||||
action: str = Field(
|
|
||||||
...,
|
|
||||||
description=f"Window action to perform. Valid actions: {list(VALID_ACTIONS.keys())}"
|
|
||||||
)
|
|
||||||
window_type: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description=f"Type of window to open (required for 'open' action). Valid types: {list(WINDOW_CONFIGS.keys())}"
|
|
||||||
)
|
|
||||||
window_title: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="Title of the target window (required for close, focus, minimize, maximize, center actions)"
|
|
||||||
)
|
|
||||||
x: Optional[float] = Field(None, description="X coordinate for move action")
|
|
||||||
y: Optional[float] = Field(None, description="Y coordinate for move action")
|
|
||||||
width: Optional[float] = Field(None, description="Width for resize action or when opening a new window")
|
|
||||||
height: Optional[float] = Field(None, description="Height for resize action or when opening a new window")
|
|
||||||
size: Optional[dict] = Field(None, description="Complete size object: {\"width\": 800, \"height\": 600} (for open action)")
|
|
||||||
position: Optional[dict] = Field(None, description="Complete position object: {\"x\": 200, \"y\": 150} (for open action)")
|
|
||||||
|
|
||||||
|
|
||||||
class WindowControllerTool(BaseTool):
|
|
||||||
"""
|
|
||||||
Proxy tool for frontend window control operations.
|
|
||||||
|
|
||||||
This tool sends window control commands to the frontend via WebSocket
|
|
||||||
and waits for the frontend to execute and return results.
|
|
||||||
|
|
||||||
The actual window operations (open, close, focus, etc.) are executed
|
|
||||||
in the browser, not on the backend.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str = "window_controller"
|
|
||||||
description: str = """
|
|
||||||
Control windows in the frontend web interface.
|
|
||||||
|
|
||||||
Supported actions:
|
|
||||||
- open: Open a new window (requires: window_type)
|
|
||||||
You SHOULD intelligently generate size and position based on window type and context.
|
|
||||||
Example: {"action": "open", "window_type": "terminal", "size": {"width": 900, "height": 600}, "position": {"x": 250, "y": 150}}
|
|
||||||
|
|
||||||
- close: Close a window (requires: window_title)
|
|
||||||
Example: {"action": "close", "window_title": "Notes"}
|
|
||||||
|
|
||||||
- focus: Bring a window to front - increases z-index to make it visible above other windows (requires: window_title)
|
|
||||||
Use this when a window is visible but behind other windows.
|
|
||||||
Does NOT change minimized/maximized state.
|
|
||||||
Example: {"action": "focus", "window_title": "Notes"}
|
|
||||||
|
|
||||||
- minimize: Minimize a window - hide it from view (requires: window_title)
|
|
||||||
Window becomes hidden and can be restored later.
|
|
||||||
Example: {"action": "minimize", "window_title": "Notes"}
|
|
||||||
|
|
||||||
- maximize: Maximize a window - expand to fullscreen (requires: window_title)
|
|
||||||
Window fills the entire screen. Can be restored to previous size.
|
|
||||||
Example: {"action": "maximize", "window_title": "Notes"}
|
|
||||||
|
|
||||||
- restore: Restore a minimized or maximized window to its normal state (requires: window_title)
|
|
||||||
Use this when user says "restore", "recover", "show", "unhide", "unminimize", "unmaximize" a window.
|
|
||||||
This is the CORRECT action when window is minimized or maximized and user wants it back to normal.
|
|
||||||
Example: {"action": "restore", "window_title": "Notes"}
|
|
||||||
|
|
||||||
- center: Center a window on screen (requires: window_title)
|
|
||||||
Window occupies 90%% of viewport in center.
|
|
||||||
Example: {"action": "center", "window_title": "Notes"}
|
|
||||||
|
|
||||||
- move: Move a window (requires: window_title, x, y)
|
|
||||||
Example: {"action": "move", "window_title": "Notes", "x": 100, "y": 100}
|
|
||||||
|
|
||||||
- resize: Resize a window (requires: window_title, width, height)
|
|
||||||
Example: {"action": "resize", "window_title": "Notes", "width": 800, "height": 600}
|
|
||||||
|
|
||||||
Available window types and recommended sizes:
|
|
||||||
- "ai_chat": AI Chat interface (600x800 default, good for right side of screen)
|
|
||||||
- "network_topology": Network topology visualization (1000x700 default, needs large space)
|
|
||||||
- "terminal": Command execution terminal (900x600 default, good for bottom or left)
|
|
||||||
- "calibre_books": E-book library browser (1200x800 default, needs wide space)
|
|
||||||
- "notes": Note-taking interface (700x600 default, flexible)
|
|
||||||
- "pdf_reader": PDF document viewer (1000x700 default, needs large space)
|
|
||||||
- "gns3_projects": GNS3 project selector (600x500 default, medium size)
|
|
||||||
- "video_recorder": Screen recording tool (800x600 default, medium size)
|
|
||||||
- "settings": Application configuration (800x600 default, medium size)
|
|
||||||
|
|
||||||
Guidelines for intelligent window placement:
|
|
||||||
- For productivity tools (notes, pdf_reader, calibre_books): Place on right side (x: 800-1200, y: 100-200)
|
|
||||||
- For system tools (terminal, settings): Place on left side (x: 100-300, y: 100-200)
|
|
||||||
- For visualization (network_topology): Place in center or large area (x: 200-400, y: 100-200)
|
|
||||||
- Avoid overlapping windows by checking their purpose
|
|
||||||
- Consider typical screen resolution (1920x1080) when positioning
|
|
||||||
|
|
||||||
When opening windows, ALWAYS include both size and position parameters for better UX.
|
|
||||||
"""
|
|
||||||
args_schema: Type[BaseModel] = WindowControllerInput
|
|
||||||
|
|
||||||
# Session ID is injected when creating the tool instance
|
|
||||||
session_id: str = "default"
|
|
||||||
|
|
||||||
def _run(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
window_type: Optional[str] = None,
|
|
||||||
window_title: Optional[str] = None,
|
|
||||||
x: Optional[float] = None,
|
|
||||||
y: Optional[float] = None,
|
|
||||||
width: Optional[float] = None,
|
|
||||||
height: Optional[float] = None,
|
|
||||||
run_manager: Optional[CallbackManagerForToolRun] = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Synchronous version - not supported.
|
|
||||||
|
|
||||||
Use the async version (_arun) instead.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError(
|
|
||||||
"WindowControllerTool requires async execution. "
|
|
||||||
"Use the async version (_arun) or ensure your agent supports async tools."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _arun(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
window_type: Optional[str] = None,
|
|
||||||
window_title: Optional[str] = None,
|
|
||||||
x: Optional[float] = None,
|
|
||||||
y: Optional[float] = None,
|
|
||||||
width: Optional[float] = None,
|
|
||||||
height: Optional[float] = None,
|
|
||||||
size: Optional[dict] = None,
|
|
||||||
position: Optional[dict] = None,
|
|
||||||
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Async implementation: Send command to frontend and wait for result.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action: Window action to perform
|
|
||||||
window_type: Type of window (for 'open' action)
|
|
||||||
window_title: Title of target window (for other actions)
|
|
||||||
x, y: Position coordinates (for 'move' action)
|
|
||||||
width, height: Size dimensions (for 'resize' action or 'open' action)
|
|
||||||
size: Complete size dict for 'open' action
|
|
||||||
position: Complete position dict for 'open' action
|
|
||||||
run_manager: LangChain callback manager
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON string with execution result from frontend
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
session_manager = get_session_manager()
|
|
||||||
session = session_manager.get_session(self.session_id)
|
|
||||||
|
|
||||||
if not session:
|
|
||||||
logger.error(f"Session not found: {self.session_id}")
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": f"Session not found: {self.session_id}"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Validate action
|
|
||||||
if action not in VALID_ACTIONS:
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": f"Invalid action '{action}'. Valid actions: {list(VALID_ACTIONS.keys())}"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Build input data
|
|
||||||
input_data = {
|
|
||||||
"action": action,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add optional parameters based on action
|
|
||||||
if action == "open":
|
|
||||||
if not window_type:
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": "Missing required parameter: window_type"
|
|
||||||
})
|
|
||||||
if window_type not in WINDOW_CONFIGS:
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": f"Invalid window_type '{window_type}'. Available: {list(WINDOW_CONFIGS.keys())}"
|
|
||||||
})
|
|
||||||
input_data["window_type"] = window_type
|
|
||||||
|
|
||||||
# Get base configuration
|
|
||||||
base_config = WINDOW_CONFIGS[window_type]
|
|
||||||
window_config = base_config.copy()
|
|
||||||
|
|
||||||
# Merge LLM-provided size (supports both formats: dict or width/height)
|
|
||||||
if size:
|
|
||||||
window_config["size"] = size
|
|
||||||
elif width is not None and height is not None:
|
|
||||||
window_config["size"] = {"width": width, "height": height}
|
|
||||||
else:
|
|
||||||
# Use default size based on window type
|
|
||||||
window_config["size"] = _get_default_size(window_type)
|
|
||||||
|
|
||||||
# Merge LLM-provided position (supports both formats: dict or x/y)
|
|
||||||
if position:
|
|
||||||
window_config["position"] = position
|
|
||||||
elif x is not None and y is not None:
|
|
||||||
window_config["position"] = {"x": x, "y": y}
|
|
||||||
else:
|
|
||||||
# Use default position based on window type
|
|
||||||
window_config["position"] = _get_default_position(window_type)
|
|
||||||
|
|
||||||
# Add min_size from base config
|
|
||||||
if "min_size" in base_config:
|
|
||||||
window_config["min_size"] = base_config["min_size"]
|
|
||||||
|
|
||||||
# Add window_config to input_data
|
|
||||||
input_data["window_config"] = window_config
|
|
||||||
|
|
||||||
logger.info(f"Opening window: type={window_type}, config={window_config}")
|
|
||||||
|
|
||||||
elif action in ["close", "focus", "minimize", "maximize", "restore", "center"]:
|
|
||||||
if not window_title:
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": f"Missing required parameter: window_title (for action '{action}')"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Check if window exists in backend state before sending command
|
|
||||||
from backend.core.window_state_manager import get_window_state_manager
|
|
||||||
state_manager = get_window_state_manager()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[WindowController] Looking for window '{window_title}' "
|
|
||||||
f"in session {self.session_id}..."
|
|
||||||
)
|
|
||||||
|
|
||||||
window = state_manager.get_window_by_title(self.session_id, window_title)
|
|
||||||
|
|
||||||
if window:
|
|
||||||
logger.info(
|
|
||||||
f"[WindowController] ✅ Found window '{window_title}' "
|
|
||||||
f"(id={window.id}, type={window.window_type})"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Log all available windows for debugging
|
|
||||||
state = state_manager.get_state(self.session_id)
|
|
||||||
if state:
|
|
||||||
available_titles = [w.title for w in state.windows]
|
|
||||||
logger.warning(
|
|
||||||
f"[WindowController] ❌ Window '{window_title}' not found. "
|
|
||||||
f"Available windows: {available_titles}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"[WindowController] ❌ Window '{window_title}' not found. "
|
|
||||||
f"No state exists for session {self.session_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not window:
|
|
||||||
# Window not found - return success (idempotent operation)
|
|
||||||
logger.info(
|
|
||||||
f"Window '{window_title}' not found in state, "
|
|
||||||
f"treating as already {'closed' if action == 'close' else 'not existent'}"
|
|
||||||
)
|
|
||||||
return json.dumps({
|
|
||||||
"status": "success",
|
|
||||||
"action": action,
|
|
||||||
"window_title": window_title,
|
|
||||||
"already_closed": True if action == "close" else False,
|
|
||||||
"not_found": True
|
|
||||||
})
|
|
||||||
|
|
||||||
input_data["window_title"] = window_title
|
|
||||||
|
|
||||||
elif action == "move":
|
|
||||||
if not window_title:
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": "Missing required parameter: window_title"
|
|
||||||
})
|
|
||||||
if x is None or y is None:
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": "Missing required parameters: x and y (for move action)"
|
|
||||||
})
|
|
||||||
input_data["window_title"] = window_title
|
|
||||||
input_data["x"] = x
|
|
||||||
input_data["y"] = y
|
|
||||||
|
|
||||||
elif action == "resize":
|
|
||||||
if not window_title:
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": "Missing required parameter: window_title"
|
|
||||||
})
|
|
||||||
# Support both formats: separate width/height or size dict
|
|
||||||
if size:
|
|
||||||
input_data["window_title"] = window_title
|
|
||||||
input_data["width"] = size.get("width")
|
|
||||||
input_data["height"] = size.get("height")
|
|
||||||
elif width is not None and height is not None:
|
|
||||||
input_data["window_title"] = window_title
|
|
||||||
input_data["width"] = width
|
|
||||||
input_data["height"] = height
|
|
||||||
else:
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": "Missing required parameters: width and height (or size dict) for resize action"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Generate task_id
|
|
||||||
task_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Create Future for waiting on frontend result
|
|
||||||
future = session.create_future(task_id)
|
|
||||||
|
|
||||||
# Send tool_call to frontend
|
|
||||||
try:
|
|
||||||
await session.websocket.send_json({
|
|
||||||
"type": "tool_call",
|
|
||||||
"task_id": task_id,
|
|
||||||
"tool": "window_controller",
|
|
||||||
"args": input_data, # Include all parameters including window_config
|
|
||||||
"session_id": self.session_id
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"WindowController: Sent tool_call to frontend, task_id={task_id}, action={action}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
session.pending_futures.pop(task_id, None)
|
|
||||||
logger.error(f"WindowController: Failed to send tool_call: {e}")
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": f"Failed to send command to frontend: {str(e)}"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Wait for frontend to execute and return result (timeout 30 seconds)
|
|
||||||
try:
|
|
||||||
result = await asyncio.wait_for(future, timeout=30.0)
|
|
||||||
|
|
||||||
logger.info(f"WindowController: Received result from frontend, task_id={task_id}")
|
|
||||||
|
|
||||||
# Return result to Agent (should already be a dict or JSON string)
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return json.dumps(result, ensure_ascii=False)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
# Timeout: remove pending future
|
|
||||||
session.pending_futures.pop(task_id, None)
|
|
||||||
|
|
||||||
logger.warning(f"WindowController: Timeout waiting for frontend, task_id={task_id}, action={action}")
|
|
||||||
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": "Timeout waiting for frontend to execute window operation",
|
|
||||||
"action": action,
|
|
||||||
"timeout_seconds": 30
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Error: remove pending future
|
|
||||||
session.pending_futures.pop(task_id, None)
|
|
||||||
|
|
||||||
logger.error(f"WindowController: Error waiting for result: {e}")
|
|
||||||
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": f"Error waiting for frontend response: {str(e)}"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _get_default_size(window_type: str) -> dict:
|
|
||||||
"""
|
|
||||||
Get default size for a window type based on its purpose.
|
|
||||||
"""
|
|
||||||
default_sizes = {
|
|
||||||
"ai_chat": {"width": 600, "height": 800},
|
|
||||||
"network_topology": {"width": 1000, "height": 700},
|
|
||||||
"terminal": {"width": 900, "height": 600},
|
|
||||||
"calibre_books": {"width": 1200, "height": 800},
|
|
||||||
"notes": {"width": 700, "height": 600},
|
|
||||||
"pdf_reader": {"width": 1000, "height": 700},
|
|
||||||
"gns3_projects": {"width": 600, "height": 500},
|
|
||||||
"video_recorder": {"width": 800, "height": 600},
|
|
||||||
"settings": {"width": 800, "height": 600},
|
|
||||||
}
|
|
||||||
return default_sizes.get(window_type, {"width": 800, "height": 600})
|
|
||||||
|
|
||||||
|
|
||||||
def _get_default_position(window_type: str) -> dict:
|
|
||||||
"""
|
|
||||||
Get default position for a window type based on its category.
|
|
||||||
Strategy: Place windows in different areas of screen to minimize overlap.
|
|
||||||
"""
|
|
||||||
# Productivity tools: right side
|
|
||||||
if window_type in ["notes", "pdf_reader", "calibre_books"]:
|
|
||||||
return {"x": 900, "y": 150}
|
|
||||||
|
|
||||||
# System tools: left side
|
|
||||||
if window_type in ["terminal", "settings", "gns3_projects"]:
|
|
||||||
return {"x": 250, "y": 150}
|
|
||||||
|
|
||||||
# Visualization: center
|
|
||||||
if window_type == "network_topology":
|
|
||||||
return {"x": 200, "y": 100}
|
|
||||||
|
|
||||||
# Chat: right edge
|
|
||||||
if window_type == "ai_chat":
|
|
||||||
return {"x": 1100, "y": 100}
|
|
||||||
|
|
||||||
# Tools: middle-right
|
|
||||||
if window_type == "video_recorder":
|
|
||||||
return {"x": 700, "y": 200}
|
|
||||||
|
|
||||||
# Default fallback
|
|
||||||
return {"x": 200, "y": 150}
|
|
||||||
|
|
||||||
|
|
||||||
def create_window_controller_tool(session_id: str) -> WindowControllerTool:
|
|
||||||
"""
|
|
||||||
Factory function to create WindowControllerTool with specific session_id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_id: Session identifier for WebSocket connection
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WindowControllerTool instance configured for this session
|
|
||||||
"""
|
|
||||||
return WindowControllerTool(session_id=session_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# TextInputTool - Smart text input to frontend windows
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class TextInputInput(BaseModel):
|
|
||||||
"""Input schema for TextInputTool."""
|
|
||||||
text: str = Field(..., description="The text content to input")
|
|
||||||
window_title: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="Target window title (strongly recommended for reliable input). Examples: 'AI Chat', 'Notes', 'Terminal'"
|
|
||||||
)
|
|
||||||
selector: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="CSS selector for specific input element (advanced use only)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TextInputTool(BaseTool):
|
|
||||||
"""
|
|
||||||
A tool for smart text input to frontend windows.
|
|
||||||
|
|
||||||
This tool sends text input commands to the frontend via WebSocket
|
|
||||||
and waits for the frontend to execute and return results.
|
|
||||||
|
|
||||||
The actual text input operations are executed in the browser.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str = "text_input"
|
|
||||||
description: str = """
|
|
||||||
Inputs text into frontend windows in FlowNet-Lab.
|
|
||||||
|
|
||||||
IMPORTANT: You MUST specify window_title parameter for reliable text input.
|
|
||||||
The focused window does NOT necessarily have a focused input field.
|
|
||||||
|
|
||||||
How to use:
|
|
||||||
1. Use window_controller to focus/open the target window first
|
|
||||||
2. Use text_input with window_title parameter to send text
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- {"text": "Hello, how are you?", "window_title": "AI Chat"}
|
|
||||||
- {"text": "Meeting notes", "window_title": "Notes"}
|
|
||||||
- {"text": "user@example.com", "selector": "#email"} # Advanced: CSS selector
|
|
||||||
|
|
||||||
Returns a dictionary with operation status and target information.
|
|
||||||
"""
|
|
||||||
args_schema: Type[BaseModel] = TextInputInput
|
|
||||||
|
|
||||||
# Session ID is injected when creating the tool instance
|
|
||||||
session_id: str = "default"
|
|
||||||
|
|
||||||
def _run(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
window_title: Optional[str] = None,
|
|
||||||
selector: Optional[str] = None,
|
|
||||||
run_manager: CallbackManagerForToolRun | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Synchronous version - not supported.
|
|
||||||
|
|
||||||
Use the async version (_arun) instead.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError(
|
|
||||||
"TextInputTool requires async execution. "
|
|
||||||
"Use the async version (_arun) or ensure your agent supports async tools."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _arun(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
window_title: Optional[str] = None,
|
|
||||||
selector: Optional[str] = None,
|
|
||||||
tool_input: Optional[str] = None, # Legacy format support
|
|
||||||
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Async implementation: Send command to frontend and wait for result.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: The text content to input (from args_schema)
|
|
||||||
window_title: Target window title (from args_schema)
|
|
||||||
selector: CSS selector for specific element (from args_schema)
|
|
||||||
tool_input: Legacy JSON string format (backward compatibility)
|
|
||||||
run_manager: LangChain callback manager
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON string with execution result from frontend
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
session_manager = get_session_manager()
|
|
||||||
session = session_manager.get_session(self.session_id)
|
|
||||||
|
|
||||||
if not session:
|
|
||||||
logger.error(f"Session not found: {self.session_id}")
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": f"Session not found: {self.session_id}"
|
|
||||||
})
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Handle legacy tool_input format
|
|
||||||
if tool_input:
|
|
||||||
input_data = json.loads(tool_input)
|
|
||||||
text = input_data.get("text", text)
|
|
||||||
window_title = input_data.get("window_title", window_title)
|
|
||||||
selector = input_data.get("selector", selector)
|
|
||||||
|
|
||||||
# Validate text
|
|
||||||
if not text:
|
|
||||||
return json.dumps({"error": "Missing text field."})
|
|
||||||
|
|
||||||
# Build command data
|
|
||||||
command_data = {
|
|
||||||
"text": text,
|
|
||||||
}
|
|
||||||
if window_title:
|
|
||||||
command_data["window_title"] = window_title
|
|
||||||
if selector:
|
|
||||||
command_data["selector"] = selector
|
|
||||||
|
|
||||||
# Generate task_id
|
|
||||||
task_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Create Future for waiting on frontend result
|
|
||||||
future = session.create_future(task_id)
|
|
||||||
|
|
||||||
# Send tool_call to frontend
|
|
||||||
await session.websocket.send_json({
|
|
||||||
"type": "tool_call",
|
|
||||||
"task_id": task_id,
|
|
||||||
"tool": "text_input",
|
|
||||||
"args": command_data,
|
|
||||||
"session_id": self.session_id
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"TextInput: Sent tool_call to frontend, task_id={task_id}")
|
|
||||||
|
|
||||||
# Wait for frontend to execute and return result (timeout 30 seconds)
|
|
||||||
result = await asyncio.wait_for(future, timeout=30.0)
|
|
||||||
|
|
||||||
logger.info(f"TextInput: Received result from frontend, task_id={task_id}")
|
|
||||||
|
|
||||||
# Return result to Agent
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return json.dumps(result, ensure_ascii=False)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
session.pending_futures.pop(task_id, None)
|
|
||||||
logger.warning(f"TextInput: Timeout waiting for frontend, task_id={task_id}")
|
|
||||||
return json.dumps({
|
|
||||||
"status": "error",
|
|
||||||
"error": "Timeout waiting for frontend to execute text input",
|
|
||||||
"timeout_seconds": 30
|
|
||||||
})
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.error(f"Invalid JSON input: {e}")
|
|
||||||
return json.dumps({"error": f"Invalid JSON input: {e}"})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
session.pending_futures.pop(task_id, None)
|
|
||||||
logger.error(f"TextInput: Error waiting for result: {e}")
|
|
||||||
return json.dumps({"error": f"Error waiting for frontend response: {str(e)}"})
|
|
||||||
|
|
||||||
|
|
||||||
def create_text_input_tool(session_id: str) -> TextInputTool:
|
|
||||||
"""
|
|
||||||
Factory function to create TextInputTool with specific session_id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_id: Session identifier for WebSocket connection
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TextInputTool instance configured for this session
|
|
||||||
"""
|
|
||||||
return TextInputTool(session_id=session_id)
|
|
||||||
Loading…
x
Reference in New Issue
Block a user