connpy v6.0.0b4: AI Stability, Remote Sync & UI Polish (Clean Commit)

This commit is contained in:
2026-05-01 18:55:25 -03:00
parent c81f6e049f
commit a192bd1912
18 changed files with 717 additions and 292 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "6.0.0b3"
__version__ = "6.0.0b4"
+53 -11
View File
@@ -273,9 +273,12 @@ class ai:
if not debug and not chunk_callback:
if not is_streaming_text:
# Stop spinner before starting live display
# Stop spinner definitively
if status:
status.stop()
try:
status.stop()
except Exception:
pass
live_display = Live(
Panel(Markdown(full_content), title=title, border_style=border, expand=False),
console=self.console,
@@ -463,7 +466,7 @@ class ai:
tail_limit = int(final_limit * 0.4)
return (text[:head_limit] + f"\n\n[... OUTPUT TRUNCATED ...]\n\n" + text[-tail_limit:])
def _print_debug_observation(self, fn, obs):
def _print_debug_observation(self, fn, obs, status=None):
"""Prints a tool observation in a readable way during debug mode."""
# Try to parse as JSON if it's a string
if isinstance(obs, str):
@@ -487,6 +490,7 @@ class ai:
content = Text("Empty data set")
else:
# Add a small spacer instead of a Rule for cleaner look
from rich.console import Group
content = Group(*elements)
elif isinstance(obs_data, list):
content = Text("\n".join(f"{item}" for item in obs_data))
@@ -494,7 +498,18 @@ class ai:
content = Text(str(obs_data))
title = f"[bold]{fn}[/bold]"
# Stop status before printing panel to avoid ghosting
if status:
try: status.stop()
except: pass
self.console.print(Panel(content, title=title, border_style="ai_status"))
# Resume status
if status:
try: status.start()
except: pass
def manage_memory_tool(self, content, action="append"):
"""Save or update long-term memory. Only use when user explicitly requests it."""
@@ -695,7 +710,7 @@ class ai:
elif fn in self.tool_status_formatters: status.update(self.tool_status_formatters[fn](args))
if debug:
self._print_debug_observation(f"Decision: {fn}", args)
self._print_debug_observation(f"Decision: {fn}", args, status=status)
if fn == "list_nodes": obs = self.list_nodes_tool(**args)
elif fn == "run_commands": obs = self.run_commands_tool(**args, status=status)
@@ -704,7 +719,7 @@ class ai:
else: obs = f"Error: Unknown tool '{fn}'."
if debug:
self._print_debug_observation(f"Observation: {fn}", obs)
self._print_debug_observation(f"Observation: {fn}", obs, status=status)
# Ensure observation is a string and truncated for the LLM
obs_str = obs if isinstance(obs, str) else json.dumps(obs)
@@ -974,7 +989,7 @@ class ai:
streamed_response = False
try:
safe_messages = self._sanitize_messages(messages)
if stream and (not debug or chunk_callback):
if stream and chunk_callback:
response, streamed_response = self._stream_completion(
model=model, messages=safe_messages, tools=tools, api_key=key,
status=status, label=label, debug=debug, num_retries=3,
@@ -1017,7 +1032,13 @@ class ai:
# In CLI debug mode, only print intermediate reasoning if there are tool calls.
# If there are no tool calls, this content is the final answer and will be printed by the caller.
if resp_msg.tool_calls:
if status:
try: status.stop()
except: pass
self.console.print(Panel(Markdown(resp_msg.content), title=f"[{current_brain}][bold]{label} Reasoning[/bold][/{current_brain}]", border_style="architect" if current_brain == "architect" else "engineer"))
if status:
try: status.start()
except: pass
if not resp_msg.tool_calls: break
@@ -1038,7 +1059,7 @@ class ai:
elif fn == "manage_memory_tool": status.update(f"[architect]Architect: [UPDATING MEMORY]")
if debug:
self._print_debug_observation(f"Decision: {fn}", args)
self._print_debug_observation(f"Decision: {fn}", args, status=status)
if fn == "delegate_to_engineer":
obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1])
@@ -1057,7 +1078,14 @@ class ai:
num_retries=3
)
obs = claude_resp.choices[0].message.content
if debug: self.console.print(Panel(Markdown(obs), title="[architect]Architect Consultation[/architect]", border_style="architect"))
if debug:
if status:
try: status.stop()
except: pass
self.console.print(Panel(Markdown(obs), title="[architect]Architect Consultation[/architect]", border_style="architect"))
if status:
try: status.start()
except: pass
except Exception as e:
if status: status.update("[unavailable]Architect unavailable! Engineer continuing alone...")
obs = f"Architect unavailable ({str(e)}). Proceeding with your best technical judgment."
@@ -1074,7 +1102,14 @@ class ai:
handover_msg = f"HANDOVER FROM EXECUTION ENGINE\n\nReason: {args['reason']}\n\nContext: {args['context']}\n\nYou are now in control of this conversation."
pending_user_message = handover_msg
obs = "Control transferred to Architect. Handover context will be provided."
if debug: self.console.print(Panel(Text(handover_msg), title="[architect]Escalation to Architect[/architect]", border_style="architect"))
if debug:
if status:
try: status.stop()
except: pass
self.console.print(Panel(Text(handover_msg), title="[architect]Escalation to Architect[/architect]", border_style="architect"))
if status:
try: status.start()
except: pass
elif fn == "return_to_engineer":
if status: status.update("[engineer]Transferring control back to Engineer...")
@@ -1088,7 +1123,14 @@ class ai:
handover_msg = f"HANDOVER FROM ARCHITECT\n\nSummary: {args['summary']}\n\nYou are now back in control. Continue handling the user's requests."
pending_user_message = handover_msg
obs = "Control returned to Engineer. Handover summary will be provided."
if debug: self.console.print(Panel(Text(handover_msg), title="[engineer]Return to Engineer[/engineer]", border_style="engineer"))
if debug:
if status:
try: status.stop()
except: pass
self.console.print(Panel(Text(handover_msg), title="[engineer]Return to Engineer[/engineer]", border_style="engineer"))
if status:
try: status.start()
except: pass
elif fn == "list_nodes": obs = self.list_nodes_tool(**args)
elif fn == "run_commands": obs = self.run_commands_tool(**args, status=status)
@@ -1098,7 +1140,7 @@ class ai:
else: obs = f"Error: {fn} unknown."
if debug and fn not in ["delegate_to_engineer", "consult_architect", "escalate_to_architect", "return_to_engineer"]:
self._print_debug_observation(f"Observation: {fn}", obs)
self._print_debug_observation(f"Observation: {fn}", obs, status=status)
# Ensure observation is a string and truncated for the LLM
obs_str = obs if isinstance(obs, str) else json.dumps(obs)
-2
View File
@@ -88,7 +88,6 @@ class AIHandler:
if "usage" in result:
u = result["usage"]
console.print(f"[debug]Tokens: {u['total']} (Input: {u['input']}, Output: {u['output']})[/debug]")
console.print()
def interactive_chat(self, args, session_id):
history = None
@@ -132,7 +131,6 @@ class AIHandler:
if "usage" in result:
u = result["usage"]
console.print(f"[debug]Tokens: {u['total']} (Input: {u['input']}, Output: {u['output']})[/debug]")
console.print()
except (KeyboardInterrupt, EOFError):
console.print("\n[dim]Session closed.[/dim]")
break
+2 -2
View File
@@ -234,8 +234,8 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
"-a": None, "-r": None, "-s": None, "-e": None, "-h": None,
},
"plugin": {
"--add": lambda w: get_cwd(w, "--add"),
"--update": lambda w: get_cwd(w, "--update"),
"--add": {"*": lambda w: get_cwd(w, "--add")},
"--update": {"*": lambda w: get_cwd(w, "--update")},
"--del": lambda w: _get_plugins("--del", configdir),
"--enable": lambda w: _get_plugins("--enable", configdir),
"--disable": lambda w: _get_plugins("--disable", configdir),
+6 -12
View File
@@ -78,7 +78,9 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
unique_id = first_req.id
sftp = first_req.sftp
debug = first_req.debug
printer.console.print(f"[debug][DEBUG][/debug] gRPC interact_node request for: [bold cyan]{unique_id}[/bold cyan]")
if debug:
printer.console.print(f"[debug][DEBUG][/debug] gRPC interact_node request for: [bold cyan]{unique_id}[/bold cyan]")
if first_req.connection_params_json:
import json
@@ -177,7 +179,8 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
while True:
data = response_queue.get()
if data is None:
printer.console.print(f"[debug][DEBUG][/debug] gRPC interact_node session closed for: [bold cyan]{unique_id}[/bold cyan]")
if debug:
printer.console.print(f"[debug][DEBUG][/debug] gRPC interact_node session closed for: [bold cyan]{unique_id}[/bold cyan]")
break
yield connpy_pb2.InteractResponse(stdout_data=data)
@handle_errors
@@ -388,12 +391,7 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
def _worker():
try:
# Set task name in thread state for printer if available
if request.name:
printer.console.print(f"[debug][DEBUG][/debug] Executing task: [bold cyan]{request.name}[/bold cyan]")
self.service.run_commands(
nodes_filter=nodes_filter,
self.service.run_commands( nodes_filter=nodes_filter,
commands=list(request.commands),
folder=request.folder if request.folder else None,
prompt=request.prompt if request.prompt else None,
@@ -439,10 +437,6 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
def _worker():
try:
# Set task name in thread state for printer if available
if request.name:
printer.console.print(f"[debug][DEBUG][/debug] Executing task: [bold cyan]{request.name}[/bold cyan]")
self.service.test_commands(
nodes_filter=nodes_filter,
commands=list(request.commands),
+29 -20
View File
@@ -586,7 +586,6 @@ class AIStub:
if response.status_update:
if response.requires_confirmation:
if status: status.stop()
if live_display: live_display.stop()
# Show prompt and wait for answer
prompt_text = Text.from_ansi(response.status_update)
@@ -595,7 +594,6 @@ class AIStub:
if status:
status.update("[ai_status]Agent: Resuming...")
status.start()
if live_display: live_display.start()
req_queue.put(connpy_pb2.AskRequest(confirmation_answer=ans))
continue
@@ -606,41 +604,52 @@ class AIStub:
if response.debug_message:
if debug:
if status:
try: status.stop()
except: pass
printer.console.print(Text.from_ansi(response.debug_message))
if status:
try: status.start()
except: pass
continue
if response.important_message:
if status:
try: status.stop()
except: pass
printer.console.print(Text.from_ansi(response.important_message))
if status:
try: status.start()
except: pass
continue
if not response.is_final:
full_content += response.text_chunk
if not live_display and not debug:
if status: status.stop()
live_display = Live(
Panel(Markdown(full_content), title="AI Assistant", expand=False),
console=printer.console,
refresh_per_second=8,
transient=False
)
live_display.start()
elif live_display:
live_display.update(Panel(Markdown(full_content), title="AI Assistant", expand=False))
if response.text_chunk:
full_content += response.text_chunk
if status and not debug:
# Update the spinner line with a preview of the response
preview = full_content.replace("\n", " ").strip()
if len(preview) > 60: preview = preview[:57] + "..."
status.update(f"[ai_status]{preview}")
continue
if response.is_final:
# Final stop for status to ensure it disappears before the panel
if status:
try: status.stop()
except: pass
final_result = from_struct(response.full_result)
responder = final_result.get("responder", "engineer")
alias = "architect" if responder == "architect" else "engineer"
role_label = "Network Architect" if responder == "architect" else "Network Engineer"
title = f"[bold {alias}]{role_label}[/bold {alias}]"
if live_display:
live_display.update(Panel(Markdown(full_content), title=title, border_style=alias, expand=False))
live_display.stop()
elif full_content:
printer.console.print(Panel(Markdown(full_content), title=title, border_style=alias, expand=False))
# Always print the final Panel
content_to_print = full_content or final_result.get("response", "")
if content_to_print:
printer.console.print(Panel(Markdown(content_to_print), title=title, border_style=alias, expand=False))
break
except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch
+4 -2
View File
@@ -20,7 +20,8 @@ class MethodHook:
try:
args, kwargs = hook(*args, **kwargs)
except Exception as e:
printer.error(f"{self.func.__name__} Pre-hook {hook.__name__} raised an exception: {e}")
hook_name = getattr(hook, "__name__", str(hook))
printer.error(f"{self.func.__name__} Pre-hook {hook_name} raised an exception: {e}")
result = self.func(*args, **kwargs)
@@ -32,7 +33,8 @@ class MethodHook:
try:
result = hook(*args, **kwargs, result=result) # Pass result to hooks
except Exception as e:
printer.error(f"{self.func.__name__} Post-hook {hook.__name__} raised an exception: {e}")
hook_name = getattr(hook, "__name__", str(hook))
printer.error(f"{self.func.__name__} Post-hook {hook_name} raised an exception: {e}")
return result
+66 -24
View File
@@ -1,6 +1,7 @@
from .base import BaseService
import yaml
import os
from copy import deepcopy
from .exceptions import InvalidConfigurationError, NodeNotFoundError, ReservedNameError
from ..configfile import NoAliasDumper
@@ -23,13 +24,45 @@ class ImportExportService(BaseService):
def export_to_dict(self, folders=None):
"""Export nodes/folders to a dictionary."""
if not folders:
return self.config._getallnodesfull(extract=False)
return deepcopy(self.config.connections)
else:
# Validate folders exist
for f in folders:
if f != "@" and f not in self.config._getallfolders():
raise NodeNotFoundError(f"Folder '{f}' not found.")
return self.config._getallnodesfull(folders, extract=False)
flat = self.config._getallnodesfull(folders, extract=False)
nested = {}
for k, v in flat.items():
uniques = self.config._explode_unique(k)
if not uniques:
continue
if "folder" in uniques and "subfolder" in uniques:
f_name = uniques["folder"]
s_name = uniques["subfolder"]
i_name = uniques["id"]
if f_name not in nested:
nested[f_name] = {"type": "folder"}
if s_name not in nested[f_name]:
nested[f_name][s_name] = {"type": "subfolder"}
nested[f_name][s_name][i_name] = v
elif "folder" in uniques:
f_name = uniques["folder"]
i_name = uniques["id"]
if f_name not in nested:
nested[f_name] = {"type": "folder"}
nested[f_name][i_name] = v
else:
i_name = uniques["id"]
nested[i_name] = v
return nested
def import_from_file(self, file_path):
"""Import nodes/folders from a YAML file."""
@@ -48,26 +81,35 @@ class ImportExportService(BaseService):
if not isinstance(data, dict):
raise InvalidConfigurationError("Invalid import data format: expected a dictionary of nodes.")
# Process imports
for k, v in data.items():
uniques = self.config._explode_unique(k)
# Ensure folders exist
if "folder" in uniques:
folder_name = f"@{uniques['folder']}"
if folder_name not in self.config._getallfolders():
folder_uniques = self.config._explode_unique(folder_name)
self.config._folder_add(**folder_uniques)
if "subfolder" in uniques:
sub_name = f"@{uniques['subfolder']}@{uniques['folder']}"
if sub_name not in self.config._getallfolders():
sub_uniques = self.config._explode_unique(sub_name)
self.config._folder_add(**sub_uniques)
# Add node/connection
v.update(uniques)
self._validate_node_name(k)
self.config._connections_add(**v)
def _traverse_import(node_data, current_folder='', current_subfolder=''):
for k, v in node_data.items():
if k == "type":
continue
if isinstance(v, dict):
node_type = v.get("type", "connection")
if node_type == "folder":
self.config._folder_add(folder=k)
_traverse_import(v, current_folder=k, current_subfolder='')
elif node_type == "subfolder":
self.config._folder_add(folder=current_folder, subfolder=k)
_traverse_import(v, current_folder=current_folder, current_subfolder=k)
elif node_type == "connection":
unique_id = k
if current_subfolder:
unique_id = f"{k}@{current_subfolder}@{current_folder}"
elif current_folder:
unique_id = f"{k}@{current_folder}"
self._validate_node_name(unique_id)
kwargs = deepcopy(v)
kwargs['id'] = k
kwargs['folder'] = current_folder
kwargs['subfolder'] = current_subfolder
self.config._connections_add(**kwargs)
else:
# Invalid format skip
pass
_traverse_import(data)
self.config._saveconfig(self.config.file)
+8 -2
View File
@@ -67,8 +67,14 @@ class NodeService(BaseService):
case_sensitive = self.config.config.get("case", False)
if filter_str:
flags = re.IGNORECASE if not case_sensitive else 0
folders = [f for f in folders if re.search(filter_str, f, flags)]
if filter_str.startswith("@"):
if not case_sensitive:
folders = [f for f in folders if f.lower() == filter_str.lower()]
else:
folders = [f for f in folders if f == filter_str]
else:
flags = re.IGNORECASE if not case_sensitive else 0
folders = [f for f in folders if re.search(filter_str, f, flags)]
return folders
def get_node_details(self, unique_id):