From 01690c815f322f247785f21d75e8147cd18c1880 Mon Sep 17 00:00:00 2001
From: Fede Luzzi
Date: Mon, 17 Aug 2026 10:20:43 -0300
Subject: [PATCH] feat(grpc,copilot): add remote multi-turn mission execution
loop, update pdoc and bump to v6.3.0
- Implement dynamic prompt settle detection and context block recalculation in gRPC server during Copilot execution.
- Enable multi-turn streaming interaction loop in NodeStub for remote Copilot and autonomous missions.
- Fix non-mission chat history logging in terminal UI.
- Add unit test coverage for remote Copilot command execution lifecycle.
- Regenerate HTML documentation in docs/ via pdoc.
- Bump version to v6.3.0.
---
README.md | 2 +-
connpy/__init__.py | 2 +-
connpy/_version.py | 2 +-
connpy/cli/terminal_ui.py | 15 +-
connpy/grpc_layer/server.py | 73 ++-
connpy/grpc_layer/stubs.py | 39 +-
connpy/tests/test_grpc_layer.py | 77 +++
docs/connpy/ai.html | 8 +-
docs/connpy/cli/ai_handler.html | 4 +-
docs/connpy/cli/api_handler.html | 4 +-
docs/connpy/cli/config_handler.html | 4 +-
docs/connpy/cli/context_handler.html | 4 +-
docs/connpy/cli/forms.html | 4 +-
docs/connpy/cli/help_text.html | 4 +-
docs/connpy/cli/helpers.html | 4 +-
docs/connpy/cli/import_export_handler.html | 4 +-
docs/connpy/cli/index.html | 4 +-
docs/connpy/cli/login_handler.html | 4 +-
docs/connpy/cli/node_handler.html | 4 +-
docs/connpy/cli/plugin_handler.html | 4 +-
docs/connpy/cli/profile_handler.html | 4 +-
docs/connpy/cli/run_handler.html | 4 +-
docs/connpy/cli/shell_handler.html | 4 +-
docs/connpy/cli/sso_handler.html | 4 +-
docs/connpy/cli/sync_handler.html | 4 +-
docs/connpy/cli/terminal_ui.html | 608 ++++++++++++------
docs/connpy/cli/user_handler.html | 4 +-
docs/connpy/cli/validators.html | 4 +-
docs/connpy/grpc_layer/connpy_pb2.html | 4 +-
docs/connpy/grpc_layer/connpy_pb2_grpc.html | 4 +-
docs/connpy/grpc_layer/index.html | 4 +-
docs/connpy/grpc_layer/remote_plugin_pb2.html | 12 +-
.../grpc_layer/remote_plugin_pb2_grpc.html | 4 +-
docs/connpy/grpc_layer/server.html | 79 ++-
docs/connpy/grpc_layer/stubs.html | 43 +-
docs/connpy/grpc_layer/user_registry.html | 4 +-
docs/connpy/grpc_layer/utils.html | 4 +-
docs/connpy/index.html | 45 +-
docs/connpy/mcp_client.html | 4 +-
docs/connpy/proto/index.html | 4 +-
docs/connpy/services/ai_service.html | 44 +-
docs/connpy/services/base.html | 4 +-
docs/connpy/services/config_service.html | 4 +-
docs/connpy/services/context_service.html | 4 +-
docs/connpy/services/exceptions.html | 4 +-
docs/connpy/services/execution_service.html | 4 +-
.../services/import_export_service.html | 4 +-
docs/connpy/services/index.html | 44 +-
docs/connpy/services/node_service.html | 4 +-
docs/connpy/services/plugin_service.html | 4 +-
docs/connpy/services/profile_service.html | 4 +-
docs/connpy/services/provider.html | 4 +-
docs/connpy/services/sync_service.html | 4 +-
docs/connpy/services/system_service.html | 4 +-
docs/connpy/services/user_service.html | 4 +-
docs/connpy/tunnels.html | 4 +-
docs/connpy/utils.html | 4 +-
57 files changed, 921 insertions(+), 340 deletions(-)
diff --git a/README.md b/README.md
index 5f21568..ed61f45 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
-# Connpy (v6.2.0)
+# Connpy (v6.3.0)
[](https://pypi.org/pypi/connpy/)
[](https://pypi.org/pypi/connpy/)
[](https://pypi.org/pypi/connpy/)
diff --git a/connpy/__init__.py b/connpy/__init__.py
index 734aa00..6f6f89a 100644
--- a/connpy/__init__.py
+++ b/connpy/__init__.py
@@ -5,7 +5,7 @@
-# Connpy (v6.2.0)
+# Connpy (v6.3.0)
[](https://pypi.org/pypi/connpy/)
[](https://pypi.org/pypi/connpy/)
[](https://pypi.org/pypi/connpy/)
diff --git a/connpy/_version.py b/connpy/_version.py
index 0a895f3..833eacf 100644
--- a/connpy/_version.py
+++ b/connpy/_version.py
@@ -1 +1 @@
-__version__ = "6.2.0"
+__version__ = "6.3.0"
diff --git a/connpy/cli/terminal_ui.py b/connpy/cli/terminal_ui.py
index 6cd529a..006a469 100644
--- a/connpy/cli/terminal_ui.py
+++ b/connpy/cli/terminal_ui.py
@@ -560,14 +560,13 @@ class CopilotInterface:
mission.setdefault('scratchpad_notes', []).append(notes)
if guide:
mission['last_guide'] = guide
-
- if guide or notes:
- asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide
- if not asst_msg and guide: asst_msg = guide
- hist = self.session_state.setdefault("copilot_chat_history", [])
- hist.append({"role": "user", "content": clean_question})
- hist.append({"role": "assistant", "content": asst_msg})
- self.session_state["copilot_chat_history"] = hist[-10:]
+ else:
+ if guide or notes:
+ asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide
+ hist = self.session_state.setdefault("copilot_chat_history", [])
+ hist.append({"role": "user", "content": clean_question})
+ hist.append({"role": "assistant", "content": asst_msg})
+ self.session_state["copilot_chat_history"] = hist[-10:]
commands = result.get("commands", [])
mission = self.session_state.get('mission', {})
if not commands:
diff --git a/connpy/grpc_layer/server.py b/connpy/grpc_layer/server.py
index 19774be..f384125 100644
--- a/connpy/grpc_layer/server.py
+++ b/connpy/grpc_layer/server.py
@@ -277,7 +277,8 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
is_single = saved_mode in (1, 'SINGLE', 'single')
if is_range or is_single:
- if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1:
+ is_mission = session_state.get('mission', {}).get('active', False) or (isinstance(node_info, dict) and node_info.get('mission', {}).get('active', False))
+ if last_total_cmds is not None and total_cmds > last_total_cmds and (saved_cmd > 1 or is_mission):
new_cmds = total_cmds - last_total_cmds
initial_cmd = saved_cmd + new_cmds
else:
@@ -321,6 +322,14 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
))
while True:
+ # Refresh latest buffer from terminal log
+ raw_bytes = n.mylog.getvalue() if hasattr(n, 'mylog') else buffer
+ if not isinstance(raw_bytes, bytes):
+ raw_bytes = str(raw_bytes).encode()
+ from connpy.utils import log_cleaner
+ cleaned_buffer = log_cleaner(raw_bytes.decode(errors='replace'))
+ buffer = cleaned_buffer
+
# 0. Drain the queue of any stale messages before starting a new interaction
while not remote_stream.copilot_queue.empty():
try:
@@ -454,16 +463,74 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
if action == "send_all":
commands = result.get("commands", [])
await n.inject_commands(commands, child_fd, on_inject=on_inject)
- return
elif action.startswith("custom:"):
custom_cmds_raw = action[7:]
custom_cmds = [cmd.strip() for cmd in custom_cmds_raw.split('\n') if cmd.strip()]
await n.inject_commands(custom_cmds, child_fd, on_inject=on_inject)
- return
else:
os.write(child_fd, b'\x15\r')
return
+ # Dynamic Wait for Device Prompt Settle
+ prompt_pattern = node_info.get("prompt", r'>$|#$|\$$|>.$|#.$|\$.$')
+ import time
+ import re
+ start_t = time.time()
+ last_len = 0
+ quiet_count = 0
+ while time.time() - start_t < 60:
+ await asyncio.sleep(0.15)
+ cur_bytes = n.mylog.getvalue()
+ if len(cur_bytes) == last_len:
+ quiet_count += 1
+ else:
+ quiet_count = 0
+ last_len = len(cur_bytes)
+
+ clean_txt = n._logclean(cur_bytes.decode(errors='replace'), True).strip()
+ lines = [l.strip() for l in clean_txt.split('\n') if l.strip()]
+ last_line = lines[-1] if lines else ""
+ if quiet_count >= 2 and re.search(prompt_pattern, last_line):
+ break
+
+ # Recalculate context blocks with updated buffer
+ raw_bytes = n.mylog.getvalue()
+ from connpy.utils import log_cleaner
+ cleaned_buffer = log_cleaner(raw_bytes.decode(errors='replace'))
+ last_line = cleaned_buffer.split('\n')[-1].strip() if cleaned_buffer.strip() else "(prompt)"
+ blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line)
+ node_info["context_blocks"] = blocks
+
+ total_cmds = len(blocks)
+ total_lines = len(cleaned_buffer.split('\n'))
+
+ last_total_cmds = session_state.get('last_total_cmds', None)
+ saved_cmd = session_state.get('context_cmd', 1)
+ is_mission = session_state.get('mission', {}).get('active', False) or (isinstance(node_info, dict) and node_info.get('mission', {}).get('active', False))
+ if last_total_cmds is not None and total_cmds > last_total_cmds and (saved_cmd > 1 or is_mission):
+ new_cmds = total_cmds - last_total_cmds
+ initial_cmd = saved_cmd + new_cmds
+ else:
+ initial_cmd = saved_cmd
+
+ session_state['context_cmd'] = max(1, initial_cmd)
+ session_state['last_total_cmds'] = total_cmds
+ session_state['last_total_lines'] = total_lines
+
+ node_info['context_cmd'] = min(session_state['context_cmd'], max(1, total_cmds))
+ node_info['context_lines'] = min(session_state.get('context_lines', 50), max(1, total_lines))
+ node_info_json = json.dumps(node_info)
+
+ preview_str = raw_bytes[-200:].decode(errors='replace') if isinstance(raw_bytes, bytes) else str(raw_bytes)[-200:]
+
+ # Signal prompt settle and send updated blocks to client
+ response_queue.put(connpy_pb2.InteractResponse(
+ copilot_prompt=True,
+ copilot_buffer_preview=preview_str,
+ copilot_node_info_json=node_info_json
+ ))
+ continue
+
asyncio.run(n._async_interact_loop(remote_stream, resize_callback, copilot_handler=remote_copilot_handler))
except Exception as e:
import traceback
diff --git a/connpy/grpc_layer/stubs.py b/connpy/grpc_layer/stubs.py
index 7a0450e..4cf9341 100644
--- a/connpy/grpc_layer/stubs.py
+++ b/connpy/grpc_layer/stubs.py
@@ -44,7 +44,7 @@ class NodeStub:
self.config = config
def _handle_remote_copilot(self, res, request_queue, response_queue, client_buffer_bytes, pause_generator, resume_generator, old_tty):
- import json, asyncio, termios, sys, tty, queue
+ import json, asyncio, termios, sys, tty, queue, os
from ..core import copilot_terminal_mode
from . import connpy_pb2
@@ -62,6 +62,7 @@ class NodeStub:
)
self.copilot_history = interface.history
self.copilot_state = interface.session_state
+ interface.session_state['banner_shown'] = False
async def on_ai_call_remote(active_buffer, question, chunk_callback, merged_node_info):
# Send request to server
@@ -84,6 +85,7 @@ class NodeStub:
# Wrap in async loop
async def run_remote_copilot():
+ nonlocal blocks, node_info
while True:
action, commands, custom_cmd = await interface.run_session(
raw_bytes=bytes(client_buffer_bytes),
@@ -97,6 +99,32 @@ class NodeStub:
request_queue.put(connpy_pb2.InteractRequest(copilot_action="continue"))
continue
+ if action in ("send_all", "custom"):
+ action_sent = "cancel"
+ if action == "send_all" and commands:
+ action_sent = f"custom:{chr(10).join(commands)}"
+ elif action == "custom" and custom_cmd:
+ action_sent = f"custom:{chr(10).join(custom_cmd)}"
+ request_queue.put(connpy_pb2.InteractRequest(copilot_action=action_sent))
+
+ print("\r\n\033[2m [Remote] Ejecutando comandos y esperando al prompt...\033[0m\r\n", flush=True)
+ while True:
+ try:
+ prompt_res = response_queue.get(timeout=0.1)
+ if prompt_res is None:
+ return "cancel", None, None
+ if prompt_res.stdout_data:
+ os.write(sys.stdout.fileno(), prompt_res.stdout_data)
+ client_buffer_bytes.extend(prompt_res.stdout_data)
+ if prompt_res.copilot_prompt:
+ if prompt_res.copilot_node_info_json:
+ node_info = json.loads(prompt_res.copilot_node_info_json)
+ blocks = node_info.get("context_blocks", [])
+ break
+ except queue.Empty:
+ await asyncio.sleep(0.05)
+ continue
+
return action, commands, custom_cmd
with copilot_terminal_mode():
@@ -104,14 +132,7 @@ class NodeStub:
print("\033[2m Returning to session...\033[0m", flush=True)
# Prepare final action for server
- action_sent = "cancel"
- if action == "send_all" and commands:
- # In remote mode, send the selected commands as a custom block
- # so the server executes exactly what the user picked (e.g., selection '1')
- action_sent = f"custom:{chr(10).join(commands)}"
- elif action == "custom" and custom_cmd:
- action_sent = f"custom:{chr(10).join(custom_cmd)}"
- request_queue.put(connpy_pb2.InteractRequest(copilot_action=action_sent))
+ request_queue.put(connpy_pb2.InteractRequest(copilot_action="cancel"))
resume_generator()
tty.setraw(sys.stdin.fileno())
diff --git a/connpy/tests/test_grpc_layer.py b/connpy/tests/test_grpc_layer.py
index 1290b0b..3580615 100644
--- a/connpy/tests/test_grpc_layer.py
+++ b/connpy/tests/test_grpc_layer.py
@@ -211,3 +211,80 @@ class TestGRPCIntegration:
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
+
+ @patch("sys.stdin.fileno", return_value=0)
+ @patch("termios.tcsetattr")
+ @patch("termios.tcgetattr")
+ @patch("tty.setraw")
+ @patch("os.write")
+ def test_handle_remote_copilot_command_execution_loop(self, mock_os_write, mock_setraw, mock_getattr, mock_setattr, mock_fileno):
+ import queue
+ from connpy.grpc_layer.stubs import NodeStub
+ from connpy.cli.terminal_ui import CopilotInterface
+
+ mock_getattr.return_value = [0, 0, 0, 0, 0, 0, [0] * 32]
+ mock_channel = MagicMock()
+ stub = NodeStub(mock_channel, "localhost:8048")
+
+ initial_node_info = {
+ "name": "r1",
+ "context_blocks": [[0, 10, "router#"]]
+ }
+ res = connpy_pb2.InteractResponse(
+ copilot_prompt=True,
+ copilot_node_info_json=json.dumps(initial_node_info)
+ )
+
+ request_queue = queue.Queue()
+ response_queue = queue.Queue()
+ client_buffer_bytes = bytearray(b"router# ")
+ pause_gen = MagicMock()
+ resume_gen = MagicMock()
+ old_tty = [0] * 7
+
+ call_count = 0
+ received_blocks_per_call = []
+
+ async def mock_run_session(raw_bytes, node_info, on_ai_call, blocks):
+ nonlocal call_count
+ call_count += 1
+ received_blocks_per_call.append(list(blocks))
+ if call_count == 1:
+ # Step 1: User approves commands
+ # Simulate server putting stdout data and next copilot prompt
+ updated_node_info = {
+ "name": "r1",
+ "context_blocks": [[0, 10, "router#"], [10, 30, "router# show ip route"]]
+ }
+ response_queue.put(connpy_pb2.InteractResponse(stdout_data=b"show ip route\r\n10.0.0.0/24\r\nrouter# "))
+ response_queue.put(connpy_pb2.InteractResponse(
+ copilot_prompt=True,
+ copilot_node_info_json=json.dumps(updated_node_info)
+ ))
+ return "send_all", ["show ip route"], None
+ else:
+ # Step 2: Second turn, mission completes or user cancels
+ return "cancel", None, None
+
+ with patch.object(CopilotInterface, "run_session", side_effect=mock_run_session):
+ stub._handle_remote_copilot(
+ res, request_queue, response_queue, client_buffer_bytes,
+ pause_gen, resume_gen, old_tty
+ )
+
+ # Assert that run_session was called twice
+ assert call_count == 2
+ # First call should have initial blocks
+ assert len(received_blocks_per_call[0]) == 1
+ # Second call should have updated blocks received from server
+ assert len(received_blocks_per_call[1]) == 2
+ assert received_blocks_per_call[1][1][2] == "router# show ip route"
+ # Assert client_buffer_bytes was updated with stdout_data
+ assert b"10.0.0.0/24" in bytes(client_buffer_bytes)
+ # Assert action sent to server
+ reqs = []
+ while not request_queue.empty():
+ reqs.append(request_queue.get_nowait())
+ assert reqs[0].copilot_action == "custom:show ip route"
+ assert reqs[1].copilot_action == "cancel"
+
diff --git a/docs/connpy/ai.html b/docs/connpy/ai.html
index 43930c3..9f71e5f 100644
--- a/docs/connpy/ai.html
+++ b/docs/connpy/ai.html
@@ -3,7 +3,7 @@
-
+
connpy.ai API documentation
@@ -2101,11 +2101,11 @@ Node: {node_name}"""
var SAFE_COMMANDS
-
-
+
The type of the None singleton.
var deferred_class_hooks
-
-
+
The type of the None singleton.
Instance variables
@@ -3234,7 +3234,7 @@ def confirm(self, user_input): return True