feat: integrate knowledge store search and background multiplexer with autonomous monitoring into agent pipeline

This commit is contained in:
2025-11-04 06:52:36 +00:00
parent e815e2e2a3
commit ed2228db83
14 changed files with 1956 additions and 7 deletions
+45 -2
View File
@@ -72,8 +72,8 @@ def get_tools_definition():
{
"type": "function",
"function": {
"name": "run_command_interactive",
"description": "Execute an interactive terminal command that requires user input or displays UI. The command runs in the user's terminal. Returns exit code only.",
"name": "start_interactive_session",
"description": "Execute an interactive terminal command that requires user input or displays UI. The command runs in a dedicated session and returns a session name.",
"parameters": {
"type": "object",
"properties": {
@@ -83,6 +83,49 @@ def get_tools_definition():
}
}
},
{
"type": "function",
"function": {
"name": "send_input_to_session",
"description": "Send input to an interactive session.",
"parameters": {
"type": "object",
"properties": {
"session_name": {"type": "string", "description": "The name of the session"},
"input_data": {"type": "string", "description": "The input to send to the session"}
},
"required": ["session_name", "input_data"]
}
}
},
{
"type": "function",
"function": {
"name": "read_session_output",
"description": "Read output from an interactive session.",
"parameters": {
"type": "object",
"properties": {
"session_name": {"type": "string", "description": "The name of the session"}
},
"required": ["session_name"]
}
}
},
{
"type": "function",
"function": {
"name": "close_interactive_session",
"description": "Close an interactive session.",
"parameters": {
"type": "object",
"properties": {
"session_name": {"type": "string", "description": "The name of the session"}
},
"required": ["session_name"]
}
}
},
{
"type": "function",
"function": {
+3 -1
View File
@@ -3,6 +3,8 @@ import subprocess
import time
import select
from pr.multiplexer import create_multiplexer, close_multiplexer, get_multiplexer
from pr.tools.interactive_control import start_interactive_session
from pr.config import MAX_CONCURRENT_SESSIONS
_processes = {}
@@ -95,7 +97,7 @@ def tail_process(pid: int, timeout: int = 30):
return {"status": "error", "error": f"Process {pid} not found"}
def run_command(command, timeout=30):
def run_command(command, timeout=30, monitored=False):
mux_name = None
try:
process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+157
View File
@@ -0,0 +1,157 @@
import subprocess
import threading
import time
from pr.multiplexer import create_multiplexer, get_multiplexer, close_multiplexer, get_all_multiplexer_states
def start_interactive_session(command, session_name=None, process_type='generic'):
"""
Start an interactive session in a dedicated multiplexer.
Args:
command: The command to run (list or string)
session_name: Optional name for the session
process_type: Type of process (ssh, vim, apt, etc.)
Returns:
session_name: The name of the created session
"""
name, mux = create_multiplexer(session_name)
mux.update_metadata('process_type', process_type)
# Start the process
if isinstance(command, str):
command = command.split()
try:
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
mux.process = process
mux.update_metadata('pid', process.pid)
# Set process type and handler
detected_type = detect_process_type(command)
mux.set_process_type(detected_type)
# Start output readers
stdout_thread = threading.Thread(target=_read_output, args=(process.stdout, mux.write_stdout), daemon=True)
stderr_thread = threading.Thread(target=_read_output, args=(process.stderr, mux.write_stderr), daemon=True)
stdout_thread.start()
stderr_thread.start()
mux.stdout_thread = stdout_thread
mux.stderr_thread = stderr_thread
return name
except Exception as e:
close_multiplexer(name)
raise e
def _read_output(stream, write_func):
"""Read from a stream and write to multiplexer buffer."""
try:
for line in iter(stream.readline, ''):
if line:
write_func(line.rstrip('\n'))
except Exception as e:
print(f"Error reading output: {e}")
def send_input_to_session(session_name, input_data):
"""
Send input to an interactive session.
Args:
session_name: Name of the session
input_data: Input string to send
"""
mux = get_multiplexer(session_name)
if not mux:
raise ValueError(f"Session {session_name} not found")
if not hasattr(mux, 'process') or mux.process.poll() is not None:
raise ValueError(f"Session {session_name} is not active")
try:
mux.process.stdin.write(input_data + '\n')
mux.process.stdin.flush()
except Exception as e:
raise RuntimeError(f"Failed to send input to session {session_name}: {e}")
def read_session_output(session_name, lines=None):
"""
Read output from a session.
Args:
session_name: Name of the session
lines: Number of recent lines to return (None for all)
Returns:
dict: {'stdout': str, 'stderr': str}
"""
mux = get_multiplexer(session_name)
if not mux:
raise ValueError(f"Session {session_name} not found")
output = mux.get_all_output()
if lines is not None:
# Return last N lines
stdout_lines = output['stdout'].split('\n')[-lines:] if output['stdout'] else []
stderr_lines = output['stderr'].split('\n')[-lines:] if output['stderr'] else []
output = {
'stdout': '\n'.join(stdout_lines),
'stderr': '\n'.join(stderr_lines)
}
return output
def list_active_sessions():
"""
List all active interactive sessions.
Returns:
dict: Session states
"""
return get_all_multiplexer_states()
def get_session_status(session_name):
"""
Get detailed status of a session.
Args:
session_name: Name of the session
Returns:
dict: Session metadata and status
"""
mux = get_multiplexer(session_name)
if not mux:
return None
status = mux.get_metadata()
status['is_active'] = hasattr(mux, 'process') and mux.process.poll() is None
if status['is_active']:
status['pid'] = mux.process.pid
status['output_summary'] = {
'stdout_lines': len(mux.stdout_buffer),
'stderr_lines': len(mux.stderr_buffer)
}
return status
def close_interactive_session(session_name):
"""
Close an interactive session.
"""
try:
mux = get_multiplexer(session_name)
if mux:
mux.process.kill()
close_multiplexer(session_name)
return {"status": "success"}
except Exception as e:
return {"status": "error", "error": str(e)}
+264
View File
@@ -0,0 +1,264 @@
import re
import time
from abc import ABC, abstractmethod
class ProcessHandler(ABC):
"""Base class for process-specific handlers."""
def __init__(self, multiplexer):
self.multiplexer = multiplexer
self.state_machine = {}
self.current_state = 'initial'
self.prompt_patterns = []
self.response_suggestions = {}
@abstractmethod
def get_process_type(self):
"""Return the process type this handler manages."""
pass
def update_state(self, output):
"""Update internal state based on output."""
pass
def get_prompt_suggestions(self):
"""Return suggested responses for current state."""
return self.response_suggestions.get(self.current_state, [])
def is_waiting_for_input(self):
"""Check if process appears to be waiting for input."""
return self.current_state in ['waiting_confirmation', 'waiting_input']
class AptHandler(ProcessHandler):
"""Handler for apt package manager interactions."""
def __init__(self, multiplexer):
super().__init__(multiplexer)
self.state_machine = {
'initial': ['running_command'],
'running_command': ['waiting_confirmation', 'completed'],
'waiting_confirmation': ['confirmed', 'cancelled'],
'confirmed': ['installing', 'completed'],
'installing': ['completed', 'error'],
'completed': [],
'error': [],
'cancelled': []
}
self.prompt_patterns = [
(r'Do you want to continue\?', 'confirmation'),
(r'After this operation.*installed\.', 'size_info'),
(r'Need to get.*B of archives\.', 'download_info'),
(r'Unpacking.*Configuring', 'configuring'),
(r'Setting up', 'setting_up'),
(r'E:\s', 'error')
]
def get_process_type(self):
return 'apt'
def update_state(self, output):
"""Update state based on apt output patterns."""
output_lower = output.lower()
# Check for completion
if 'processing triggers' in output_lower or 'done' in output_lower:
self.current_state = 'completed'
# Check for confirmation prompts
elif 'do you want to continue' in output_lower:
self.current_state = 'waiting_confirmation'
# Check for installation progress
elif 'setting up' in output_lower or 'unpacking' in output_lower:
self.current_state = 'installing'
# Check for errors
elif 'e:' in output_lower or 'error' in output_lower:
self.current_state = 'error'
def get_prompt_suggestions(self):
"""Return suggested responses for apt prompts."""
suggestions = super().get_prompt_suggestions()
if self.current_state == 'waiting_confirmation':
suggestions.extend(['y', 'yes', 'n', 'no'])
return suggestions
class VimHandler(ProcessHandler):
"""Handler for vim editor interactions."""
def __init__(self, multiplexer):
super().__init__(multiplexer)
self.state_machine = {
'initial': ['normal_mode', 'insert_mode'],
'normal_mode': ['insert_mode', 'command_mode', 'visual_mode'],
'insert_mode': ['normal_mode'],
'command_mode': ['normal_mode'],
'visual_mode': ['normal_mode'],
'exiting': []
}
self.prompt_patterns = [
(r'-- INSERT --', 'insert_mode'),
(r'-- VISUAL --', 'visual_mode'),
(r':', 'command_mode'),
(r'Press ENTER', 'waiting_enter'),
(r'Saved', 'saved')
]
self.mode_indicators = {
'insert': '-- INSERT --',
'visual': '-- VISUAL --',
'command': ':'
}
def get_process_type(self):
return 'vim'
def update_state(self, output):
"""Update state based on vim mode indicators."""
if '-- INSERT --' in output:
self.current_state = 'insert_mode'
elif '-- VISUAL --' in output:
self.current_state = 'visual_mode'
elif output.strip().endswith(':'):
self.current_state = 'command_mode'
elif 'Press ENTER' in output:
self.current_state = 'waiting_enter'
else:
# Default to normal mode if no specific indicators
self.current_state = 'normal_mode'
def get_prompt_suggestions(self):
"""Return suggested commands for vim modes."""
suggestions = super().get_prompt_suggestions()
if self.current_state == 'command_mode':
suggestions.extend(['w', 'q', 'wq', 'q!', 'w!'])
elif self.current_state == 'normal_mode':
suggestions.extend(['i', 'a', 'o', 'dd', ':w', ':q'])
elif self.current_state == 'waiting_enter':
suggestions.extend(['\n'])
return suggestions
class SSHHandler(ProcessHandler):
"""Handler for SSH connection interactions."""
def __init__(self, multiplexer):
super().__init__(multiplexer)
self.state_machine = {
'initial': ['connecting'],
'connecting': ['auth_prompt', 'connected', 'failed'],
'auth_prompt': ['connected', 'failed'],
'connected': ['shell', 'disconnected'],
'shell': ['disconnected'],
'failed': [],
'disconnected': []
}
self.prompt_patterns = [
(r'password:', 'password_prompt'),
(r'yes/no', 'host_key_prompt'),
(r'Permission denied', 'auth_failed'),
(r'Welcome to', 'connected'),
(r'\$', 'shell_prompt'),
(r'\#', 'root_shell_prompt'),
(r'Connection closed', 'disconnected')
]
def get_process_type(self):
return 'ssh'
def update_state(self, output):
"""Update state based on SSH connection output."""
output_lower = output.lower()
if 'permission denied' in output_lower:
self.current_state = 'failed'
elif 'password:' in output_lower:
self.current_state = 'auth_prompt'
elif 'yes/no' in output_lower:
self.current_state = 'auth_prompt'
elif 'welcome to' in output_lower or 'last login' in output_lower:
self.current_state = 'connected'
elif output.strip().endswith('$') or output.strip().endswith('#'):
self.current_state = 'shell'
elif 'connection closed' in output_lower:
self.current_state = 'disconnected'
def get_prompt_suggestions(self):
"""Return suggested responses for SSH prompts."""
suggestions = super().get_prompt_suggestions()
if self.current_state == 'auth_prompt':
if 'password:' in self.multiplexer.get_all_output()['stdout']:
suggestions.extend(['<password>']) # Placeholder for actual password
elif 'yes/no' in self.multiplexer.get_all_output()['stdout']:
suggestions.extend(['yes', 'no'])
return suggestions
class GenericProcessHandler(ProcessHandler):
"""Fallback handler for unknown process types."""
def __init__(self, multiplexer):
super().__init__(multiplexer)
self.state_machine = {
'initial': ['running'],
'running': ['waiting_input', 'completed'],
'waiting_input': ['running'],
'completed': []
}
self.prompt_patterns = [
(r'\?\s*$', 'waiting_input'), # Lines ending with ?
(r'>\s*$', 'waiting_input'), # Lines ending with >
(r':\s*$', 'waiting_input'), # Lines ending with :
(r'done', 'completed'),
(r'finished', 'completed'),
(r'exit code', 'completed')
]
def get_process_type(self):
return 'generic'
def update_state(self, output):
"""Basic state detection for generic processes."""
output_lower = output.lower()
if any(pattern in output_lower for pattern in ['done', 'finished', 'complete']):
self.current_state = 'completed'
elif any(output.strip().endswith(char) for char in ['?', '>', ':']):
self.current_state = 'waiting_input'
else:
self.current_state = 'running'
# Handler registry
_handler_classes = {
'apt': AptHandler,
'vim': VimHandler,
'ssh': SSHHandler,
'generic': GenericProcessHandler
}
def get_handler_for_process(process_type, multiplexer):
"""Get appropriate handler for a process type."""
handler_class = _handler_classes.get(process_type, GenericProcessHandler)
return handler_class(multiplexer)
def detect_process_type(command):
"""Detect process type from command."""
command_str = ' '.join(command) if isinstance(command, list) else command
command_lower = command_str.lower()
if 'apt' in command_lower or 'apt-get' in command_lower:
return 'apt'
elif 'vim' in command_lower or 'vi ' in command_lower:
return 'vim'
elif 'ssh' in command_lower:
return 'ssh'
else:
return 'generic'
return 'ssh'
def detect_process_type(command):
"""Detect process type from command."""
command_str = ' '.join(command) if isinstance(command, list) else command
command_lower = command_str.lower()
if 'apt' in command_lower or 'apt-get' in command_lower:
return 'apt'
elif 'vim' in command_lower or 'vi ' in command_lower:
return 'vim'
elif 'ssh' in command_lower:
return 'ssh'
else:
return 'generic'
+278
View File
@@ -0,0 +1,278 @@
import re
import time
from collections import defaultdict
class PromptDetector:
"""Detects various process prompts and manages interaction state."""
def __init__(self):
self.prompt_patterns = self._load_prompt_patterns()
self.state_machines = self._load_state_machines()
self.session_states = {}
self.timeout_configs = {
'default': 30, # 30 seconds default timeout
'apt': 300, # 5 minutes for apt operations
'ssh': 60, # 1 minute for SSH connections
'vim': 3600 # 1 hour for vim sessions
}
def _load_prompt_patterns(self):
"""Load regex patterns for detecting various prompts."""
return {
'bash_prompt': [
re.compile(r'[\w\-\.]+@[\w\-\.]+:.*[\$#]\s*$'),
re.compile(r'\$\s*$'),
re.compile(r'#\s*$'),
re.compile(r'>\s*$') # Continuation prompt
],
'confirmation': [
re.compile(r'[Yy]/[Nn]', re.IGNORECASE),
re.compile(r'[Yy]es/[Nn]o', re.IGNORECASE),
re.compile(r'continue\?', re.IGNORECASE),
re.compile(r'proceed\?', re.IGNORECASE)
],
'password': [
re.compile(r'password:', re.IGNORECASE),
re.compile(r'passphrase:', re.IGNORECASE),
re.compile(r'enter password', re.IGNORECASE)
],
'sudo_password': [
re.compile(r'\[sudo\].*password', re.IGNORECASE)
],
'apt': [
re.compile(r'Do you want to continue\?', re.IGNORECASE),
re.compile(r'After this operation', re.IGNORECASE),
re.compile(r'Need to get', re.IGNORECASE)
],
'vim': [
re.compile(r'-- INSERT --'),
re.compile(r'-- VISUAL --'),
re.compile(r':'),
re.compile(r'Press ENTER', re.IGNORECASE)
],
'ssh': [
re.compile(r'yes/no', re.IGNORECASE),
re.compile(r'password:', re.IGNORECASE),
re.compile(r'Permission denied', re.IGNORECASE)
],
'git': [
re.compile(r'Username:', re.IGNORECASE),
re.compile(r'Email:', re.IGNORECASE)
],
'error': [
re.compile(r'error:', re.IGNORECASE),
re.compile(r'failed', re.IGNORECASE),
re.compile(r'exception', re.IGNORECASE)
]
}
def _load_state_machines(self):
"""Load state machines for different process types."""
return {
'apt': {
'states': ['initial', 'running', 'confirming', 'installing', 'completed', 'error'],
'transitions': {
'initial': ['running'],
'running': ['confirming', 'installing', 'completed', 'error'],
'confirming': ['installing', 'cancelled'],
'installing': ['completed', 'error'],
'completed': [],
'error': [],
'cancelled': []
}
},
'ssh': {
'states': ['initial', 'connecting', 'authenticating', 'connected', 'error'],
'transitions': {
'initial': ['connecting'],
'connecting': ['authenticating', 'connected', 'error'],
'authenticating': ['connected', 'error'],
'connected': ['error'],
'error': []
}
},
'vim': {
'states': ['initial', 'normal', 'insert', 'visual', 'command', 'exiting'],
'transitions': {
'initial': ['normal', 'insert'],
'normal': ['insert', 'visual', 'command', 'exiting'],
'insert': ['normal'],
'visual': ['normal'],
'command': ['normal', 'exiting'],
'exiting': []
}
}
}
def detect_prompt(self, output, process_type='generic'):
"""Detect what type of prompt is present in the output."""
detections = {}
# Check all pattern categories
for category, patterns in self.prompt_patterns.items():
for pattern in patterns:
if pattern.search(output):
if category not in detections:
detections[category] = []
detections[category].append(pattern.pattern)
# Process type specific detection
if process_type in self.prompt_patterns:
for pattern in self.prompt_patterns[process_type]:
if pattern.search(output):
detections[process_type] = detections.get(process_type, [])
detections[process_type].append(pattern.pattern)
return detections
def get_response_suggestions(self, prompt_detections, process_type='generic'):
"""Get suggested responses based on detected prompts."""
suggestions = []
for category, patterns in prompt_detections.items():
if category == 'confirmation':
suggestions.extend(['y', 'yes', 'n', 'no'])
elif category == 'password':
suggestions.append('<password>')
elif category == 'sudo_password':
suggestions.append('<sudo_password>')
elif category == 'apt':
if any('continue' in p for p in patterns):
suggestions.extend(['y', 'yes'])
elif category == 'vim':
if any(':' in p for p in patterns):
suggestions.extend(['w', 'q', 'wq', 'q!'])
elif any('ENTER' in p for p in patterns):
suggestions.append('\n')
elif category == 'ssh':
if any('yes/no' in p for p in patterns):
suggestions.extend(['yes', 'no'])
elif any('password' in p for p in patterns):
suggestions.append('<password>')
elif category == 'bash_prompt':
suggestions.extend(['help', 'ls', 'pwd', 'exit'])
return list(set(suggestions)) # Remove duplicates
def update_session_state(self, session_name, output, process_type='generic'):
"""Update the state machine for a session based on output."""
if session_name not in self.session_states:
self.session_states[session_name] = {
'current_state': 'initial',
'process_type': process_type,
'last_activity': time.time(),
'transitions': []
}
session_state = self.session_states[session_name]
old_state = session_state['current_state']
# Detect prompts and determine new state
detections = self.detect_prompt(output, process_type)
new_state = self._determine_state_from_detections(detections, process_type, old_state)
if new_state != old_state:
session_state['transitions'].append({
'from': old_state,
'to': new_state,
'timestamp': time.time(),
'trigger': detections
})
session_state['current_state'] = new_state
session_state['last_activity'] = time.time()
return new_state
def _determine_state_from_detections(self, detections, process_type, current_state):
"""Determine new state based on prompt detections."""
if process_type in self.state_machines:
state_machine = self.state_machines[process_type]
# State transition logic based on detections
if 'confirmation' in detections and current_state in ['running', 'initial']:
return 'confirming'
elif 'password' in detections or 'sudo_password' in detections:
return 'authenticating'
elif 'error' in detections:
return 'error'
elif 'bash_prompt' in detections and current_state != 'initial':
return 'connected' if process_type == 'ssh' else 'completed'
elif 'vim' in detections:
if any('-- INSERT --' in p for p in detections.get('vim', [])):
return 'insert'
elif any('-- VISUAL --' in p for p in detections.get('vim', [])):
return 'visual'
elif any(':' in p for p in detections.get('vim', [])):
return 'command'
# Default state progression
if current_state == 'initial':
return 'running'
elif current_state == 'running' and detections:
return 'waiting_input'
elif current_state == 'waiting_input' and not detections:
return 'running'
return current_state
def is_waiting_for_input(self, session_name):
"""Check if a session is currently waiting for input."""
if session_name not in self.session_states:
return False
state = self.session_states[session_name]['current_state']
process_type = self.session_states[session_name]['process_type']
# States that typically indicate waiting for input
waiting_states = {
'generic': ['waiting_input'],
'apt': ['confirming'],
'ssh': ['authenticating'],
'vim': ['command', 'insert', 'visual']
}
return state in waiting_states.get(process_type, [])
def get_session_timeout(self, session_name):
"""Get the timeout for a session based on its process type."""
if session_name not in self.session_states:
return self.timeout_configs['default']
process_type = self.session_states[session_name]['process_type']
return self.timeout_configs.get(process_type, self.timeout_configs['default'])
def check_for_timeouts(self):
"""Check all sessions for timeouts and return timed out sessions."""
timed_out = []
current_time = time.time()
for session_name, state in self.session_states.items():
timeout = self.get_session_timeout(session_name)
if current_time - state['last_activity'] > timeout:
timed_out.append(session_name)
return timed_out
def get_session_info(self, session_name):
"""Get information about a session's state."""
if session_name not in self.session_states:
return None
state = self.session_states[session_name]
return {
'current_state': state['current_state'],
'process_type': state['process_type'],
'last_activity': state['last_activity'],
'transitions': state['transitions'][-5:], # Last 5 transitions
'is_waiting': self.is_waiting_for_input(session_name)
}
# Global detector instance
_detector = None
def get_global_detector():
"""Get the global prompt detector instance."""
global _detector
if _detector is None:
_detector = PromptDetector()
return _detector