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.
This commit is contained in:
2026-06-03 16:49:52 -03:00
parent 2b8e637298
commit 61a44d004f
11 changed files with 295 additions and 158 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "6.0.1"
__version__ = "6.0.2"
+13 -12
View File
@@ -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 = [
+4 -4
View File
@@ -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 <key>' or 'connpy config --architect-auth <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
+11 -11
View File
@@ -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)
+46 -24
View File
@@ -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'(?<!\\)\$', '', prompt)
newpattern = f".*({updatedprompt}).*{e}.*"
cleaned_output = output
try:
newpattern = f".*({updatedprompt}).*{e}.*"
cleaned_output = re.sub(newpattern, '', cleaned_output)
except re.error:
try:
escaped_e = re.escape(e)
newpattern = f".*({updatedprompt}).*{escaped_e}.*"
cleaned_output = re.sub(newpattern, '', cleaned_output)
except re.error:
pass
if e in cleaned_output:
self.result[e] = True
else:
self.result[e]= False
try:
if re.search(e, cleaned_output):
self.result[e] = True
else:
self.result[e] = False
except re.error:
self.result[e] = False
self.status = 0
return self.result
if result == 2:
+17 -8
View File
@@ -719,7 +719,9 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
finally:
q.put(None)
threading.Thread(target=_worker, daemon=True).start()
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = q.get()
@@ -768,7 +770,9 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
finally:
q.put(None)
threading.Thread(target=_worker, daemon=True).start()
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = q.get()
@@ -953,6 +957,7 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
def _handle_chat_stream(self, request_iterator, context, service_method):
import queue
import threading
import contextvars
chunk_queue = queue.Queue()
request_queue = queue.Queue()
@@ -985,6 +990,7 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
session_id=session_id,
debug=debug,
status=bridge,
console=bridge,
confirm_handler=bridge.confirm,
chunk_callback=callback,
trust=trust,
@@ -1046,10 +1052,10 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
if req.HasField("engineer_auth"): overrides["engineer_auth"] = from_struct(req.engineer_auth)
if req.HasField("architect_auth"): overrides["architect_auth"] = from_struct(req.architect_auth)
# Start AI in its own thread so we can keep listening for interrupts
# Start AI in its own thread with a fresh copy of context so we can keep listening for interrupts
ctx_ai = contextvars.copy_context()
ai_thread = threading.Thread(
target=run_ai_task,
args=(req.input_text, req.session_id, req.debug, overrides, req.trust),
target=lambda: ctx_ai.run(run_ai_task, req.input_text, req.session_id, req.debug, overrides, req.trust),
daemon=True
)
ai_thread.start()
@@ -1061,8 +1067,9 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
# When client closes stream, send sentinel
chunk_queue.put((None, None))
# Start listening for client requests/signals
threading.Thread(target=request_listener, daemon=True).start()
# Start listening for client requests/signals with a copied context
ctx_listener = contextvars.copy_context()
threading.Thread(target=lambda: ctx_listener.run(request_listener), daemon=True).start()
# Main response loop (yields to gRPC)
while True:
@@ -1109,7 +1116,9 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
finally:
chunk_queue.put((None, None))
threading.Thread(target=_worker, daemon=True).start()
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = chunk_queue.get()
+52
View File
@@ -338,6 +338,58 @@ class TestNodeTest:
assert isinstance(result, dict)
assert result.get("1.1.1.1") == False
def test_test_expected_regex(self, mock_pexpect):
"""Regex in expected matches correctly."""
child = mock_pexpect["child"]
child.expect.return_value = 0
from connpy.core import node
n = node("router1", "10.0.0.1", user="admin", password="")
with patch.object(n, '_connect', return_value=True):
n.child = child
n.mylog = io.BytesIO(b"Debian version 12.5")
with patch.object(n, '_logclean', return_value="Debian version 12.5"):
result = n.test(["cat /etc/debian_version"], "version \\d+\\.\\d+")
assert isinstance(result, dict)
assert result.get("version \\d+\\.\\d+") == True
def test_test_expected_invalid_regex(self, mock_pexpect):
"""Malformed regex defaults to literal matching safely."""
child = mock_pexpect["child"]
child.expect.return_value = 0
from connpy.core import node
n = node("router1", "10.0.0.1", user="admin", password="")
with patch.object(n, '_connect', return_value=True):
n.child = child
# (invalid is a malformed regex (missing closing paren), but matches literally
n.mylog = io.BytesIO(b"some (invalid text")
with patch.object(n, '_logclean', return_value="some (invalid text"):
result = n.test(["echo"], "(invalid")
assert isinstance(result, dict)
assert result.get("(invalid") == True
def test_test_expected_with_vars(self, mock_pexpect):
"""Expected output formats variables properly."""
child = mock_pexpect["child"]
child.expect.return_value = 0
from connpy.core import node
n = node("router1", "10.0.0.1", user="admin", password="")
with patch.object(n, '_connect', return_value=True):
n.child = child
n.mylog = io.BytesIO(b"Debian version 12")
with patch.object(n, '_logclean', return_value="Debian version 12"):
result = n.test(["echo"], "version {version_num}", vars={"version_num": "12"})
assert isinstance(result, dict)
assert result.get("version 12") == True
# =========================================================================
# nodes (parallel) tests
+8 -8
View File
@@ -90,7 +90,7 @@ el.replaceWith(d);
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()
@@ -100,8 +100,8 @@ el.replaceWith(d);
elif args.session:
session_id = args.session[0]
# Configurar argumentos adicionales para el servicio de AI
# Prioridad: CLI Args &gt; Configuración Local
# Configure additional arguments for the AI service
# Priority: CLI Args &gt; Local Config
settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {})
arguments = {}
@@ -129,7 +129,7 @@ el.replaceWith(d);
printer.warning(&#34;Architect API key/auth not configured. Architect will be unavailable.&#34;)
printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; or &#39;connpy config --architect-auth &lt;auth&gt;&#39; to enable it.&#34;)
# 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
@@ -502,7 +502,7 @@ el.replaceWith(d);
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()
@@ -512,8 +512,8 @@ el.replaceWith(d);
elif args.session:
session_id = args.session[0]
# Configurar argumentos adicionales para el servicio de AI
# Prioridad: CLI Args &gt; Configuración Local
# Configure additional arguments for the AI service
# Priority: CLI Args &gt; Local Config
settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {})
arguments = {}
@@ -541,7 +541,7 @@ el.replaceWith(d);
printer.warning(&#34;Architect API key/auth not configured. Architect will be unavailable.&#34;)
printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; or &#39;connpy config --architect-auth &lt;auth&gt;&#39; to enable it.&#34;)
# 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
+22 -22
View File
@@ -121,14 +121,14 @@ el.replaceWith(d);
}
# 1. Visual Separation
self.console.print(&#34;&#34;) # Salto de línea real
self.console.print(&#34;&#34;) # Real line break
self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;))
self.console.print(Panel(
&#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n&#34;
&#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;,
border_style=&#34;cyan&#34;
))
self.console.print(&#34;\n&#34;) # Pequeño espacio antes del prompt del copilot
self.console.print(&#34;\n&#34;) # Small space before the copilot prompt
bindings = KeyBindings()
@bindings.add(&#39;c-up&#39;)
@@ -195,7 +195,7 @@ el.replaceWith(d);
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(&#39;/&#39;) and &#39; &#39; not in text:
commands = [&#39;/os&#39;, &#39;/prompt&#39;, &#39;/architect&#39;, &#39;/engineer&#39;, &#39;/trust&#39;, &#39;/untrust&#39;, &#39;/memorize&#39;, &#39;/clear&#39;]
matches = [c for c in commands if c.startswith(text.lower())]
@@ -210,19 +210,19 @@ el.replaceWith(d);
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
def clean_preview(text):
# Limpia saltos de línea y el prompt inicial (todo hasta #, &gt; o $) para que quede solo el comando
# Clean newlines and the initial prompt (all up to #, &gt; or $) to leave only the command
original = text.strip().replace(&#39;\r&#39;, &#39;&#39;).replace(&#39;\n&#39;, &#39; &#39;)
cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, original)
# Si limpiar el prompt nos deja con un string vacío (ej: era solo &#34;iol#&#34;), devolvemos el original
# If cleaning the prompt leaves us with an empty string (e.g. it was just &#34;iol#&#34;), return the original
return cleaned if cleaned else original
if state[&#39;context_mode&#39;] == 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) &gt; 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&#39;t break the UI
previews = []
for b in range_blocks:
p = clean_preview(b[2])
@@ -300,8 +300,8 @@ el.replaceWith(d);
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&#39;t leave the terminal in a strange state.
question = await session.prompt_async(
get_prompt_text,
key_bindings=bindings,
@@ -333,12 +333,12 @@ el.replaceWith(d);
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(&#39;\x1b[1A\x1b[2K&#39;)
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[&#39;toolbar_msg&#39;] = &#39;&#39;
clean_question = directive.get(&#34;clean_prompt&#34;, question)
@@ -575,14 +575,14 @@ el.replaceWith(d);
}
# 1. Visual Separation
self.console.print(&#34;&#34;) # Salto de línea real
self.console.print(&#34;&#34;) # Real line break
self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;))
self.console.print(Panel(
&#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n&#34;
&#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;,
border_style=&#34;cyan&#34;
))
self.console.print(&#34;\n&#34;) # Pequeño espacio antes del prompt del copilot
self.console.print(&#34;\n&#34;) # Small space before the copilot prompt
bindings = KeyBindings()
@bindings.add(&#39;c-up&#39;)
@@ -649,7 +649,7 @@ el.replaceWith(d);
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(&#39;/&#39;) and &#39; &#39; not in text:
commands = [&#39;/os&#39;, &#39;/prompt&#39;, &#39;/architect&#39;, &#39;/engineer&#39;, &#39;/trust&#39;, &#39;/untrust&#39;, &#39;/memorize&#39;, &#39;/clear&#39;]
matches = [c for c in commands if c.startswith(text.lower())]
@@ -664,19 +664,19 @@ el.replaceWith(d);
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
def clean_preview(text):
# Limpia saltos de línea y el prompt inicial (todo hasta #, &gt; o $) para que quede solo el comando
# Clean newlines and the initial prompt (all up to #, &gt; or $) to leave only the command
original = text.strip().replace(&#39;\r&#39;, &#39;&#39;).replace(&#39;\n&#39;, &#39; &#39;)
cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, original)
# Si limpiar el prompt nos deja con un string vacío (ej: era solo &#34;iol#&#34;), devolvemos el original
# If cleaning the prompt leaves us with an empty string (e.g. it was just &#34;iol#&#34;), return the original
return cleaned if cleaned else original
if state[&#39;context_mode&#39;] == 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) &gt; 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&#39;t break the UI
previews = []
for b in range_blocks:
p = clean_preview(b[2])
@@ -754,8 +754,8 @@ el.replaceWith(d);
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&#39;t leave the terminal in a strange state.
question = await session.prompt_async(
get_prompt_text,
key_bindings=bindings,
@@ -787,12 +787,12 @@ el.replaceWith(d);
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(&#39;\x1b[1A\x1b[2K&#39;)
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[&#39;toolbar_msg&#39;] = &#39;&#39;
clean_question = directive.get(&#34;clean_prompt&#34;, question)
+17 -8
View File
@@ -177,6 +177,7 @@ el.replaceWith(d);
def _handle_chat_stream(self, request_iterator, context, service_method):
import queue
import threading
import contextvars
chunk_queue = queue.Queue()
request_queue = queue.Queue()
@@ -209,6 +210,7 @@ el.replaceWith(d);
session_id=session_id,
debug=debug,
status=bridge,
console=bridge,
confirm_handler=bridge.confirm,
chunk_callback=callback,
trust=trust,
@@ -270,10 +272,10 @@ el.replaceWith(d);
if req.HasField(&#34;engineer_auth&#34;): overrides[&#34;engineer_auth&#34;] = from_struct(req.engineer_auth)
if req.HasField(&#34;architect_auth&#34;): overrides[&#34;architect_auth&#34;] = from_struct(req.architect_auth)
# Start AI in its own thread so we can keep listening for interrupts
# Start AI in its own thread with a fresh copy of context so we can keep listening for interrupts
ctx_ai = contextvars.copy_context()
ai_thread = threading.Thread(
target=run_ai_task,
args=(req.input_text, req.session_id, req.debug, overrides, req.trust),
target=lambda: ctx_ai.run(run_ai_task, req.input_text, req.session_id, req.debug, overrides, req.trust),
daemon=True
)
ai_thread.start()
@@ -285,8 +287,9 @@ el.replaceWith(d);
# When client closes stream, send sentinel
chunk_queue.put((None, None))
# Start listening for client requests/signals
threading.Thread(target=request_listener, daemon=True).start()
# Start listening for client requests/signals with a copied context
ctx_listener = contextvars.copy_context()
threading.Thread(target=lambda: ctx_listener.run(request_listener), daemon=True).start()
# Main response loop (yields to gRPC)
while True:
@@ -333,7 +336,9 @@ el.replaceWith(d);
finally:
chunk_queue.put((None, None))
threading.Thread(target=_worker, daemon=True).start()
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = chunk_queue.get()
@@ -858,7 +863,9 @@ def service(self):
finally:
q.put(None)
threading.Thread(target=_worker, daemon=True).start()
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = q.get()
@@ -907,7 +914,9 @@ def service(self):
finally:
q.put(None)
threading.Thread(target=_worker, daemon=True).start()
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = q.get()
+101 -57
View File
@@ -649,7 +649,7 @@ class ai:
self.one_shot = kwargs.get(&#34;one_shot&#34;, False)
# 1. Cargar configuración genérica con herencia/merge global
# 1. Load generic configuration with global inheritance/merge
if hasattr(self.config, &#34;get_effective_setting&#34;):
aiconfig = self.config.get_effective_setting(&#34;ai&#34;, {})
else:
@@ -692,7 +692,7 @@ class ai:
custom_trusted = [c.strip() for c in custom_trusted.split(&#34;,&#34;) 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
@@ -729,7 +729,7 @@ class ai:
self.session_id = getattr(self.config, &#34;session_id&#34;, None)
self.session_path = os.path.join(self.sessions_dir, f&#34;{self.session_id}.json&#34;) if self.session_id else None
# Prompts base agnósticos
# Agnostic base prompts
architect_instructions = &#34;&#34;
if self.has_architect:
architect_instructions = &#34;&#34;&#34;
@@ -1269,7 +1269,7 @@ class ai:
def _engineer_loop(self, task, status=None, debug=False, chat_history=None):
&#34;&#34;&#34;Internal loop where the Engineer executes technical tasks for the Architect.&#34;&#34;&#34;
# 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 &#34;claude&#34; in self.engineer_model.lower() and &#34;vertex&#34; not in self.engineer_model.lower():
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: self.engineer_system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}]
else:
@@ -1328,7 +1328,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 = &#34;&#34;
if fn == &#34;list_nodes&#34;: s_text = f&#34;[ai_status]Engineer: [SEARCH] {args.get(&#39;filter_pattern&#39;,&#39;.*&#39;)}&#34;
@@ -1583,7 +1583,7 @@ class ai:
usage = {&#34;input&#34;: 0, &#34;output&#34;: 0, &#34;total&#34;: 0}
# 1. Selector de Rol inicial (Sticky Brain)
# 1. Initial Role Selector (Sticky Brain)
explicit_architect = re.match(r&#39;^(architect|arquitecto|@architect)[:\s]&#39;, user_input, re.I)
explicit_engineer = re.match(r&#39;^(engineer|ingeniero|@engineer)[:\s]&#39;, user_input, re.I)
@@ -1592,7 +1592,7 @@ class ai:
elif explicit_engineer:
current_brain = &#34;engineer&#34;
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(&#39;tool_calls&#39;) if isinstance(msg, dict) else getattr(msg, &#39;tool_calls&#39;, None)
@@ -1606,7 +1606,7 @@ class ai:
if is_architect_active: break
current_brain = &#34;architect&#34; if is_architect_active else &#34;engineer&#34;
# 2. Preparación de mensajes y limpieza
# 2. Message preparation and cleaning
clean_input = re.sub(r&#39;^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+&#39;, &#39;&#39;, user_input, flags=re.IGNORECASE).strip()
system_prompt = self.architect_system_prompt if current_brain == &#34;architect&#34; else self.engineer_system_prompt
@@ -1615,13 +1615,13 @@ class ai:
key = self.architect_key if current_brain == &#34;architect&#34; else self.engineer_key
current_auth = self.architect_auth if current_brain == &#34;architect&#34; 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 &#34;claude&#34; in model.lower() and &#34;vertex&#34; not in model.lower():
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}]
else:
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt}]
# Interleaving de historial
# History interleaving
last_role = &#34;system&#34;
# Sanitize history if the current target model is not compatible with cache_control
history_to_process = chat_history[-self.max_history:]
@@ -1641,7 +1641,7 @@ class ai:
if last_role == &#39;user&#39;: messages[-1][&#39;content&#39;] += &#34;\n&#34; + clean_input
else: messages.append({&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: clean_input})
# 3. Bucle de ejecución
# 3. Execution loop
iteration = 0
try:
# Set up remote interrupt callback if bridge is provided
@@ -2536,7 +2536,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
usage = {&#34;input&#34;: 0, &#34;output&#34;: 0, &#34;total&#34;: 0}
# 1. Selector de Rol inicial (Sticky Brain)
# 1. Initial Role Selector (Sticky Brain)
explicit_architect = re.match(r&#39;^(architect|arquitecto|@architect)[:\s]&#39;, user_input, re.I)
explicit_engineer = re.match(r&#39;^(engineer|ingeniero|@engineer)[:\s]&#39;, user_input, re.I)
@@ -2545,7 +2545,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
elif explicit_engineer:
current_brain = &#34;engineer&#34;
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(&#39;tool_calls&#39;) if isinstance(msg, dict) else getattr(msg, &#39;tool_calls&#39;, None)
@@ -2559,7 +2559,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
if is_architect_active: break
current_brain = &#34;architect&#34; if is_architect_active else &#34;engineer&#34;
# 2. Preparación de mensajes y limpieza
# 2. Message preparation and cleaning
clean_input = re.sub(r&#39;^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+&#39;, &#39;&#39;, user_input, flags=re.IGNORECASE).strip()
system_prompt = self.architect_system_prompt if current_brain == &#34;architect&#34; else self.engineer_system_prompt
@@ -2568,13 +2568,13 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
key = self.architect_key if current_brain == &#34;architect&#34; else self.engineer_key
current_auth = self.architect_auth if current_brain == &#34;architect&#34; 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 &#34;claude&#34; in model.lower() and &#34;vertex&#34; not in model.lower():
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}]
else:
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt}]
# Interleaving de historial
# History interleaving
last_role = &#34;system&#34;
# Sanitize history if the current target model is not compatible with cache_control
history_to_process = chat_history[-self.max_history:]
@@ -2594,7 +2594,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
if last_role == &#39;user&#39;: messages[-1][&#39;content&#39;] += &#34;\n&#34; + clean_input
else: messages.append({&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: clean_input})
# 3. Bucle de ejecución
# 3. Execution loop
iteration = 0
try:
# Set up remote interrupt callback if bridge is provided
@@ -4778,12 +4778,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, &#39;stop_reading&#39;):
stream.stop_reading()
elif hasattr(stream, &#39;_loop&#39;) and hasattr(stream, &#39;stdin_fd&#39;):
# 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:
@@ -4800,7 +4800,7 @@ class node:
break
finally:
print(&#34;\033[2m Returning to session...\033[0m&#34;, 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, &#39;start_reading&#39;):
stream.start_reading()
elif hasattr(stream, &#39;_loop&#39;) and hasattr(stream, &#39;stdin_fd&#39;):
@@ -4868,14 +4868,6 @@ class node:
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
# Attempt to set the terminal size
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -4896,6 +4888,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 &#34;screen_length_command&#34; 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
@@ -4978,14 +4984,6 @@ class node:
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
# Attempt to set the terminal size
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -5007,6 +5005,15 @@ class node:
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
if c == commands[0] and &#34;screen_length_command&#34; 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
@@ -5032,13 +5039,28 @@ class node:
if vars is not None:
e = e.format(**vars)
updatedprompt = re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, prompt)
newpattern = f&#34;.*({updatedprompt}).*{e}.*&#34;
cleaned_output = output
try:
newpattern = f&#34;.*({updatedprompt}).*{e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
except re.error:
try:
escaped_e = re.escape(e)
newpattern = f&#34;.*({updatedprompt}).*{escaped_e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
except re.error:
pass
if e in cleaned_output:
self.result[e] = True
else:
self.result[e]= False
try:
if re.search(e, cleaned_output):
self.result[e] = True
else:
self.result[e] = False
except re.error:
self.result[e] = False
self.status = 0
return self.result
if result == 2:
@@ -5446,14 +5468,6 @@ def run(self, commands, vars = None,*, folder = &#39;&#39;, prompt = r&#39;&gt;$
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
# Attempt to set the terminal size
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -5474,6 +5488,20 @@ def run(self, commands, vars = None,*, folder = &#39;&#39;, prompt = r&#39;&gt;$
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 &#34;screen_length_command&#34; 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
@@ -5597,14 +5625,6 @@ def test(self, commands, expected, vars = None,*, folder = &#39;&#39;, prompt =
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
# Attempt to set the terminal size
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -5626,6 +5646,15 @@ def test(self, commands, expected, vars = None,*, folder = &#39;&#39;, prompt =
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
if c == commands[0] and &#34;screen_length_command&#34; 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
@@ -5651,13 +5680,28 @@ def test(self, commands, expected, vars = None,*, folder = &#39;&#39;, prompt =
if vars is not None:
e = e.format(**vars)
updatedprompt = re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, prompt)
newpattern = f&#34;.*({updatedprompt}).*{e}.*&#34;
cleaned_output = output
try:
newpattern = f&#34;.*({updatedprompt}).*{e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
except re.error:
try:
escaped_e = re.escape(e)
newpattern = f&#34;.*({updatedprompt}).*{escaped_e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
except re.error:
pass
if e in cleaned_output:
self.result[e] = True
else:
self.result[e]= False
try:
if re.search(e, cleaned_output):
self.result[e] = True
else:
self.result[e] = False
except re.error:
self.result[e] = False
self.status = 0
return self.result
if result == 2: