refactor: Major upgrade to v5.1b6 - AWS SSM support & Distributed Architecture
Core & Protocols: - Native AWS SSM support added (aws ssm start-session). - Improved Pexpect logic for ssm, kubectl, and docker. - Cleaned connection success messages (omitting ports for non-IP protocols). gRPC Layer: - Migrated gRPC modules to 'connpy/grpc_layer/'. - Implemented dynamic node naming (e.g. ssm-i-xxxx@aws) for accurate server-side logging. - Added automatic sys.path resolution for gRPC generated modules. - Enhanced InteractNode response with initial connection status. Printer & Concurrency: - Implemented ThreadLocalStream for isolated thread-safe output. - Self-healing Console objects to prevent 'closed file' errors in test/async environments. - Capture clean plugin output in remote executions. AI & Services: - Improved tool registration and debug visualization. - Restored native dictionary returns for AI tools to fix Web UI rendering. - Increased backup retention to 100 copies in SyncService. - Silenced noisy auto-sync CLI messages. Quality & Docs: - Total tests: 267 (all passing). - New test suites for gRPC layer and printer concurrency. - Updated .gitignore to exclude internal planning docs. - Full technical documentation regenerated with pdoc.
This commit is contained in:
+205
-67
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<meta name="generator" content="pdoc3 0.11.6">
|
||||
<title>connpy API documentation</title>
|
||||
<meta name="description" content="Connection manager …">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
|
||||
@@ -37,9 +37,9 @@ el.replaceWith(d);
|
||||
</header>
|
||||
<section id="section-intro">
|
||||
<h2 id="connection-manager">Connection manager</h2>
|
||||
<p>Connpy is a SSH, SFTP, Telnet, kubectl, and Docker pod connection manager and automation module for Linux, Mac, and Docker.</p>
|
||||
<p>Connpy is a SSH, SFTP, Telnet, kubectl, Docker pod, and AWS SSM connection manager and automation module for Linux, Mac, and Docker.</p>
|
||||
<h3 id="features">Features</h3>
|
||||
<pre><code>- Manage connections using SSH, SFTP, Telnet, kubectl, and Docker exec.
|
||||
<pre><code>- Manage connections using SSH, SFTP, Telnet, kubectl, Docker exec, and AWS SSM.
|
||||
- Set contexts to manage specific nodes from specific contexts (work/home/clients/etc).
|
||||
- You can generate profiles and reference them from nodes using @profilename so you don't
|
||||
need to edit multiple nodes when changing passwords or other information.
|
||||
@@ -516,7 +516,7 @@ class Preload:
|
||||
<dd>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt><code class="name"><a title="connpy.grpc" href="grpc/index.html">connpy.grpc</a></code></dt>
|
||||
<dt><code class="name"><a title="connpy.grpc_layer" href="grpc_layer/index.html">connpy.grpc_layer</a></code></dt>
|
||||
<dd>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
@@ -1116,14 +1116,20 @@ class ai:
|
||||
status_formatter (callable): Function(args_dict) -> status string.
|
||||
"""
|
||||
name = tool_definition["function"]["name"]
|
||||
|
||||
# Check if already registered to prevent duplicates
|
||||
if target in ("engineer", "both"):
|
||||
self.external_engineer_tools.append(tool_definition)
|
||||
if not any(t["function"]["name"] == name for t in self.external_engineer_tools):
|
||||
self.external_engineer_tools.append(tool_definition)
|
||||
if target in ("architect", "both"):
|
||||
self.external_architect_tools.append(tool_definition)
|
||||
if not any(t["function"]["name"] == name for t in self.external_architect_tools):
|
||||
self.external_architect_tools.append(tool_definition)
|
||||
|
||||
self.external_tool_handlers[name] = handler
|
||||
if engineer_prompt:
|
||||
|
||||
if engineer_prompt and engineer_prompt not in self.engineer_prompt_extensions:
|
||||
self.engineer_prompt_extensions.append(engineer_prompt)
|
||||
if architect_prompt:
|
||||
if architect_prompt and architect_prompt not in self.architect_prompt_extensions:
|
||||
self.architect_prompt_extensions.append(architect_prompt)
|
||||
if status_formatter:
|
||||
self.tool_status_formatters[name] = status_formatter
|
||||
@@ -1355,12 +1361,46 @@ class ai:
|
||||
|
||||
def _truncate(self, text, limit=None):
|
||||
"""Truncate text to specified limit, keeping head (60%) and tail (40%)."""
|
||||
if not isinstance(text, str): return str(text)
|
||||
final_limit = limit or self.max_truncate
|
||||
if len(text) <= final_limit: return text
|
||||
head_limit = int(final_limit * 0.6)
|
||||
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):
|
||||
"""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):
|
||||
try:
|
||||
obs_data = json.loads(obs)
|
||||
except Exception:
|
||||
obs_data = obs
|
||||
else:
|
||||
obs_data = obs
|
||||
|
||||
if isinstance(obs_data, dict):
|
||||
elements = []
|
||||
for k, v in obs_data.items():
|
||||
elements.append(Text(f"• {k}:", style="key"))
|
||||
# Use Text for values to ensure newlines are rendered
|
||||
val = str(v)
|
||||
# If it's a multiline string from a delegation task, keep it clean
|
||||
elements.append(Text(val))
|
||||
|
||||
if not elements:
|
||||
content = Text("Empty data set")
|
||||
else:
|
||||
# Add a small spacer instead of a Rule for cleaner look
|
||||
content = Group(*elements)
|
||||
elif isinstance(obs_data, list):
|
||||
content = Text("\n".join(f"• {item}" for item in obs_data))
|
||||
else:
|
||||
content = Text(str(obs_data))
|
||||
|
||||
title = f"[bold]{fn}[/bold]"
|
||||
self.console.print(Panel(content, title=title, border_style="ai_status"))
|
||||
|
||||
def manage_memory_tool(self, content, action="append"):
|
||||
"""Save or update long-term memory. Only use when user explicitly requests it."""
|
||||
if not content or not content.strip():
|
||||
@@ -1398,8 +1438,8 @@ class ai:
|
||||
ts = data.get("tags")
|
||||
if isinstance(ts, dict): os_tag = ts.get("os", "unknown")
|
||||
res[name] = {"os": os_tag}
|
||||
return json.dumps(res)
|
||||
return json.dumps({"count": len(matched_names), "nodes": matched_names, "note": "Use 'get_node_info' for details."})
|
||||
return res
|
||||
return {"count": len(matched_names), "nodes": matched_names, "note": "Use 'get_node_info' for details."}
|
||||
except Exception as e:
|
||||
return f"Error listing nodes: {str(e)}"
|
||||
|
||||
@@ -1473,7 +1513,7 @@ class ai:
|
||||
if not matched_names: return "No nodes found matching filter."
|
||||
thisnodes_dict = self.config.getitems(matched_names, extract=True)
|
||||
result = nodes(thisnodes_dict, config=self.config).run(commands)
|
||||
return self._truncate(json.dumps(result))
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Error executing commands: {str(e)}"
|
||||
|
||||
@@ -1482,7 +1522,7 @@ class ai:
|
||||
try:
|
||||
d = self.config.getitem(node_name, extract=True)
|
||||
if 'password' in d: d['password'] = '***'
|
||||
return json.dumps(d)
|
||||
return d
|
||||
except Exception as e:
|
||||
return f"Error getting node info: {str(e)}"
|
||||
|
||||
@@ -1526,7 +1566,7 @@ class ai:
|
||||
self.console.print(f"[warning] You can press Ctrl+C to interrupt and get a summary.[/warning]")
|
||||
soft_limit_warned = True
|
||||
|
||||
if status: status.update(f"[ai_status]Engineer: Analyzing mission... (step {iteration})")
|
||||
if status and not chat_history: status.update(f"[ai_status]Engineer: Analyzing mission... (step {iteration})")
|
||||
|
||||
try:
|
||||
safe_messages = self._sanitize_messages(messages)
|
||||
@@ -1549,8 +1589,8 @@ 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
|
||||
if status:
|
||||
# Notificación en tiempo real de la tarea técnica (Only if not in Architect loop)
|
||||
if status and not chat_history:
|
||||
if fn == "list_nodes": status.update(f"[ai_status]Engineer: [SEARCH] {args.get('filter_pattern','.*')}")
|
||||
elif fn == "run_commands":
|
||||
cmds = args.get('commands', [])
|
||||
@@ -1559,7 +1599,8 @@ class ai:
|
||||
elif fn == "get_node_info": status.update(f"[ai_status]Engineer: [INSPECT] {args.get('node_name','')}")
|
||||
elif fn in self.tool_status_formatters: status.update(self.tool_status_formatters[fn](args))
|
||||
|
||||
if debug: self.console.print(Panel(Text(json.dumps(args, indent=2)), title=f"[bold engineer]Engineer Tool: {fn}[/bold engineer]", border_style="engineer"))
|
||||
if debug:
|
||||
self._print_debug_observation(f"Decision: {fn}", args)
|
||||
|
||||
if fn == "list_nodes": obs = self.list_nodes_tool(**args)
|
||||
elif fn == "run_commands": obs = self.run_commands_tool(**args, status=status)
|
||||
@@ -1567,8 +1608,12 @@ class ai:
|
||||
elif fn in self.external_tool_handlers: obs = self.external_tool_handlers[fn](self, **args)
|
||||
else: obs = f"Error: Unknown tool '{fn}'."
|
||||
|
||||
if debug: self.console.print(Panel(Text(str(obs)), title=f"[bold pass]Engineer Observation: {fn}[/bold pass]", border_style="success"))
|
||||
messages.append({"tool_call_id": tc.id, "role": "tool", "name": fn, "content": obs})
|
||||
if debug:
|
||||
self._print_debug_observation(f"Observation: {fn}", obs)
|
||||
|
||||
# Ensure observation is a string and truncated for the LLM
|
||||
obs_str = obs if isinstance(obs, str) else json.dumps(obs)
|
||||
messages.append({"tool_call_id": tc.id, "role": "tool", "name": fn, "content": self._truncate(obs_str)})
|
||||
|
||||
if iteration >= self.hard_limit_iterations:
|
||||
self.console.print(f"[error]⛔ Engineer reached hard limit ({self.hard_limit_iterations} steps). Forcing stop.[/error]")
|
||||
@@ -1582,30 +1627,46 @@ class ai:
|
||||
|
||||
def _get_engineer_tools(self):
|
||||
"""Define tools available to the Engineer."""
|
||||
tools = [
|
||||
base_tools = [
|
||||
{"type": "function", "function": {"name": "list_nodes", "description": "Lists available nodes in the inventory.", "parameters": {"type": "object", "properties": {"filter_pattern": {"type": "string", "description": "Regex to filter nodes (e.g. '.*', 'border.*')."}}}}},
|
||||
{"type": "function", "function": {"name": "run_commands", "description": "Runs one or more commands on matched nodes. MANDATORY: You MUST call 'list_nodes' first to verify the target list.", "parameters": {"type": "object", "properties": {"nodes_filter": {"type": "string", "description": "Exact node name or verified filter pattern."}, "commands": {"type": "array", "items": {"type": "string"}, "description": "List of commands (e.g. ['show ip route', 'show int desc'])."}}, "required": ["nodes_filter", "commands"]}}},
|
||||
{"type": "function", "function": {"name": "get_node_info", "description": "Gets full metadata for a specific node.", "parameters": {"type": "object", "properties": {"node_name": {"type": "string"}}, "required": ["node_name"]}}}
|
||||
]
|
||||
|
||||
if self.architect_key:
|
||||
tools.extend([
|
||||
base_tools.extend([
|
||||
{"type": "function", "function": {"name": "consult_architect", "description": "Ask the Strategic Reasoning Engine for advice on complex design, architecture, or troubleshooting decisions. You remain in control and will present the response to the user. Use this for: configuration planning, design validation, complex troubleshooting.", "parameters": {"type": "object", "properties": {"question": {"type": "string", "description": "Strategic question or decision needed."}, "technical_summary": {"type": "string", "description": "Technical findings and context gathered so far."}}, "required": ["question", "technical_summary"]}}},
|
||||
{"type": "function", "function": {"name": "escalate_to_architect", "description": "Transfer full control to the Strategic Reasoning Engine. Use ONLY when the user explicitly requests the Architect or when the problem requires strategic oversight beyond consultation. After escalation, the Architect takes over the conversation.", "parameters": {"type": "object", "properties": {"reason": {"type": "string", "description": "Why you're escalating (e.g. 'User requested Architect', 'Complex multi-site design needed')."}, "context": {"type": "string", "description": "Full context and findings to hand over."}}, "required": ["reason", "context"]}}}
|
||||
])
|
||||
|
||||
tools.extend(self.external_engineer_tools)
|
||||
return tools
|
||||
# Deduplicate by name to prevent Gemini BadRequestError
|
||||
all_tools = base_tools + self.external_engineer_tools
|
||||
seen_names = set()
|
||||
unique_tools = []
|
||||
for t in all_tools:
|
||||
name = t["function"]["name"]
|
||||
if name not in seen_names:
|
||||
unique_tools.append(t)
|
||||
seen_names.add(name)
|
||||
return unique_tools
|
||||
|
||||
def _get_architect_tools(self):
|
||||
"""Define tools available to the Strategic Reasoning Engine."""
|
||||
tools = [
|
||||
base_tools = [
|
||||
{"type": "function", "function": {"name": "delegate_to_engineer", "description": "Delegates a technical mission to the Engineer.", "parameters": {"type": "object", "properties": {"task": {"type": "string", "description": "Detailed technical mission or goal."}}, "required": ["task"]}}},
|
||||
{"type": "function", "function": {"name": "return_to_engineer", "description": "Return control to the Engineer. Use this when your strategic analysis is complete and the Engineer should handle the rest of the conversation.", "parameters": {"type": "object", "properties": {"summary": {"type": "string", "description": "Brief summary of your analysis to hand over to the Engineer."}}, "required": ["summary"]}}},
|
||||
{"type": "function", "function": {"name": "manage_memory_tool", "description": "Saves information to long-term memory. MANDATORY: Only use this if the user explicitly asks to remember or save something.", "parameters": {"type": "object", "properties": {"content": {"type": "string"}, "action": {"type": "string", "enum": ["append", "replace"]}}, "required": ["content"]}}}
|
||||
]
|
||||
tools.extend(self.external_architect_tools)
|
||||
return tools
|
||||
|
||||
all_tools = base_tools + self.external_architect_tools
|
||||
seen_names = set()
|
||||
unique_tools = []
|
||||
for t in all_tools:
|
||||
name = t["function"]["name"]
|
||||
if name not in seen_names:
|
||||
unique_tools.append(t)
|
||||
seen_names.add(name)
|
||||
return unique_tools
|
||||
|
||||
def _get_sessions(self):
|
||||
"""Returns a list of session metadata sorted by date."""
|
||||
@@ -1809,12 +1870,16 @@ class ai:
|
||||
soft_limit_warned = True
|
||||
|
||||
label = "[architect][bold]Architect[/bold][/architect]" if current_brain == "architect" else "[engineer][bold]Engineer[/bold][/engineer]"
|
||||
if status: status.update(f"{label} is thinking... (step {iteration})")
|
||||
if status:
|
||||
# Notify responder identity ONLY for web/remote clients (StatusBridge has is_web)
|
||||
if getattr(status, "is_web", False):
|
||||
status.update(f"__RESPONDER__:{current_brain}")
|
||||
status.update(f"{label} is thinking... (step {iteration})")
|
||||
|
||||
streamed_response = False
|
||||
try:
|
||||
safe_messages = self._sanitize_messages(messages)
|
||||
if stream and not debug:
|
||||
if stream and (not debug or 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,
|
||||
@@ -1854,7 +1919,10 @@ class ai:
|
||||
messages.append(msg_dict)
|
||||
|
||||
if debug and resp_msg.content:
|
||||
self.console.print(Panel(Markdown(resp_msg.content), title=f"{label} Reasoning", border_style="architect" if current_brain == "architect" else "engineer"))
|
||||
# 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:
|
||||
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 not resp_msg.tool_calls: break
|
||||
|
||||
@@ -1874,7 +1942,8 @@ class ai:
|
||||
if fn == "delegate_to_engineer": status.update(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
|
||||
elif fn == "manage_memory_tool": status.update(f"[architect]Architect: [UPDATING MEMORY]")
|
||||
|
||||
if debug: self.console.print(Panel(Text(json.dumps(args, indent=2)), title=f"{label} Decision: {fn}", border_style="debug"))
|
||||
if debug:
|
||||
self._print_debug_observation(f"Decision: {fn}", args)
|
||||
|
||||
if fn == "delegate_to_engineer":
|
||||
obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1])
|
||||
@@ -1932,9 +2001,13 @@ class ai:
|
||||
elif fn == "manage_memory_tool": obs = self.manage_memory_tool(**args)
|
||||
elif fn in self.external_tool_handlers: obs = self.external_tool_handlers[fn](self, **args)
|
||||
else: obs = f"Error: {fn} unknown."
|
||||
|
||||
messages.append({"tool_call_id": tc.id, "role": "tool", "name": fn, "content": obs})
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Ensure observation is a string and truncated for the LLM
|
||||
obs_str = obs if isinstance(obs, str) else json.dumps(obs)
|
||||
messages.append({"tool_call_id": tc.id, "role": "tool", "name": fn, "content": self._truncate(obs_str)})
|
||||
# Inject pending user message AFTER all tool responses are added
|
||||
if pending_user_message:
|
||||
messages.append({"role": "user", "content": pending_user_message})
|
||||
@@ -1960,14 +2033,25 @@ class ai:
|
||||
if last_msg.get("tool_calls"):
|
||||
for tc in last_msg["tool_calls"]:
|
||||
messages.append({"tool_call_id": tc.get("id"), "role": "tool", "name": tc.get("function", {}).get("name"), "content": "Operation cancelled by user."})
|
||||
messages.append({"role": "user", "content": "USER INTERRUPTED. Briefly summarize what you were doing and stop."})
|
||||
|
||||
# Use a fresh list for the summary call to avoid history corruption
|
||||
summary_messages = list(messages)
|
||||
summary_messages.append({"role": "user", "content": "USER INTERRUPTED. Briefly summarize what you were doing and stop."})
|
||||
try:
|
||||
safe_messages = self._sanitize_messages(messages)
|
||||
safe_messages = self._sanitize_messages(summary_messages)
|
||||
# Use tools=None to force a text summary during interruption
|
||||
response = completion(model=model, messages=safe_messages, tools=None, api_key=key)
|
||||
resp_msg = response.choices[0].message
|
||||
messages.append(resp_msg.model_dump(exclude_none=True))
|
||||
except Exception: pass
|
||||
|
||||
# IMPORTANT: Manually trigger callback for the summary so Web UI sees it
|
||||
if chunk_callback and resp_msg.content:
|
||||
chunk_callback(resp_msg.content)
|
||||
except Exception:
|
||||
error_msg = "Operation interrupted by user. Summary unavailable."
|
||||
messages.append({"role": "assistant", "content": error_msg})
|
||||
if chunk_callback:
|
||||
chunk_callback(error_msg)
|
||||
finally:
|
||||
# Auto-save session
|
||||
self.save_session(messages, model=model)
|
||||
@@ -1989,7 +2073,7 @@ class ai:
|
||||
<dl>
|
||||
<dt id="connpy.ai.SAFE_COMMANDS"><code class="name">var <span class="ident">SAFE_COMMANDS</span></code></dt>
|
||||
<dd>
|
||||
<div class="desc"></div>
|
||||
<div class="desc"><p>The type of the None singleton.</p></div>
|
||||
</dd>
|
||||
</dl>
|
||||
<h3>Instance variables</h3>
|
||||
@@ -2132,12 +2216,16 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
||||
soft_limit_warned = True
|
||||
|
||||
label = "[architect][bold]Architect[/bold][/architect]" if current_brain == "architect" else "[engineer][bold]Engineer[/bold][/engineer]"
|
||||
if status: status.update(f"{label} is thinking... (step {iteration})")
|
||||
if status:
|
||||
# Notify responder identity ONLY for web/remote clients (StatusBridge has is_web)
|
||||
if getattr(status, "is_web", False):
|
||||
status.update(f"__RESPONDER__:{current_brain}")
|
||||
status.update(f"{label} is thinking... (step {iteration})")
|
||||
|
||||
streamed_response = False
|
||||
try:
|
||||
safe_messages = self._sanitize_messages(messages)
|
||||
if stream and not debug:
|
||||
if stream and (not debug or 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,
|
||||
@@ -2177,7 +2265,10 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
||||
messages.append(msg_dict)
|
||||
|
||||
if debug and resp_msg.content:
|
||||
self.console.print(Panel(Markdown(resp_msg.content), title=f"{label} Reasoning", border_style="architect" if current_brain == "architect" else "engineer"))
|
||||
# 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:
|
||||
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 not resp_msg.tool_calls: break
|
||||
|
||||
@@ -2197,7 +2288,8 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
||||
if fn == "delegate_to_engineer": status.update(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
|
||||
elif fn == "manage_memory_tool": status.update(f"[architect]Architect: [UPDATING MEMORY]")
|
||||
|
||||
if debug: self.console.print(Panel(Text(json.dumps(args, indent=2)), title=f"{label} Decision: {fn}", border_style="debug"))
|
||||
if debug:
|
||||
self._print_debug_observation(f"Decision: {fn}", args)
|
||||
|
||||
if fn == "delegate_to_engineer":
|
||||
obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1])
|
||||
@@ -2255,9 +2347,13 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
||||
elif fn == "manage_memory_tool": obs = self.manage_memory_tool(**args)
|
||||
elif fn in self.external_tool_handlers: obs = self.external_tool_handlers[fn](self, **args)
|
||||
else: obs = f"Error: {fn} unknown."
|
||||
|
||||
messages.append({"tool_call_id": tc.id, "role": "tool", "name": fn, "content": obs})
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Ensure observation is a string and truncated for the LLM
|
||||
obs_str = obs if isinstance(obs, str) else json.dumps(obs)
|
||||
messages.append({"tool_call_id": tc.id, "role": "tool", "name": fn, "content": self._truncate(obs_str)})
|
||||
# Inject pending user message AFTER all tool responses are added
|
||||
if pending_user_message:
|
||||
messages.append({"role": "user", "content": pending_user_message})
|
||||
@@ -2283,14 +2379,25 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
||||
if last_msg.get("tool_calls"):
|
||||
for tc in last_msg["tool_calls"]:
|
||||
messages.append({"tool_call_id": tc.get("id"), "role": "tool", "name": tc.get("function", {}).get("name"), "content": "Operation cancelled by user."})
|
||||
messages.append({"role": "user", "content": "USER INTERRUPTED. Briefly summarize what you were doing and stop."})
|
||||
|
||||
# Use a fresh list for the summary call to avoid history corruption
|
||||
summary_messages = list(messages)
|
||||
summary_messages.append({"role": "user", "content": "USER INTERRUPTED. Briefly summarize what you were doing and stop."})
|
||||
try:
|
||||
safe_messages = self._sanitize_messages(messages)
|
||||
safe_messages = self._sanitize_messages(summary_messages)
|
||||
# Use tools=None to force a text summary during interruption
|
||||
response = completion(model=model, messages=safe_messages, tools=None, api_key=key)
|
||||
resp_msg = response.choices[0].message
|
||||
messages.append(resp_msg.model_dump(exclude_none=True))
|
||||
except Exception: pass
|
||||
|
||||
# IMPORTANT: Manually trigger callback for the summary so Web UI sees it
|
||||
if chunk_callback and resp_msg.content:
|
||||
chunk_callback(resp_msg.content)
|
||||
except Exception:
|
||||
error_msg = "Operation interrupted by user. Summary unavailable."
|
||||
messages.append({"role": "assistant", "content": error_msg})
|
||||
if chunk_callback:
|
||||
chunk_callback(error_msg)
|
||||
finally:
|
||||
# Auto-save session
|
||||
self.save_session(messages, model=model)
|
||||
@@ -2366,7 +2473,7 @@ def confirm(self, user_input): return True</code></pre>
|
||||
try:
|
||||
d = self.config.getitem(node_name, extract=True)
|
||||
if 'password' in d: d['password'] = '***'
|
||||
return json.dumps(d)
|
||||
return d
|
||||
except Exception as e:
|
||||
return f"Error getting node info: {str(e)}"</code></pre>
|
||||
</details>
|
||||
@@ -2394,8 +2501,8 @@ def confirm(self, user_input): return True</code></pre>
|
||||
ts = data.get("tags")
|
||||
if isinstance(ts, dict): os_tag = ts.get("os", "unknown")
|
||||
res[name] = {"os": os_tag}
|
||||
return json.dumps(res)
|
||||
return json.dumps({"count": len(matched_names), "nodes": matched_names, "note": "Use 'get_node_info' for details."})
|
||||
return res
|
||||
return {"count": len(matched_names), "nodes": matched_names, "note": "Use 'get_node_info' for details."}
|
||||
except Exception as e:
|
||||
return f"Error listing nodes: {str(e)}"</code></pre>
|
||||
</details>
|
||||
@@ -2498,14 +2605,20 @@ def confirm(self, user_input): return True</code></pre>
|
||||
status_formatter (callable): Function(args_dict) -> status string.
|
||||
"""
|
||||
name = tool_definition["function"]["name"]
|
||||
|
||||
# Check if already registered to prevent duplicates
|
||||
if target in ("engineer", "both"):
|
||||
self.external_engineer_tools.append(tool_definition)
|
||||
if not any(t["function"]["name"] == name for t in self.external_engineer_tools):
|
||||
self.external_engineer_tools.append(tool_definition)
|
||||
if target in ("architect", "both"):
|
||||
self.external_architect_tools.append(tool_definition)
|
||||
if not any(t["function"]["name"] == name for t in self.external_architect_tools):
|
||||
self.external_architect_tools.append(tool_definition)
|
||||
|
||||
self.external_tool_handlers[name] = handler
|
||||
if engineer_prompt:
|
||||
|
||||
if engineer_prompt and engineer_prompt not in self.engineer_prompt_extensions:
|
||||
self.engineer_prompt_extensions.append(engineer_prompt)
|
||||
if architect_prompt:
|
||||
if architect_prompt and architect_prompt not in self.architect_prompt_extensions:
|
||||
self.architect_prompt_extensions.append(architect_prompt)
|
||||
if status_formatter:
|
||||
self.tool_status_formatters[name] = status_formatter</code></pre>
|
||||
@@ -2601,7 +2714,7 @@ def confirm(self, user_input): return True</code></pre>
|
||||
if not matched_names: return "No nodes found matching filter."
|
||||
thisnodes_dict = self.config.getitems(matched_names, extract=True)
|
||||
result = nodes(thisnodes_dict, config=self.config).run(commands)
|
||||
return self._truncate(json.dumps(result))
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Error executing commands: {str(e)}"</code></pre>
|
||||
</details>
|
||||
@@ -3761,7 +3874,8 @@ class node:
|
||||
size = re.search('columns=([0-9]+).*lines=([0-9]+)',str(os.get_terminal_size()))
|
||||
self.child.setwinsize(int(size.group(2)),int(size.group(1)))
|
||||
if logger:
|
||||
logger("success", "Connected to " + self.unique + " at " + self.host + (":" if self.port != '' else '') + self.port + " via: " + self.protocol)
|
||||
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}")
|
||||
|
||||
if 'logfile' in dir(self):
|
||||
# Initialize self.mylog
|
||||
@@ -3840,7 +3954,8 @@ class node:
|
||||
now = datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')
|
||||
if connect == True:
|
||||
if logger:
|
||||
logger("success", "Connected to " + self.unique + " at " + self.host + (":" if self.port != '' else '') + self.port + " via: " + self.protocol)
|
||||
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:
|
||||
@@ -3941,7 +4056,8 @@ class node:
|
||||
connect = self._connect(timeout = timeout, logger = logger)
|
||||
if connect == True:
|
||||
if logger:
|
||||
logger("success", "Connected to " + self.unique + " at " + self.host + (":" if self.port != '' else '') + self.port + " via: " + self.protocol)
|
||||
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:
|
||||
@@ -4046,6 +4162,19 @@ class node:
|
||||
cmd += f" {docker_command}"
|
||||
return cmd
|
||||
|
||||
@MethodHook
|
||||
def _generate_ssm_cmd(self):
|
||||
region = self.tags.get("region", "") if isinstance(self.tags, dict) else ""
|
||||
profile = self.tags.get("profile", "") if isinstance(self.tags, dict) else ""
|
||||
cmd = f"aws ssm start-session --target {self.host}"
|
||||
if region:
|
||||
cmd += f" --region {region}"
|
||||
if profile:
|
||||
cmd += f" --profile {profile}"
|
||||
if self.options:
|
||||
cmd += f" {self.options}"
|
||||
return cmd
|
||||
|
||||
@MethodHook
|
||||
def _get_cmd(self):
|
||||
if self.protocol in ["ssh", "sftp"]:
|
||||
@@ -4056,6 +4185,8 @@ class node:
|
||||
return self._generate_kube_cmd()
|
||||
elif self.protocol == "docker":
|
||||
return self._generate_docker_cmd()
|
||||
elif self.protocol == "ssm":
|
||||
return self._generate_ssm_cmd()
|
||||
else:
|
||||
printer.error(f"Invalid protocol: {self.protocol}")
|
||||
sys.exit(1)
|
||||
@@ -4076,7 +4207,8 @@ class node:
|
||||
"sftp": ['yes/no', 'refused', 'supported', 'Invalid|[u|U]sage: sftp', 'ssh-keygen.*\"', 'timeout|timed.out', 'unavailable', 'closed', password_prompt, prompt, 'suspend', pexpect.EOF, pexpect.TIMEOUT, "No route to host", "resolve hostname", "no matching", "[b|B]ad (owner|permissions)"],
|
||||
"telnet": ['[u|U]sername:', 'refused', 'supported', 'invalid|unrecognized option', 'ssh-keygen.*\"', 'timeout|timed.out', 'unavailable', 'closed', password_prompt, prompt, 'suspend', pexpect.EOF, pexpect.TIMEOUT, "No route to host", "resolve hostname", "no matching", "[b|B]ad (owner|permissions)"],
|
||||
"kubectl": ['[u|U]sername:', '[r|R]efused', '[E|e]rror', 'DEPRECATED', pexpect.TIMEOUT, password_prompt, prompt, pexpect.EOF, "expired|invalid"],
|
||||
"docker": ['[u|U]sername:', 'Cannot', '[E|e]rror', 'failed', 'not a docker command', 'unknown', 'unable to resolve', pexpect.TIMEOUT, password_prompt, prompt, pexpect.EOF]
|
||||
"docker": ['[u|U]sername:', 'Cannot', '[E|e]rror', 'failed', 'not a docker command', 'unknown', 'unable to resolve', pexpect.TIMEOUT, password_prompt, prompt, pexpect.EOF],
|
||||
"ssm": ['[u|U]sername:', 'Cannot', '[E|e]rror', 'failed', 'SessionManagerPlugin', 'unknown', 'unable to resolve', pexpect.TIMEOUT, password_prompt, prompt, pexpect.EOF]
|
||||
}
|
||||
|
||||
error_indices = {
|
||||
@@ -4084,7 +4216,8 @@ class node:
|
||||
"sftp": [1, 2, 3, 4, 5, 6, 7, 12, 13, 14, 15, 16],
|
||||
"telnet": [1, 2, 3, 4, 5, 6, 7, 12, 13, 14, 15, 16],
|
||||
"kubectl": [1, 2, 3, 4, 8], # Define error indices for kube
|
||||
"docker": [1, 2, 3, 4, 5, 6, 7] # Define error indices for docker
|
||||
"docker": [1, 2, 3, 4, 5, 6, 7], # Define error indices for docker
|
||||
"ssm": [1, 2, 3, 4, 5, 6, 7]
|
||||
}
|
||||
|
||||
eof_indices = {
|
||||
@@ -4092,7 +4225,8 @@ class node:
|
||||
"sftp": [8, 9, 10, 11],
|
||||
"telnet": [8, 9, 10, 11],
|
||||
"kubectl": [5, 6, 7], # Define eof indices for kube
|
||||
"docker": [8, 9, 10] # Define eof indices for docker
|
||||
"docker": [8, 9, 10], # Define eof indices for docker
|
||||
"ssm": [8, 9, 10]
|
||||
}
|
||||
|
||||
initial_indices = {
|
||||
@@ -4100,7 +4234,8 @@ class node:
|
||||
"sftp": [0],
|
||||
"telnet": [0],
|
||||
"kubectl": [0], # Define special indices for kube
|
||||
"docker": [0] # Define special indices for docker
|
||||
"docker": [0], # Define special indices for docker
|
||||
"ssm": [0]
|
||||
}
|
||||
|
||||
attempts = 1
|
||||
@@ -4124,7 +4259,7 @@ class node:
|
||||
if results in initial_indices[self.protocol]:
|
||||
if self.protocol in ["ssh", "sftp"]:
|
||||
child.sendline('yes')
|
||||
elif self.protocol in ["telnet", "kubectl", "docker"]:
|
||||
elif self.protocol in ["telnet", "kubectl", "docker", "ssm"]:
|
||||
if self.user:
|
||||
child.sendline(self.user)
|
||||
else:
|
||||
@@ -4244,7 +4379,8 @@ def interact(self, debug = False, logger = None):
|
||||
size = re.search('columns=([0-9]+).*lines=([0-9]+)',str(os.get_terminal_size()))
|
||||
self.child.setwinsize(int(size.group(2)),int(size.group(1)))
|
||||
if logger:
|
||||
logger("success", "Connected to " + self.unique + " at " + self.host + (":" if self.port != '' else '') + self.port + " via: " + self.protocol)
|
||||
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}")
|
||||
|
||||
if 'logfile' in dir(self):
|
||||
# Initialize self.mylog
|
||||
@@ -4337,7 +4473,8 @@ def run(self, commands, vars = None,*, folder = '', prompt = r'>$
|
||||
now = datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')
|
||||
if connect == True:
|
||||
if logger:
|
||||
logger("success", "Connected to " + self.unique + " at " + self.host + (":" if self.port != '' else '') + self.port + " via: " + self.protocol)
|
||||
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:
|
||||
@@ -4479,7 +4616,8 @@ def test(self, commands, expected, vars = None,*, prompt = r'>$|#$|\$$|&g
|
||||
connect = self._connect(timeout = timeout, logger = logger)
|
||||
if connect == True:
|
||||
if logger:
|
||||
logger("success", "Connected to " + self.unique + " at " + self.host + (":" if self.port != '' else '') + self.port + " via: " + self.protocol)
|
||||
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:
|
||||
@@ -5268,7 +5406,7 @@ def test(self, commands, expected, vars = None,*, prompt = None, parallel = 10,
|
||||
<li><h3><a href="#header-submodules">Sub-modules</a></h3>
|
||||
<ul>
|
||||
<li><code><a title="connpy.cli" href="cli/index.html">connpy.cli</a></code></li>
|
||||
<li><code><a title="connpy.grpc" href="grpc/index.html">connpy.grpc</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer" href="grpc_layer/index.html">connpy.grpc_layer</a></code></li>
|
||||
<li><code><a title="connpy.proto" href="proto/index.html">connpy.proto</a></code></li>
|
||||
<li><code><a title="connpy.services" href="services/index.html">connpy.services</a></code></li>
|
||||
<li><code><a title="connpy.tests" href="tests/index.html">connpy.tests</a></code></li>
|
||||
@@ -5331,7 +5469,7 @@ def test(self, commands, expected, vars = None,*, prompt = None, parallel = 10,
|
||||
</nav>
|
||||
</main>
|
||||
<footer id="footer">
|
||||
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
|
||||
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user