From 3f259449d45663afac198a079a670b8c23287781 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 17 Jan 2026 05:27:54 -0400 Subject: [PATCH] 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 --- src/cmdforge/gui/dialogs/step_dialog.py | 217 +++++++++++++++++++- src/cmdforge/gui/pages/tool_builder_page.py | 65 +++++- src/cmdforge/gui/widgets/flow_graph.py | 50 ++++- src/cmdforge/gui/widgets/icons.py | 60 ++++++ src/cmdforge/runner.py | 54 ++++- 5 files changed, 435 insertions(+), 11 deletions(-) diff --git a/src/cmdforge/gui/dialogs/step_dialog.py b/src/cmdforge/gui/dialogs/step_dialog.py index c5d02ee..7e453ad 100644 --- a/src/cmdforge/gui/dialogs/step_dialog.py +++ b/src/cmdforge/gui/dialogs/step_dialog.py @@ -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 + ) diff --git a/src/cmdforge/gui/pages/tool_builder_page.py b/src/cmdforge/gui/pages/tool_builder_page.py index c169aaa..3d4d95e 100644 --- a/src/cmdforge/gui/pages/tool_builder_page.py +++ b/src/cmdforge/gui/pages/tool_builder_page.py @@ -10,10 +10,10 @@ from PySide6.QtWidgets import ( from PySide6.QtCore import Qt from ...tool import ( - Tool, ToolArgument, PromptStep, CodeStep, + Tool, ToolArgument, PromptStep, CodeStep, ToolStep, load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES ) -from ..widgets.icons import get_prompt_icon, get_code_icon +from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon class ToolBuilderPage(QWidget): @@ -190,6 +190,10 @@ class ToolBuilderPage(QWidget): self.btn_add_code.clicked.connect(self._add_code_step) steps_btns.addWidget(self.btn_add_code) + self.btn_add_tool = QPushButton("Add Tool") + self.btn_add_tool.clicked.connect(self._add_tool_step) + steps_btns.addWidget(self.btn_add_tool) + self.btn_edit_step = QPushButton("Edit") self.btn_edit_step.setObjectName("secondary") self.btn_edit_step.clicked.connect(self._edit_step) @@ -379,6 +383,9 @@ class ToolBuilderPage(QWidget): if arg.variable: available_vars.add(arg.variable) + # Count for default naming + tool_count = 0 + for i, step in enumerate(self._tool.steps, 1): if isinstance(step, PromptStep): prompt_count += 1 @@ -391,6 +398,11 @@ class ToolBuilderPage(QWidget): step_name = step.name if step.name else f"Code {code_count}" text = f"{step_name} [python] → ${step.output_var}" icon = get_code_icon(20) + elif isinstance(step, ToolStep): + tool_count += 1 + step_name = step.name if step.name else f"Tool {tool_count}" + text = f"{step_name} [{step.tool}] → ${step.output_var}" + icon = get_tool_icon(20) else: text = f"Unknown step" icon = None @@ -430,6 +442,7 @@ class ToolBuilderPage(QWidget): # Count steps by type for default naming prompt_count = 0 code_count = 0 + tool_count = 0 # Track available variables for dependency checking available_vars = {"input"} @@ -449,6 +462,11 @@ class ToolBuilderPage(QWidget): step_name = step.name if step.name else f"Code {code_count}" text = f"{step_name} [python] → ${step.output_var}" icon = get_code_icon(20) + elif isinstance(step, ToolStep): + tool_count += 1 + step_name = step.name if step.name else f"Tool {tool_count}" + text = f"{step_name} [{step.tool}] → ${step.output_var}" + icon = get_tool_icon(20) else: text = f"Unknown step" icon = None @@ -508,6 +526,23 @@ class ToolBuilderPage(QWidget): del self._tool.arguments[idx] self._refresh_arguments() + def _add_tool_dependency(self, tool_ref: str): + """Add a tool reference to the dependencies list if not already present. + + Args: + tool_ref: Tool reference (e.g., 'my-tool' or 'owner/tool-name') + """ + if not self._tool or not tool_ref: + return + + # Initialize dependencies list if needed + if not hasattr(self._tool, 'dependencies') or self._tool.dependencies is None: + self._tool.dependencies = [] + + # Add if not already present + if tool_ref not in self._tool.dependencies: + self._tool.dependencies.append(tool_ref) + def _get_available_vars(self, up_to_step: int = -1) -> list: """Get available variables for a step. @@ -559,6 +594,21 @@ class ToolBuilderPage(QWidget): self._tool.steps.append(step) self._refresh_steps() + def _add_tool_step(self): + """Add a tool step (call another tool).""" + from ..dialogs.step_dialog import ToolStepDialog + available_vars = self._get_available_vars() + current_name = self._tool.name if self._tool else None + dialog = ToolStepDialog(self, available_vars=available_vars, current_tool_name=current_name) + if dialog.exec(): + step = dialog.get_step() + if not self._tool: + self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}") + self._tool.steps.append(step) + # Auto-add to dependencies if not already present + self._add_tool_dependency(step.tool) + self._refresh_steps() + def _edit_step(self): """Edit selected step from list view.""" items = self.steps_list.selectedItems() @@ -578,11 +628,20 @@ class ToolBuilderPage(QWidget): from ..dialogs.step_dialog import CodeStepDialog available_vars = self._get_available_vars(up_to_step=idx) dialog = CodeStepDialog(self, step, available_vars=available_vars) + elif isinstance(step, ToolStep): + from ..dialogs.step_dialog import ToolStepDialog + available_vars = self._get_available_vars(up_to_step=idx) + current_name = self._tool.name if self._tool else None + dialog = ToolStepDialog(self, step, available_vars=available_vars, current_tool_name=current_name) else: return if dialog.exec(): - self._tool.steps[idx] = dialog.get_step() + new_step = dialog.get_step() + self._tool.steps[idx] = new_step + # Auto-add to dependencies if it's a ToolStep + if isinstance(new_step, ToolStep): + self._add_tool_dependency(new_step.tool) self._refresh_steps() def _delete_step(self): diff --git a/src/cmdforge/gui/widgets/flow_graph.py b/src/cmdforge/gui/widgets/flow_graph.py index aa6efd9..b891608 100644 --- a/src/cmdforge/gui/widgets/flow_graph.py +++ b/src/cmdforge/gui/widgets/flow_graph.py @@ -8,10 +8,10 @@ from PySide6.QtGui import QKeyEvent, QAction from NodeGraphQt import NodeGraph, BaseNode -from ...tool import Tool, PromptStep, CodeStep +from ...tool import Tool, PromptStep, CodeStep, ToolStep from .icons import ( get_prompt_icon_path, get_code_icon_path, - get_input_icon_path, get_output_icon_path + get_input_icon_path, get_output_icon_path, get_tool_icon_path ) @@ -105,6 +105,33 @@ class CodeNode(CmdForgeBaseNode): self.output_ports()[0]._name = step.output_var or 'result' +class ToolNode(CmdForgeBaseNode): + """Node representing a tool step (calling another tool).""" + + NODE_NAME = 'Tool' + + def __init__(self): + super().__init__() + self.set_color(159, 122, 234) # Purple + self.set_icon(get_tool_icon_path(16)) + self.add_input('in', color=(220, 180, 250)) + self.add_output('out', color=(250, 180, 220)) + + # Add properties + self.add_text_input('tool_ref', 'Tool', text='') + self.add_text_input('output_var', 'Output', text='tool_output') + + def set_step(self, step: ToolStep, index: int): + """Configure node from a ToolStep.""" + self._step_data = step + self._step_index = index + self.set_property('tool_ref', step.tool or '') + self.set_property('output_var', step.output_var or 'tool_output') + # Update output port name + if self.output_ports(): + self.output_ports()[0]._name = step.output_var or 'tool_output' + + class OutputNode(CmdForgeBaseNode): """Node representing tool output.""" @@ -168,6 +195,7 @@ class FlowGraphWidget(QWidget): self._graph.register_node(InputNode) self._graph.register_node(PromptNode) self._graph.register_node(CodeNode) + self._graph.register_node(ToolNode) self._graph.register_node(OutputNode) # Connect signals @@ -265,6 +293,7 @@ class FlowGraphWidget(QWidget): # Count steps by type for default naming prompt_count = 0 code_count = 0 + tool_count = 0 for i, step in enumerate(self._tool.steps or []): if isinstance(step, PromptStep): @@ -287,6 +316,16 @@ class FlowGraphWidget(QWidget): pos=[x_pos, 0] ) node.set_step(step, i) + elif isinstance(step, ToolStep): + tool_count += 1 + # Use custom name if set, otherwise default to "Tool N" (per type) + node_name = step.name if step.name else f'Tool {tool_count}' + node = self._graph.create_node( + 'cmdforge.ToolNode', + name=node_name, + pos=[x_pos, 0] + ) + node.set_step(step, i) else: continue @@ -443,7 +482,12 @@ class FlowGraphWidget(QWidget): def _on_node_double_clicked(self, node): """Handle node double-click.""" if hasattr(node, '_step_index') and node._step_index >= 0: - step_type = 'prompt' if isinstance(node, PromptNode) else 'code' + if isinstance(node, PromptNode): + step_type = 'prompt' + elif isinstance(node, ToolNode): + step_type = 'tool' + else: + step_type = 'code' self.node_double_clicked.emit(node._step_index, step_type) def _on_nodes_deleted(self, nodes): diff --git a/src/cmdforge/gui/widgets/icons.py b/src/cmdforge/gui/widgets/icons.py index 984533e..b7599f9 100644 --- a/src/cmdforge/gui/widgets/icons.py +++ b/src/cmdforge/gui/widgets/icons.py @@ -273,3 +273,63 @@ def get_output_icon(size: int = 24) -> QIcon: if key not in _icon_cache: _icon_cache[key] = create_output_icon(size) return _icon_cache[key] + + +def create_tool_icon(size: int = 24, color: QColor = None) -> QIcon: + """Create a puzzle piece icon for tool steps (calling another tool). + + Args: + size: Icon size in pixels + color: Icon color (defaults to purple #9f7aea) + """ + if color is None: + color = QColor(159, 122, 234) # Purple + + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + + pen = QPen(color, 1.5) + painter.setPen(pen) + painter.setBrush(QBrush(color)) + + # Draw a simple puzzle piece shape + margin = size * 0.12 + w = size - 2 * margin + h = size - 2 * margin + + path = QPainterPath() + # Start from top-left, going clockwise + path.moveTo(margin, margin + h * 0.3) + # Top edge with bump + path.lineTo(margin + w * 0.35, margin + h * 0.3) + path.arcTo(QRectF(margin + w * 0.35 - w * 0.1, margin, w * 0.3, h * 0.3), 180, -180) + path.lineTo(margin + w, margin + h * 0.3) + # Right edge + path.lineTo(margin + w, margin + h * 0.7) + # Bottom edge with notch + path.lineTo(margin + w * 0.65, margin + h * 0.7) + path.arcTo(QRectF(margin + w * 0.35, margin + h * 0.7, w * 0.3, h * 0.3), 0, 180) + path.lineTo(margin, margin + h * 0.7) + # Left edge back to start + path.closeSubpath() + + painter.drawPath(path) + painter.end() + + return QIcon(pixmap) + + +def get_tool_icon(size: int = 24) -> QIcon: + """Get cached tool icon.""" + key = ('tool', size) + if key not in _icon_cache: + _icon_cache[key] = create_tool_icon(size) + return _icon_cache[key] + + +def get_tool_icon_path(size: int = 16) -> str: + """Get file path to tool icon (for NodeGraphQt).""" + return _get_icon_path('tool', size, create_tool_icon) diff --git a/src/cmdforge/runner.py b/src/cmdforge/runner.py index ad5bff5..3535593 100644 --- a/src/cmdforge/runner.py +++ b/src/cmdforge/runner.py @@ -7,7 +7,7 @@ from typing import Optional from .tool import Tool, PromptStep, CodeStep, ToolStep from .providers import call_provider, mock_provider -from .resolver import resolve_tool, ToolNotFoundError, ToolSpec +from .resolver import resolve_tool, ToolNotFoundError, ToolSpec, install_from_registry from .manifest import load_manifest from .profiles import load_profile @@ -15,6 +15,36 @@ from .profiles import load_profile MAX_TOOL_DEPTH = 10 +def auto_install_dependencies(tool: Tool, verbose: bool = False) -> list[str]: + """ + Automatically install missing dependencies for a tool. + + Args: + tool: Tool to check and install dependencies for + verbose: Show installation progress + + Returns: + List of successfully installed tool references + """ + missing = check_dependencies(tool) + if not missing: + return [] + + installed = [] + for dep in missing: + try: + if verbose: + print(f"[auto-install] Installing {dep}...", file=sys.stderr) + install_from_registry(dep) + installed.append(dep) + if verbose: + print(f"[auto-install] Installed {dep}", file=sys.stderr) + except Exception as e: + print(f"Warning: Failed to auto-install {dep}: {e}", file=sys.stderr) + + return installed + + def check_dependencies(tool: Tool, checked: set = None) -> list[str]: """ Check if all dependencies for a tool are available. @@ -293,6 +323,7 @@ def run_tool( dry_run: bool = False, show_prompt: bool = False, verbose: bool = False, + auto_install: bool = False, _depth: int = 0, _call_stack: Optional[list] = None ) -> tuple[str, int]: @@ -307,6 +338,7 @@ def run_tool( dry_run: Just show what would happen show_prompt: Show prompts in addition to output verbose: Show debug info + auto_install: Automatically install missing dependencies _depth: Internal recursion depth tracker _call_stack: Internal call stack for error reporting @@ -322,8 +354,19 @@ def run_tool( if _depth == 0 and (tool.dependencies or any(isinstance(s, ToolStep) for s in tool.steps)): missing = check_dependencies(tool) if missing: - print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr) - print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr) + if auto_install: + # Try to auto-install missing dependencies + installed = auto_install_dependencies(tool, verbose=verbose) + # Re-check after installation + still_missing = check_dependencies(tool) + if still_missing: + print(f"Warning: Could not install all dependencies. Missing: {', '.join(still_missing)}", file=sys.stderr) + elif installed: + print(f"Auto-installed dependencies: {', '.join(installed)}", file=sys.stderr) + else: + print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr) + print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr) + print(f"Or use --auto-install to install automatically", file=sys.stderr) # Continue anyway - the actual step execution will fail with a better error # Initialize variables with input and arguments @@ -448,6 +491,8 @@ def create_argument_parser(tool: Tool) -> argparse.ArgumentParser: help="Override provider (e.g., --provider mock)") parser.add_argument("-v", "--verbose", action="store_true", help="Show debug information") + parser.add_argument("--auto-install", action="store_true", + help="Automatically install missing tool dependencies") # Tool-specific flags from arguments for arg in tool.arguments: @@ -527,7 +572,8 @@ def main(): provider_override=effective_provider, dry_run=args.dry_run, show_prompt=args.show_prompt, - verbose=args.verbose + verbose=args.verbose, + auto_install=args.auto_install ) # Write output