From 3637d34f6fe10c621951274b7ebc497c7c7187ec Mon Sep 17 00:00:00 2001
From: Fede Luzzi
Date: Thu, 13 Aug 2026 18:49:59 -0300
Subject: [PATCH] feat(copilot): implement 5-turn chat history & internal agent
notes () for CLI & Command Center (v6.2.0)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add multi-turn conversation memory (sliding window of last 5 turns / 10 messages) for Terminal Copilot.
- Introduce XML tag in Copilot response schema for tracking internal agent reasoning, tool usage, and key facts across turns.
- Instruct Copilot via System Prompt about the 5-turn pruning window and mandate summarizing critical facts in .
- Extend Command Center frontend (types.ts, useAISession.ts, AIPanel.tsx) with expandable "🧠Internal Agent Notes / Memory" accordion and browser console response debug logging.
- Ensure 100% transparent serialization across local CLI execution, remote gRPC streams, and Web UI.
- Update documentation and bump version to v6.2.0.
---
README.md | 2 +-
connpy/__init__.py | 2 +-
connpy/_version.py | 2 +-
connpy/ai.py | 30 +++++++++++++---
connpy/cli/terminal_ui.py | 21 ++++++-----
connpy/tests/test_ai_copilot.py | 53 +++++++++++++++++++++-------
docs/connpy/ai.html | 60 +++++++++++++++++++++++++++-----
docs/connpy/cli/terminal_ui.html | 41 +++++++++++++---------
docs/connpy/index.html | 4 +--
9 files changed, 161 insertions(+), 54 deletions(-)
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://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 61827e8..4db9219 100644
--- a/connpy/__init__.py
+++ b/connpy/__init__.py
@@ -5,7 +5,7 @@
-# Connpy (v6.1.1)
+# Connpy (v6.2.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 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);
-Connpy (v6.1.1)
+Connpy (v6.2.0)
@@ -4087,7 +4087,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,