Add tool composition UI with auto-dependency management

- Add ToolStepDialog for configuring tool steps (tool selection, input mapping, args)
- Add "Add Tool" button in Tool Builder alongside Add Prompt/Add Code
- Add ToolNode in flow graph visualization (purple puzzle piece icon)
- Auto-populate dependencies when adding ToolStep in GUI
- Add --auto-install flag for automatic runtime dependency installation
- Add check_dependencies() and auto_install_dependencies() functions
- Update runner to pass auto_install parameter through execution chain

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-17 05:27:54 -04:00
co-authored by Claude Opus 4.5
parent 05a2fae94c
commit 3f259449d4
5 changed files with 435 additions and 11 deletions
+216 -1
View File
@@ -9,7 +9,7 @@ from PySide6.QtWidgets import (
)
from PySide6.QtCore import Qt, QThread, Signal
from ...tool import PromptStep, CodeStep
from ...tool import PromptStep, CodeStep, ToolStep, list_tools, load_tool
from ...providers import load_providers, call_provider
from ...profiles import list_profiles
@@ -424,3 +424,218 @@ IMPORTANT: Return ONLY executable inline Python code. No function definitions, n
output_var=self.output_input.text().strip(),
name=name
)
class ToolStepDialog(QDialog):
"""Dialog for adding/editing tool steps (calling another tool)."""
def __init__(self, parent, step: ToolStep = None, available_vars: list = None, current_tool_name: str = None):
super().__init__(parent)
self.setWindowTitle("Edit Tool Step" if step else "Add Tool Step")
self.setMinimumSize(550, 500)
self._step = step
self._available_vars = available_vars or ["input"]
self._current_tool_name = current_tool_name # To prevent self-referencing
self._tool_args = {} # Cache of tool arguments
self._setup_ui()
if step:
self._load_step(step)
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Form
form = QFormLayout()
form.setSpacing(12)
# Step name (optional)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Optional display name")
form.addRow("Step name:", self.name_input)
# Tool selection
self.tool_combo = QComboBox()
self._populate_tools()
self.tool_combo.currentTextChanged.connect(self._on_tool_changed)
form.addRow("Tool:", self.tool_combo)
# Tool description (read-only)
self.tool_desc = QLabel("")
self.tool_desc.setStyleSheet("color: #718096; font-size: 11px;")
self.tool_desc.setWordWrap(True)
form.addRow("", self.tool_desc)
# Output variable
self.output_input = QLineEdit()
self.output_input.setPlaceholderText("tool_output")
self.output_input.setText("tool_output")
form.addRow("Output variable:", self.output_input)
# Provider override (optional)
self.provider_combo = QComboBox()
self.provider_combo.addItem("(use tool's default)")
providers = load_providers()
for provider in sorted(providers, key=lambda p: p.name):
self.provider_combo.addItem(provider.name)
form.addRow("Provider override:", self.provider_combo)
layout.addLayout(form)
# Input template
input_group = QGroupBox("Input")
input_layout = QVBoxLayout(input_group)
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
input_help = QLabel(f"Available variables: {vars_text}")
input_help.setStyleSheet("color: #718096; font-size: 11px;")
input_layout.addWidget(input_help)
self.input_template = QPlainTextEdit()
self.input_template.setPlaceholderText("{input}")
self.input_template.setPlainText("{input}")
self.input_template.setMaximumHeight(80)
input_layout.addWidget(self.input_template)
layout.addWidget(input_group)
# Tool arguments
self.args_group = QGroupBox("Tool Arguments")
self.args_layout = QFormLayout(self.args_group)
self.args_layout.setSpacing(8)
self._arg_inputs = {} # variable -> QLineEdit
layout.addWidget(self.args_group)
# Buttons
buttons = QHBoxLayout()
buttons.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("OK")
self.btn_ok.clicked.connect(self._validate_and_accept)
buttons.addWidget(self.btn_ok)
layout.addLayout(buttons)
# Trigger initial tool selection
if self.tool_combo.count() > 0:
self._on_tool_changed(self.tool_combo.currentText())
def _populate_tools(self):
"""Populate the tool dropdown."""
tools = list_tools()
for tool_name in sorted(tools):
# Skip the current tool to prevent self-referencing
if tool_name != self._current_tool_name:
self.tool_combo.addItem(tool_name)
def _on_tool_changed(self, tool_name: str):
"""Handle tool selection change."""
# Clear existing arg inputs
for widget in self._arg_inputs.values():
self.args_layout.removeRow(widget)
self._arg_inputs.clear()
if not tool_name:
self.tool_desc.setText("")
return
tool = load_tool(tool_name)
if not tool:
self.tool_desc.setText("(Tool not found)")
return
# Update description
self.tool_desc.setText(tool.description or "(No description)")
# Cache arguments
self._tool_args[tool_name] = tool.arguments
# Create input fields for each argument
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
for arg in tool.arguments:
line = QLineEdit()
line.setPlaceholderText(f"Default: {arg.default}" if arg.default else f"Variable: {vars_text}")
if arg.default:
line.setText(arg.default)
self._arg_inputs[arg.variable] = line
label = f"{arg.flag}:"
if arg.description:
label = f"{arg.flag} ({arg.description}):"
self.args_layout.addRow(label, line)
def _load_step(self, step: ToolStep):
"""Load step data into form."""
# Load name
if step.name:
self.name_input.setText(step.name)
# Select tool
idx = self.tool_combo.findText(step.tool)
if idx >= 0:
self.tool_combo.setCurrentIndex(idx)
else:
# Tool might be qualified name - try to add it
self.tool_combo.addItem(step.tool)
self.tool_combo.setCurrentText(step.tool)
self.output_input.setText(step.output_var)
self.input_template.setPlainText(step.input_template)
# Set provider override
if step.provider:
idx = self.provider_combo.findText(step.provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
# Set argument values (after tool is selected and args are loaded)
for var, value in step.args.items():
if var in self._arg_inputs:
self._arg_inputs[var].setText(str(value))
def _validate_and_accept(self):
"""Validate and accept."""
tool = self.tool_combo.currentText().strip()
if not tool:
self.tool_combo.setFocus()
return
output = self.output_input.text().strip()
if not output:
self.output_input.setFocus()
return
self.accept()
def get_step(self) -> ToolStep:
"""Get the step from form data."""
# Collect arguments
args = {}
for var, line in self._arg_inputs.items():
value = line.text().strip()
if value:
args[var] = value
# Get provider override
provider = self.provider_combo.currentText()
if provider == "(use tool's default)":
provider = None
# Get name, use None if empty
name = self.name_input.text().strip() or None
return ToolStep(
tool=self.tool_combo.currentText(),
output_var=self.output_input.text().strip(),
input_template=self.input_template.toPlainText() or "{input}",
args=args,
provider=provider,
name=name
)