diff --git a/README.md b/README.md index 7b1bc99..ac25f0a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

-# Connpy (v6.1.1) +# Connpy (v6.2.0) [![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/) diff --git a/connpy/__init__.py b/connpy/__init__.py index 61827e8..4db9219 100644 --- a/connpy/__init__.py +++ b/connpy/__init__.py @@ -5,7 +5,7 @@

-# Connpy (v6.1.1) +# Connpy (v6.2.0) [![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/) diff --git a/connpy/_version.py b/connpy/_version.py index 7f8a859..0a895f3 100644 --- a/connpy/_version.py +++ b/connpy/_version.py @@ -1 +1 @@ -__version__ = "6.1.1" +__version__ = "6.2.0" diff --git a/connpy/ai.py b/connpy/ai.py index 5ba7ae1..b5731a9 100755 --- a/connpy/ai.py +++ b/connpy/ai.py @@ -1394,6 +1394,9 @@ Rules: 5. Do NOT provide commands to execute unless specifically requested. Instead, explain the consequences and best practices. 6. Keep your guide concise and authoritative. 7. You MUST output your response in the following strict format: + +Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses. + Your brief tactical guide in markdown. @@ -1402,7 +1405,8 @@ Your brief tactical guide in markdown. low -8. Risk level is usually "low" for read-only/no commands. +8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your are preserved in the assistant history, summarizing key facts in ensures you never lose vital context when older turns expire. +9. Risk level is usually "low" for read-only/no commands. Terminal Context: {terminal_buffer} @@ -1419,6 +1423,9 @@ Rules: 5. If the user wants to execute an action, provide the required CLI commands inside a block, one command per line. If no commands are needed, leave it empty or omit the block. 6. ULTRA-CONCISE. Keep your guide to the point. 7. You MUST output your response in the following strict format: + +Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses. + Your brief tactical guide in markdown. 3-4 sentences max. @@ -1429,7 +1436,8 @@ command 2 low, high, or destructive -8. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops. +8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your are preserved in the assistant history, summarizing key facts in ensures you never lose vital context when older turns expire. +9. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops. Terminal Context: {terminal_buffer} @@ -1457,10 +1465,18 @@ Node: {node_name}""" system_prompt += "\nUse these tools to validate syntax or find exact commands if needed before providing the final guide." messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_question} + {"role": "system", "content": system_prompt} ] + chat_history = node_info.get("chat_history", []) if node_info else [] + if chat_history: + clean_history = chat_history[-10:] + for msg in clean_history: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append({"role": msg["role"], "content": msg["content"]}) + + messages.append({"role": "user", "content": user_question}) + iteration = 0 max_iterations = 5 # Allow up to 5 iterations for tool usage @@ -1582,6 +1598,11 @@ Node: {node_name}""" chunk_callback(new_text) streamed_guide += new_text + notes = "" + notes_match = re.search(r"(.*?)", full_content, re.DOTALL) + if notes_match: + notes = notes_match.group(1).strip() + guide = "" commands = [] risk_level = "low" @@ -1606,6 +1627,7 @@ Node: {node_name}""" return { "commands": commands, "guide": guide, + "notes": notes, "risk_level": risk_level, "error": None } diff --git a/connpy/cli/terminal_ui.py b/connpy/cli/terminal_ui.py index 57c12d2..ebe0131 100644 --- a/connpy/cli/terminal_ui.py +++ b/connpy/cli/terminal_ui.py @@ -36,6 +36,7 @@ class CopilotInterface: self.session_state.setdefault('persona', 'engineer') self.session_state.setdefault('trust_mode', False) self.session_state.setdefault('memories', []) + self.session_state.setdefault('copilot_chat_history', []) self.session_state.setdefault('os', None) self.session_state.setdefault('prompt', None) self.session_state.setdefault('context_mode', self.mode_range) @@ -382,18 +383,11 @@ class CopilotInterface: merged_node_info['persona'] = self.session_state['persona'] merged_node_info['trust'] = self.session_state['trust_mode'] merged_node_info['memories'] = list(self.session_state['memories']) + merged_node_info['chat_history'] = list(self.session_state.get('copilot_chat_history', [])) for k, v in overrides.items(): merged_node_info[k] = v - # Enrich question - past = self.history.get_strings() - if len(past) > 1: - clean_past = [q for q in past[-6:-1] if not q.startswith('/')] - if clean_past: - history_text = "\n".join(f"- {q}" for q in clean_past) - clean_question = f"Previous questions:\n{history_text}\n\nCurrent Question:\n{clean_question}" - # 3. AI Execution # Use persona from overrides (one-shot) or from session state active_persona = merged_node_info.get('persona', self.session_state.get('persona', 'engineer')) @@ -461,6 +455,17 @@ class CopilotInterface: from rich.markdown import Markdown self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color)) + # Update copilot_chat_history with clean Q&A turn + if result and not result.get("error"): + guide = result.get("guide", "") + notes = result.get("notes", "") + if guide: + 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", []) if not commands: self.console.print("") diff --git a/connpy/tests/test_ai_copilot.py b/connpy/tests/test_ai_copilot.py index 7c897f6..5a5e021 100644 --- a/connpy/tests/test_ai_copilot.py +++ b/connpy/tests/test_ai_copilot.py @@ -27,22 +27,21 @@ def mock_acompletion(): with patch('litellm.acompletion') as mock: yield mock +class MockDelta: + def __init__(self, content): + self.content = content + +class MockChoice: + def __init__(self, content): + self.delta = MockDelta(content) + +class MockChunk: + def __init__(self, content): + self.choices = [MockChoice(content)] + def test_aask_copilot_tool_call(mock_acompletion): agent = ai(DummyConfig()) - # Setup mock response for streaming - class MockDelta: - def __init__(self, content): - self.content = content - - class MockChoice: - def __init__(self, content): - self.delta = MockDelta(content) - - class MockChunk: - def __init__(self, content): - self.choices = [MockChoice(content)] - # acompletion is awaited and returns an async iterator async def mock_ac(*args, **kwargs): return MockAsyncIterator([ @@ -557,6 +556,34 @@ def test_copilot_single_mode_retains_command_block(): assert interface_custom.session_state.get('context_cmd') == 4 +def test_aask_copilot_notes_parsing(mock_acompletion): + agent = ai(DummyConfig()) + + async def mock_ac(*args, **kwargs): + return MockAsyncIterator([ + MockChunk("Tool used: mcp_cisco__search. MTU mismatch suspected."), + MockChunk("Check MTU on eth0."), + MockChunk("low") + ]) + + mock_acompletion.side_effect = mock_ac + + async def run_test(): + return await agent.aask_copilot("Router#", "Why is ping failing?", node_info={"chat_history": [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}]}) + + result = asyncio.run(run_test()) + + assert result["error"] is None + assert result["notes"] == "Tool used: mcp_cisco__search. MTU mismatch suspected." + assert result["guide"] == "Check MTU on eth0." + + # Check that messages passed to acompletion included chat_history + call_args = mock_acompletion.call_args[1] + msgs = call_args["messages"] + assert len(msgs) == 4 # system, user(hello), asst(hi), current_user + assert msgs[1]["content"] == "hello" + assert msgs[2]["content"] == "hi" + assert msgs[3]["content"] == "Why is ping failing?" diff --git a/docs/connpy/ai.html b/docs/connpy/ai.html index 5913b4a..43930c3 100644 --- a/docs/connpy/ai.html +++ b/docs/connpy/ai.html @@ -1835,6 +1835,9 @@ Rules: 5. Do NOT provide commands to execute unless specifically requested. Instead, explain the consequences and best practices. 6. Keep your guide concise and authoritative. 7. You MUST output your response in the following strict format: +<notes> +Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses. +</notes> <guide> Your brief tactical guide in markdown. </guide> @@ -1843,7 +1846,8 @@ Your brief tactical guide in markdown. <risk> low </risk> -8. Risk level is usually "low" for read-only/no commands. +8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your <notes> tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your <notes> are preserved in the assistant history, summarizing key facts in <notes> ensures you never lose vital context when older turns expire. +9. Risk level is usually "low" for read-only/no commands. Terminal Context: {terminal_buffer} @@ -1860,6 +1864,9 @@ Rules: 5. If the user wants to execute an action, provide the required CLI commands inside a <commands> block, one command per line. If no commands are needed, leave it empty or omit the block. 6. ULTRA-CONCISE. Keep your guide to the point. 7. You MUST output your response in the following strict format: +<notes> +Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses. +</notes> <guide> Your brief tactical guide in markdown. 3-4 sentences max. </guide> @@ -1870,7 +1877,8 @@ command 2 <risk> low, high, or destructive </risk> -8. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops. +8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your <notes> tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your <notes> are preserved in the assistant history, summarizing key facts in <notes> ensures you never lose vital context when older turns expire. +9. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops. Terminal Context: {terminal_buffer} @@ -1898,10 +1906,18 @@ Node: {node_name}""" system_prompt += "\nUse these tools to validate syntax or find exact commands if needed before providing the final guide." messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_question} + {"role": "system", "content": system_prompt} ] + chat_history = node_info.get("chat_history", []) if node_info else [] + if chat_history: + clean_history = chat_history[-10:] + for msg in clean_history: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append({"role": msg["role"], "content": msg["content"]}) + + messages.append({"role": "user", "content": user_question}) + iteration = 0 max_iterations = 5 # Allow up to 5 iterations for tool usage @@ -2023,6 +2039,11 @@ Node: {node_name}""" chunk_callback(new_text) streamed_guide += new_text + notes = "" + notes_match = re.search(r"<notes>(.*?)</notes>", full_content, re.DOTALL) + if notes_match: + notes = notes_match.group(1).strip() + guide = "" commands = [] risk_level = "low" @@ -2047,6 +2068,7 @@ Node: {node_name}""" return { "commands": commands, "guide": guide, + "notes": notes, "risk_level": risk_level, "error": None } @@ -2173,6 +2195,9 @@ Rules: 5. Do NOT provide commands to execute unless specifically requested. Instead, explain the consequences and best practices. 6. Keep your guide concise and authoritative. 7. You MUST output your response in the following strict format: +<notes> +Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses. +</notes> <guide> Your brief tactical guide in markdown. </guide> @@ -2181,7 +2206,8 @@ Your brief tactical guide in markdown. <risk> low </risk> -8. Risk level is usually "low" for read-only/no commands. +8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your <notes> tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your <notes> are preserved in the assistant history, summarizing key facts in <notes> ensures you never lose vital context when older turns expire. +9. Risk level is usually "low" for read-only/no commands. Terminal Context: {terminal_buffer} @@ -2198,6 +2224,9 @@ Rules: 5. If the user wants to execute an action, provide the required CLI commands inside a <commands> block, one command per line. If no commands are needed, leave it empty or omit the block. 6. ULTRA-CONCISE. Keep your guide to the point. 7. You MUST output your response in the following strict format: +<notes> +Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses. +</notes> <guide> Your brief tactical guide in markdown. 3-4 sentences max. </guide> @@ -2208,7 +2237,8 @@ command 2 <risk> low, high, or destructive </risk> -8. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops. +8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your <notes> tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your <notes> are preserved in the assistant history, summarizing key facts in <notes> ensures you never lose vital context when older turns expire. +9. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops. Terminal Context: {terminal_buffer} @@ -2236,10 +2266,18 @@ Node: {node_name}""" system_prompt += "\nUse these tools to validate syntax or find exact commands if needed before providing the final guide." messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_question} + {"role": "system", "content": system_prompt} ] + chat_history = node_info.get("chat_history", []) if node_info else [] + if chat_history: + clean_history = chat_history[-10:] + for msg in clean_history: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append({"role": msg["role"], "content": msg["content"]}) + + messages.append({"role": "user", "content": user_question}) + iteration = 0 max_iterations = 5 # Allow up to 5 iterations for tool usage @@ -2361,6 +2399,11 @@ Node: {node_name}""" chunk_callback(new_text) streamed_guide += new_text + notes = "" + notes_match = re.search(r"<notes>(.*?)</notes>", full_content, re.DOTALL) + if notes_match: + notes = notes_match.group(1).strip() + guide = "" commands = [] risk_level = "low" @@ -2385,6 +2428,7 @@ Node: {node_name}""" return { "commands": commands, "guide": guide, + "notes": notes, "risk_level": risk_level, "error": None } diff --git a/docs/connpy/cli/terminal_ui.html b/docs/connpy/cli/terminal_ui.html index c13d0a7..0bc0315 100644 --- a/docs/connpy/cli/terminal_ui.html +++ b/docs/connpy/cli/terminal_ui.html @@ -70,6 +70,7 @@ el.replaceWith(d); self.session_state.setdefault('persona', 'engineer') self.session_state.setdefault('trust_mode', False) self.session_state.setdefault('memories', []) + self.session_state.setdefault('copilot_chat_history', []) self.session_state.setdefault('os', None) self.session_state.setdefault('prompt', None) self.session_state.setdefault('context_mode', self.mode_range) @@ -416,18 +417,11 @@ el.replaceWith(d); merged_node_info['persona'] = self.session_state['persona'] merged_node_info['trust'] = self.session_state['trust_mode'] merged_node_info['memories'] = list(self.session_state['memories']) + merged_node_info['chat_history'] = list(self.session_state.get('copilot_chat_history', [])) for k, v in overrides.items(): merged_node_info[k] = v - # Enrich question - past = self.history.get_strings() - if len(past) > 1: - clean_past = [q for q in past[-6:-1] if not q.startswith('/')] - if clean_past: - history_text = "\n".join(f"- {q}" for q in clean_past) - clean_question = f"Previous questions:\n{history_text}\n\nCurrent Question:\n{clean_question}" - # 3. AI Execution # Use persona from overrides (one-shot) or from session state active_persona = merged_node_info.get('persona', self.session_state.get('persona', 'engineer')) @@ -495,6 +489,17 @@ el.replaceWith(d); from rich.markdown import Markdown self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color)) + # Update copilot_chat_history with clean Q&A turn + if result and not result.get("error"): + guide = result.get("guide", "") + notes = result.get("notes", "") + if guide: + 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", []) if not commands: self.console.print("") @@ -922,18 +927,11 @@ el.replaceWith(d); merged_node_info['persona'] = self.session_state['persona'] merged_node_info['trust'] = self.session_state['trust_mode'] merged_node_info['memories'] = list(self.session_state['memories']) + merged_node_info['chat_history'] = list(self.session_state.get('copilot_chat_history', [])) for k, v in overrides.items(): merged_node_info[k] = v - # Enrich question - past = self.history.get_strings() - if len(past) > 1: - clean_past = [q for q in past[-6:-1] if not q.startswith('/')] - if clean_past: - history_text = "\n".join(f"- {q}" for q in clean_past) - clean_question = f"Previous questions:\n{history_text}\n\nCurrent Question:\n{clean_question}" - # 3. AI Execution # Use persona from overrides (one-shot) or from session state active_persona = merged_node_info.get('persona', self.session_state.get('persona', 'engineer')) @@ -1001,6 +999,17 @@ el.replaceWith(d); from rich.markdown import Markdown self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color)) + # Update copilot_chat_history with clean Q&A turn + if result and not result.get("error"): + guide = result.get("guide", "") + notes = result.get("notes", "") + if guide: + 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", []) if not commands: self.console.print("") diff --git a/docs/connpy/index.html b/docs/connpy/index.html index 8d5b805..18d5c23 100644 --- a/docs/connpy/index.html +++ b/docs/connpy/index.html @@ -41,7 +41,7 @@ el.replaceWith(d);

App Logo

-

Connpy (v6.1.1)

+

Connpy (v6.2.0)

@@ -4087,7 +4087,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,