From 61a44d004fcae6c06cf5e0e54ba364b3d1e9c9dc Mon Sep 17 00:00:00 2001 From: Fede Luzzi Date: Wed, 3 Jun 2026 16:49:52 -0300 Subject: [PATCH] feat(core,grpc): add regex support for node expectations and secure thread context sharing - Implement dynamic regex matching fallback (re.search) in `node.test` with safe handling of invalid patterns. - Refactor terminal window resizing (setwinsize) to trigger only on non-router devices and handle SIGWINCH re-renders. - Introduce `contextvars` context copying for background worker threads in gRPC execution and AI servicers. - Add unit tests for regex validation, malformed expression fallbacks, and variable formatting in node testing. - Optimize Playbook Builder AI guidelines for single-task test evaluations. - Unify codebase comments to English. --- connpy/_version.py | 2 +- connpy/ai.py | 25 ++--- connpy/cli/ai_handler.py | 8 +- connpy/cli/terminal_ui.py | 22 ++-- connpy/core.py | 72 ++++++++----- connpy/grpc_layer/server.py | 25 +++-- connpy/tests/test_core.py | 52 +++++++++ docs/connpy/cli/ai_handler.html | 16 +-- docs/connpy/cli/terminal_ui.html | 44 ++++---- docs/connpy/grpc_layer/server.html | 25 +++-- docs/connpy/index.html | 162 ++++++++++++++++++----------- 11 files changed, 295 insertions(+), 158 deletions(-) diff --git a/connpy/_version.py b/connpy/_version.py index 79a961b..7f229cf 100644 --- a/connpy/_version.py +++ b/connpy/_version.py @@ -1 +1 @@ -__version__ = "6.0.1" +__version__ = "6.0.2" diff --git a/connpy/ai.py b/connpy/ai.py index 62aeeda..2deb2df 100755 --- a/connpy/ai.py +++ b/connpy/ai.py @@ -17,7 +17,7 @@ def _init_litellm(): global _litellm_initialized if not _litellm_initialized: import litellm - # Silenciar feedback de litellm + # Silence litellm feedback litellm.suppress_debug_info = True litellm.set_verbose = False _litellm_initialized = True @@ -117,7 +117,7 @@ class ai: self.one_shot = kwargs.get("one_shot", False) - # 1. Cargar configuración genérica con herencia/merge global + # 1. Load generic configuration with global inheritance/merge if hasattr(self.config, "get_effective_setting"): aiconfig = self.config.get_effective_setting("ai", {}) else: @@ -160,7 +160,7 @@ class ai: custom_trusted = [c.strip() for c in custom_trusted.split(",") if c.strip()] self.safe_commands = list(self.SAFE_COMMANDS) + (custom_trusted if isinstance(custom_trusted, list) else []) - # Límites + # Limits self.max_history = 30 self.max_truncate = 50000 self.soft_limit_iterations = 20 # Show warning and suggest Ctrl+C @@ -197,7 +197,7 @@ class ai: self.session_id = getattr(self.config, "session_id", None) self.session_path = os.path.join(self.sessions_dir, f"{self.session_id}.json") if self.session_id else None - # Prompts base agnósticos + # Agnostic base prompts architect_instructions = "" if self.has_architect: architect_instructions = """ @@ -737,7 +737,7 @@ class ai: def _engineer_loop(self, task, status=None, debug=False, chat_history=None): """Internal loop where the Engineer executes technical tasks for the Architect.""" - # Optimización de caché para el Ingeniero (Solo para Anthropic directo, Vertex tiene reglas distintas) + # Cache optimization for the Engineer (Only for direct Anthropic, Vertex has different rules) if "claude" in self.engineer_model.lower() and "vertex" not in self.engineer_model.lower(): messages = [{"role": "system", "content": [{"type": "text", "text": self.engineer_system_prompt, "cache_control": {"type": "ephemeral"}}]}] else: @@ -796,7 +796,7 @@ class ai: for tc in resp_msg.tool_calls: fn, args = tc.function.name, json.loads(tc.function.arguments) - # Notificación en tiempo real de la tarea técnica (Only if not in Architect loop) + # Real-time notification of the technical task (Only if not in Architect loop) if status and not chat_history: s_text = "" if fn == "list_nodes": s_text = f"[ai_status]Engineer: [SEARCH] {args.get('filter_pattern','.*')}" @@ -1051,7 +1051,7 @@ class ai: usage = {"input": 0, "output": 0, "total": 0} - # 1. Selector de Rol inicial (Sticky Brain) + # 1. Initial Role Selector (Sticky Brain) explicit_architect = re.match(r'^(architect|arquitecto|@architect)[:\s]', user_input, re.I) explicit_engineer = re.match(r'^(engineer|ingeniero|@engineer)[:\s]', user_input, re.I) @@ -1060,7 +1060,7 @@ class ai: elif explicit_engineer: current_brain = "engineer" else: - # Sticky Brain: Detectar si el Arquitecto estaba al mando en el historial reciente + # Sticky Brain: Detect if the Architect was in control in recent history is_architect_active = False for msg in reversed(chat_history[-5:]): tcs = msg.get('tool_calls') if isinstance(msg, dict) else getattr(msg, 'tool_calls', None) @@ -1074,7 +1074,7 @@ class ai: if is_architect_active: break current_brain = "architect" if is_architect_active else "engineer" - # 2. Preparación de mensajes y limpieza + # 2. Message preparation and cleaning clean_input = re.sub(r'^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+', '', user_input, flags=re.IGNORECASE).strip() system_prompt = self.architect_system_prompt if current_brain == "architect" else self.engineer_system_prompt @@ -1083,13 +1083,13 @@ class ai: key = self.architect_key if current_brain == "architect" else self.engineer_key current_auth = self.architect_auth if current_brain == "architect" else self.engineer_auth - # Estructura optimizada para Prompt Caching (Solo para Anthropic directo, Vertex tiene reglas distintas) + # Optimized structure for Prompt Caching (Only for direct Anthropic, Vertex has different rules) if "claude" in model.lower() and "vertex" not in model.lower(): messages = [{"role": "system", "content": [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]}] else: messages = [{"role": "system", "content": system_prompt}] - # Interleaving de historial + # History interleaving last_role = "system" # Sanitize history if the current target model is not compatible with cache_control history_to_process = chat_history[-self.max_history:] @@ -1109,7 +1109,7 @@ class ai: if last_role == 'user': messages[-1]['content'] += "\n" + clean_input else: messages.append({"role": "user", "content": clean_input}) - # 3. Bucle de ejecución + # 3. Execution loop iteration = 0 try: # Set up remote interrupt callback if bridge is provided @@ -1683,6 +1683,7 @@ Guidelines: 4. If `validate_playbook` returns errors, fix them in your YAML and validate again before responding to the user. 5. When the playbook is complete, validated, and the user approves it, you MUST call the `return_playbook` tool to return the final YAML. 6. All text responses must be in the same language the user uses in their prompt. +7. EFFICIENT TESTING: When the user asks to verify or check a condition (e.g. verify OS version, check port status), a single task with `action: 'test'` is completely self-sufficient. DO NOT generate an `action: 'run'` task followed by an `action: 'test'` task to perform the same check. The `test` action executes the commands, verifies the expectation, and displays the output if `output: stdout` is configured. """ PLAYBOOK_BUILDER_TOOLS = [ diff --git a/connpy/cli/ai_handler.py b/connpy/cli/ai_handler.py index c16ace0..fabc377 100644 --- a/connpy/cli/ai_handler.py +++ b/connpy/cli/ai_handler.py @@ -44,7 +44,7 @@ class AIHandler: if args.mcp is not None: return self.configure_mcp(args) - # Determinar session_id para retomar + # Determine session_id to resume session_id = None if args.resume: sessions, _ = self.app.services.ai.list_sessions() @@ -54,8 +54,8 @@ class AIHandler: elif args.session: session_id = args.session[0] - # Configurar argumentos adicionales para el servicio de AI - # Prioridad: CLI Args > Configuración Local + # Configure additional arguments for the AI service + # Priority: CLI Args > Local Config settings = self.app.services.config_svc.get_settings().get("ai", {}) arguments = {} @@ -83,7 +83,7 @@ class AIHandler: printer.warning("Architect API key/auth not configured. Architect will be unavailable.") printer.info("Use 'connpy config --architect-api-key ' or 'connpy config --architect-auth ' to enable it.") - # El resto de la interacción el CLI la maneja con el agente subyacente + # The rest of the interaction is handled by the CLI with the underlying agent self.app.myai = self.app.services.ai self.ai_overrides = arguments diff --git a/connpy/cli/terminal_ui.py b/connpy/cli/terminal_ui.py index 77a434a..5efb670 100644 --- a/connpy/cli/terminal_ui.py +++ b/connpy/cli/terminal_ui.py @@ -87,14 +87,14 @@ class CopilotInterface: } # 1. Visual Separation - self.console.print("") # Salto de línea real + self.console.print("") # Real line break self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan")) self.console.print(Panel( "[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n" "Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]", border_style="cyan" )) - self.console.print("\n") # Pequeño espacio antes del prompt del copilot + self.console.print("\n") # Small space before the copilot prompt bindings = KeyBindings() @bindings.add('c-up') @@ -161,7 +161,7 @@ class CopilotInterface: if app and app.current_buffer: text = app.current_buffer.text - # Solo mostrar ayuda de comandos si estamos escribiendo el primer comando y no hay espacios + # Only show command help if typing the first command and there are no spaces if text.startswith('/') and ' ' not in text: commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear'] matches = [c for c in commands if c.startswith(text.lower())] @@ -176,19 +176,19 @@ class CopilotInterface: idx = max(0, state['total_cmds'] - state['context_cmd']) def clean_preview(text): - # Limpia saltos de línea y el prompt inicial (todo hasta #, > o $) para que quede solo el comando + # Clean newlines and the initial prompt (all up to #, > or $) to leave only the command original = text.strip().replace('\r', '').replace('\n', ' ') cleaned = re.sub(r'^.*?[#>\$]\s*', '', original) - # Si limpiar el prompt nos deja con un string vacío (ej: era solo "iol#"), devolvemos el original + # If cleaning the prompt leaves us with an empty string (e.g. it was just "iol#"), return the original return cleaned if cleaned else original if state['context_mode'] == self.mode_range: range_blocks = blocks[idx:] - # Si hay más de un bloque, el último es siempre el prompt vacío/actual. Lo omitimos visualmente. + # If there is more than one block, the last one is always the empty/current prompt. We omit it visually. if len(range_blocks) > 1: range_blocks = range_blocks[:-1] - # Limpiar y truncar comandos muy largos para que no rompan la UI + # Clean and truncate very long commands so they don't break the UI previews = [] for b in range_blocks: p = clean_preview(b[2]) @@ -266,8 +266,8 @@ class CopilotInterface: style=ui_style ) try: - # Usamos un try/finally interno para asegurar que si algo falla en prompt_async, - # no nos quedemos con la terminal en un estado extraño. + # We use an internal try/finally to ensure that if something fails in prompt_async, + # we don't leave the terminal in a strange state. question = await session.prompt_async( get_prompt_text, key_bindings=bindings, @@ -299,12 +299,12 @@ class CopilotInterface: except: pass asyncio.create_task(delayed_refresh()) - # Mover el cursor arriba y limpiar la línea para que el nuevo prompt reemplace al anterior + # Move the cursor up and clean the line so the new prompt replaces the previous one sys.stdout.write('\x1b[1A\x1b[2K') sys.stdout.flush() continue else: - # Limpiar el mensaje de la barra cuando se hace una pregunta real + # Clean the toolbar message when a real question is asked state['toolbar_msg'] = '' clean_question = directive.get("clean_prompt", question) diff --git a/connpy/core.py b/connpy/core.py index 5b46270..e19d9a1 100755 --- a/connpy/core.py +++ b/connpy/core.py @@ -27,10 +27,10 @@ def copilot_terminal_mode(): try: old_settings = termios.tcgetattr(fd) - # Primero pasamos a raw mode absoluto para matar ISIG, ICANON, ECHO, etc. + # First we switch to absolute raw mode to disable ISIG, ICANON, ECHO, etc. tty.setraw(fd) - # Luego rehabilitamos OPOST para que rich.Live se dibuje correctamente + # Then we re-enable OPOST so rich.Live renders correctly new_settings = termios.tcgetattr(fd) new_settings[1] = new_settings[1] | termios.OPOST termios.tcsetattr(fd, termios.TCSANOW, new_settings) @@ -686,12 +686,12 @@ class node: # Get raw bytes from BytesIO raw_bytes = self.mylog.getvalue() - # Detener el lector de la terminal para que prompt_toolkit (en run_session) - # tenga control exclusivo del stdin sin interferencias de LocalStream. + # Stop terminal reading so prompt_toolkit (in run_session) + # has exclusive control of stdin without LocalStream interference. if hasattr(stream, 'stop_reading'): stream.stop_reading() elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'): - # Fallback si no tiene el método (en LocalStream) + # Fallback if the method is missing (in LocalStream) stream._loop.remove_reader(stream.stdin_fd) try: @@ -708,7 +708,7 @@ class node: break finally: print("\033[2m Returning to session...\033[0m", flush=True) - # Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet + # Restart terminal reading to return to interactive SSH/Telnet mode if hasattr(stream, 'start_reading'): stream.start_reading() elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'): @@ -776,14 +776,6 @@ class node: port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else "" logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}") - # Attempt to set the terminal size - try: - self.child.setwinsize(65535, 65535) - except Exception: - try: - self.child.setwinsize(10000, 10000) - except Exception: - pass if "prompt" in self.tags: prompt = self.tags["prompt"] expects = [prompt, pexpect.EOF, pexpect.TIMEOUT] @@ -804,6 +796,20 @@ class node: self.status = 1 return self.output result = self.child.expect(expects, timeout = timeout) + # Only set terminal size on devices without a + # screen_length_command (e.g. Linux/bash servers). + # Routers already disable pagination via that command. + # After setwinsize, consume any SIGWINCH re-render + # prompt (~40ms on bash) with a short timeout. + if c == commands[0] and "screen_length_command" not in self.tags: + try: + self.child.setwinsize(65535, 65535) + except Exception: + try: + self.child.setwinsize(10000, 10000) + except Exception: + pass + self.child.expect(expects, timeout = 1) self.child.sendline(c) if result == 2: break @@ -886,14 +892,6 @@ class node: port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else "" logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}") - # Attempt to set the terminal size - try: - self.child.setwinsize(65535, 65535) - except Exception: - try: - self.child.setwinsize(10000, 10000) - except Exception: - pass if "prompt" in self.tags: prompt = self.tags["prompt"] expects = [prompt, pexpect.EOF, pexpect.TIMEOUT] @@ -915,6 +913,15 @@ class node: self.status = 1 return self.output result = self.child.expect(expects, timeout = timeout) + if c == commands[0] and "screen_length_command" not in self.tags: + try: + self.child.setwinsize(65535, 65535) + except Exception: + try: + self.child.setwinsize(10000, 10000) + except Exception: + pass + self.child.expect(expects, timeout = 1) self.child.sendline(c) if result == 2: break @@ -940,13 +947,28 @@ class node: if vars is not None: e = e.format(**vars) updatedprompt = re.sub(r'(?