sudosu 0.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sudosu/__init__.py +3 -0
- sudosu/cli.py +561 -0
- sudosu/commands/__init__.py +15 -0
- sudosu/commands/agent.py +318 -0
- sudosu/commands/config.py +96 -0
- sudosu/commands/init.py +73 -0
- sudosu/commands/integrations.py +563 -0
- sudosu/commands/memory.py +170 -0
- sudosu/commands/onboarding.py +319 -0
- sudosu/commands/tasks.py +635 -0
- sudosu/core/__init__.py +238 -0
- sudosu/core/agent_loader.py +263 -0
- sudosu/core/connection.py +196 -0
- sudosu/core/default_agent.py +541 -0
- sudosu/core/prompt_refiner.py +0 -0
- sudosu/core/safety.py +75 -0
- sudosu/core/session.py +205 -0
- sudosu/tools/__init__.py +373 -0
- sudosu/ui/__init__.py +451 -0
- sudosu-0.1.5.dist-info/METADATA +172 -0
- sudosu-0.1.5.dist-info/RECORD +25 -0
- sudosu-0.1.5.dist-info/WHEEL +5 -0
- sudosu-0.1.5.dist-info/entry_points.txt +2 -0
- sudosu-0.1.5.dist-info/licenses/LICENSE +21 -0
- sudosu-0.1.5.dist-info/top_level.txt +1 -0
sudosu/core/__init__.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Core configuration management for Sudosu."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Load environment variables from .env file
|
|
12
|
+
load_dotenv()
|
|
13
|
+
|
|
14
|
+
# Default paths - use XDG-compliant location for app data
|
|
15
|
+
# ~/.local/share/sudosu/ for app data (history, etc.)
|
|
16
|
+
# ~/.config/sudosu/ for config (or keep ~/.sudosu/config.yaml for backwards compat)
|
|
17
|
+
APP_DATA_DIR = Path.home() / ".local" / "share" / "sudosu"
|
|
18
|
+
GLOBAL_CONFIG_DIR = Path.home() / ".sudosu" # Keep for backwards compatibility
|
|
19
|
+
CONFIG_FILE = "config.yaml"
|
|
20
|
+
|
|
21
|
+
# Default backend URLs (hardcoded, not from env vars)
|
|
22
|
+
DEFAULT_DEV_BACKEND_URL = "ws://localhost:8000/ws"
|
|
23
|
+
DEFAULT_PROD_BACKEND_URL = "wss://sudosu-cli.trysudosu.com/ws"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_global_config_dir() -> Path:
|
|
27
|
+
"""Get the global configuration directory."""
|
|
28
|
+
return GLOBAL_CONFIG_DIR
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_app_data_dir() -> Path:
|
|
32
|
+
"""Get the app data directory (for history, cache, etc.)."""
|
|
33
|
+
return APP_DATA_DIR
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_project_config_dir(cwd: Optional[Path] = None) -> Optional[Path]:
|
|
37
|
+
"""Get the project-specific configuration directory if it exists."""
|
|
38
|
+
cwd = cwd or Path.cwd()
|
|
39
|
+
project_config = cwd / ".sudosu"
|
|
40
|
+
if project_config.exists():
|
|
41
|
+
return project_config
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ensure_config_structure() -> Path:
|
|
46
|
+
"""
|
|
47
|
+
Ensure the global config file exists.
|
|
48
|
+
|
|
49
|
+
NOTE: Only creates config.yaml in ~/.sudosu/, nothing else.
|
|
50
|
+
All other files (.sudosu/AGENT.md, agents/, etc.) are project-local.
|
|
51
|
+
"""
|
|
52
|
+
config_dir = get_global_config_dir()
|
|
53
|
+
|
|
54
|
+
# Create config directory (just for config.yaml)
|
|
55
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
|
|
57
|
+
# Create default config if it doesn't exist
|
|
58
|
+
config_file = config_dir / CONFIG_FILE
|
|
59
|
+
if not config_file.exists():
|
|
60
|
+
default_config = {
|
|
61
|
+
"mode": "prod", # default to production
|
|
62
|
+
"backend_url": DEFAULT_PROD_BACKEND_URL,
|
|
63
|
+
"dev_backend_url": DEFAULT_DEV_BACKEND_URL,
|
|
64
|
+
"prod_backend_url": DEFAULT_PROD_BACKEND_URL,
|
|
65
|
+
"api_key": "",
|
|
66
|
+
"default_model": "gemini-2.5-pro",
|
|
67
|
+
"theme": "default",
|
|
68
|
+
}
|
|
69
|
+
with open(config_file, "w", encoding="utf-8") as f:
|
|
70
|
+
yaml.dump(default_config, f, default_flow_style=False)
|
|
71
|
+
|
|
72
|
+
return config_dir
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ensure_app_data_dir() -> Path:
|
|
76
|
+
"""Ensure app data directory exists for history, cache, etc."""
|
|
77
|
+
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
return APP_DATA_DIR
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def ensure_project_structure(cwd: Optional[Path] = None) -> Path:
|
|
82
|
+
"""
|
|
83
|
+
Ensure project-local .sudosu/ structure exists with default AGENT.md.
|
|
84
|
+
|
|
85
|
+
This is called when user runs `sudosu` in a directory.
|
|
86
|
+
Creates:
|
|
87
|
+
- .sudosu/
|
|
88
|
+
- .sudosu/AGENT.md (default agent prompt, user-editable)
|
|
89
|
+
- .sudosu/agents/ (for custom agents)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Path to the project .sudosu directory
|
|
93
|
+
"""
|
|
94
|
+
from sudosu.core.default_agent import generate_default_agent_md
|
|
95
|
+
|
|
96
|
+
cwd = cwd or Path.cwd()
|
|
97
|
+
project_config = cwd / ".sudosu"
|
|
98
|
+
|
|
99
|
+
# Create .sudosu/ if it doesn't exist
|
|
100
|
+
project_config.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
# Create agents/ subdirectory
|
|
103
|
+
agents_dir = project_config / "agents"
|
|
104
|
+
agents_dir.mkdir(exist_ok=True)
|
|
105
|
+
|
|
106
|
+
# Create default AGENT.md if it doesn't exist
|
|
107
|
+
default_agent_file = project_config / "AGENT.md"
|
|
108
|
+
if not default_agent_file.exists():
|
|
109
|
+
default_agent_file.write_text(generate_default_agent_md())
|
|
110
|
+
|
|
111
|
+
return project_config
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_config() -> dict:
|
|
115
|
+
"""Load the global configuration."""
|
|
116
|
+
config_file = get_global_config_dir() / CONFIG_FILE
|
|
117
|
+
|
|
118
|
+
if not config_file.exists():
|
|
119
|
+
return {}
|
|
120
|
+
|
|
121
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
122
|
+
return yaml.safe_load(f) or {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def save_config(config: dict) -> None:
|
|
126
|
+
"""Save the global configuration."""
|
|
127
|
+
config_file = get_global_config_dir() / CONFIG_FILE
|
|
128
|
+
|
|
129
|
+
with open(config_file, "w", encoding="utf-8") as f:
|
|
130
|
+
yaml.dump(config, f, default_flow_style=False)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_config_value(key: str, default: Any = None) -> Any:
|
|
134
|
+
"""Get a specific configuration value."""
|
|
135
|
+
config = load_config()
|
|
136
|
+
return config.get(key, default)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def set_config_value(key: str, value: Any) -> None:
|
|
140
|
+
"""Set a specific configuration value."""
|
|
141
|
+
config = load_config()
|
|
142
|
+
config[key] = value
|
|
143
|
+
save_config(config)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_mode() -> str:
|
|
147
|
+
"""Get the current mode (dev or prod)."""
|
|
148
|
+
# Check environment variable first
|
|
149
|
+
env_mode = os.getenv("SUDOSU_MODE", "").lower()
|
|
150
|
+
if env_mode in ["dev", "prod"]:
|
|
151
|
+
return env_mode
|
|
152
|
+
|
|
153
|
+
# Fall back to config file
|
|
154
|
+
config_mode = get_config_value("mode", "prod")
|
|
155
|
+
return config_mode if config_mode in ["dev", "prod"] else "prod"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def set_mode(mode: str) -> None:
|
|
159
|
+
"""Set the current mode (dev or prod)."""
|
|
160
|
+
if mode not in ["dev", "prod"]:
|
|
161
|
+
raise ValueError("Mode must be 'dev' or 'prod'")
|
|
162
|
+
set_config_value("mode", mode)
|
|
163
|
+
|
|
164
|
+
# Update backend_url based on mode
|
|
165
|
+
if mode == "dev":
|
|
166
|
+
url = get_config_value("dev_backend_url", DEFAULT_DEV_BACKEND_URL)
|
|
167
|
+
else:
|
|
168
|
+
url = get_config_value("prod_backend_url", DEFAULT_PROD_BACKEND_URL)
|
|
169
|
+
|
|
170
|
+
set_config_value("backend_url", url)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_backend_url() -> str:
|
|
174
|
+
"""Get the backend WebSocket URL based on current mode."""
|
|
175
|
+
# Check for direct environment variable override (highest priority)
|
|
176
|
+
env_url = os.getenv("SUDOSU_BACKEND_URL")
|
|
177
|
+
if env_url:
|
|
178
|
+
return env_url
|
|
179
|
+
|
|
180
|
+
# Get current mode
|
|
181
|
+
mode = get_mode()
|
|
182
|
+
|
|
183
|
+
# Get mode-specific URL
|
|
184
|
+
if mode == "dev":
|
|
185
|
+
# Priority: env var > config file > default
|
|
186
|
+
env_url = os.getenv("SUDOSU_DEV_BACKEND_URL")
|
|
187
|
+
if env_url:
|
|
188
|
+
return env_url
|
|
189
|
+
config_url = get_config_value("dev_backend_url")
|
|
190
|
+
return config_url if config_url else DEFAULT_DEV_BACKEND_URL
|
|
191
|
+
else:
|
|
192
|
+
# Priority: env var > config file > default
|
|
193
|
+
env_url = os.getenv("SUDOSU_PROD_BACKEND_URL")
|
|
194
|
+
if env_url:
|
|
195
|
+
return env_url
|
|
196
|
+
config_url = get_config_value("prod_backend_url")
|
|
197
|
+
return config_url if config_url else DEFAULT_PROD_BACKEND_URL
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_agents_dir(project_first: bool = True) -> Optional[Path]:
|
|
201
|
+
"""Get the agents directory (project-specific only).
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Path to project's .sudosu/agents/ or None if not in a project
|
|
205
|
+
"""
|
|
206
|
+
if project_first:
|
|
207
|
+
project_dir = get_project_config_dir()
|
|
208
|
+
if project_dir:
|
|
209
|
+
agents_dir = project_dir / "agents"
|
|
210
|
+
if agents_dir.exists():
|
|
211
|
+
return agents_dir
|
|
212
|
+
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_skills_dir(project_first: bool = True) -> Optional[Path]:
|
|
217
|
+
"""Get the skills directory (project-specific only).
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Path to project's .sudosu/skills/ or None if not in a project
|
|
221
|
+
"""
|
|
222
|
+
if project_first:
|
|
223
|
+
project_dir = get_project_config_dir()
|
|
224
|
+
if project_dir:
|
|
225
|
+
skills_dir = project_dir / "skills"
|
|
226
|
+
if skills_dir.exists():
|
|
227
|
+
return skills_dir
|
|
228
|
+
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# Export session management
|
|
233
|
+
from sudosu.core.session import (
|
|
234
|
+
ConversationSession,
|
|
235
|
+
SessionManager,
|
|
236
|
+
get_session_manager,
|
|
237
|
+
reset_session_manager,
|
|
238
|
+
)
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Agent loader - Parse and load AGENT.md files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import frontmatter
|
|
7
|
+
|
|
8
|
+
from sudosu.core.default_agent import CONTEXT_AWARE_PROMPT, SUB_AGENT_CONSULTATION_PROMPT
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_agent_file(agent_path: Path) -> Optional[dict]:
|
|
12
|
+
"""
|
|
13
|
+
Parse an AGENT.md file and return the agent configuration.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
agent_path: Path to the agent directory containing AGENT.md
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Agent configuration dict or None if invalid
|
|
20
|
+
"""
|
|
21
|
+
agent_md = agent_path / "AGENT.md"
|
|
22
|
+
|
|
23
|
+
if not agent_md.exists():
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
with open(agent_md, "r", encoding="utf-8") as f:
|
|
28
|
+
post = frontmatter.load(f)
|
|
29
|
+
|
|
30
|
+
# Get description - ensure it's a string
|
|
31
|
+
description = post.get("description", "")
|
|
32
|
+
if isinstance(description, list):
|
|
33
|
+
description = "\n".join(str(item) for item in description)
|
|
34
|
+
|
|
35
|
+
# Get tools list
|
|
36
|
+
tools = post.get("tools", ["read_file", "write_file", "list_directory"])
|
|
37
|
+
if isinstance(tools, str):
|
|
38
|
+
tools = [tools]
|
|
39
|
+
|
|
40
|
+
# Get integrations list
|
|
41
|
+
integrations = post.get("integrations", [])
|
|
42
|
+
if isinstance(integrations, str):
|
|
43
|
+
integrations = [integrations]
|
|
44
|
+
|
|
45
|
+
# Get the base system prompt and append context-aware instructions
|
|
46
|
+
base_prompt = str(post.content).strip()
|
|
47
|
+
# Append context awareness to all agents for better conversation handling
|
|
48
|
+
# Also append consultation instructions so sub-agents know when to consult sudosu
|
|
49
|
+
system_prompt = base_prompt + CONTEXT_AWARE_PROMPT + SUB_AGENT_CONSULTATION_PROMPT
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
"name": str(post.get("name", agent_path.name)),
|
|
53
|
+
"description": str(description),
|
|
54
|
+
"model": str(post.get("model", "gemini-2.5-pro")),
|
|
55
|
+
"tools": tools,
|
|
56
|
+
"integrations": integrations,
|
|
57
|
+
"skills": post.get("skills", []),
|
|
58
|
+
"system_prompt": system_prompt,
|
|
59
|
+
"path": str(agent_path),
|
|
60
|
+
}
|
|
61
|
+
except Exception as e:
|
|
62
|
+
print(f"Error parsing agent file {agent_md}: {e}")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def discover_agents(agents_dir: Path) -> list[dict]:
|
|
67
|
+
"""
|
|
68
|
+
Discover all agents in a directory.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
agents_dir: Path to the agents directory
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of agent configurations
|
|
75
|
+
"""
|
|
76
|
+
agents = []
|
|
77
|
+
|
|
78
|
+
if not agents_dir.exists():
|
|
79
|
+
return agents
|
|
80
|
+
|
|
81
|
+
for item in agents_dir.iterdir():
|
|
82
|
+
if item.is_dir():
|
|
83
|
+
agent = parse_agent_file(item)
|
|
84
|
+
if agent:
|
|
85
|
+
agents.append(agent)
|
|
86
|
+
|
|
87
|
+
return agents
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_agent_config(agent_name: str, agents_dirs: list[Path]) -> Optional[dict]:
|
|
91
|
+
"""
|
|
92
|
+
Load a specific agent's configuration by name.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
agent_name: Name of the agent to load
|
|
96
|
+
agents_dirs: List of directories to search for agents
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Agent configuration dict or None if not found
|
|
100
|
+
"""
|
|
101
|
+
for agents_dir in agents_dirs:
|
|
102
|
+
agent_path = agents_dir / agent_name
|
|
103
|
+
if agent_path.exists():
|
|
104
|
+
return parse_agent_file(agent_path)
|
|
105
|
+
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def create_agent_template(
|
|
110
|
+
agent_dir: Path,
|
|
111
|
+
name: str,
|
|
112
|
+
description: str,
|
|
113
|
+
system_prompt: str,
|
|
114
|
+
model: str = "gemini-2.5-pro",
|
|
115
|
+
tools: Optional[list[str]] = None,
|
|
116
|
+
integrations: Optional[list[str]] = None,
|
|
117
|
+
) -> Path:
|
|
118
|
+
"""
|
|
119
|
+
Create a new agent from a template.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
agent_dir: Directory where the agent will be created
|
|
123
|
+
name: Agent name
|
|
124
|
+
description: Agent description
|
|
125
|
+
system_prompt: Agent system prompt (markdown body)
|
|
126
|
+
model: Model to use
|
|
127
|
+
tools: List of allowed tools
|
|
128
|
+
integrations: List of connected integrations the agent should use
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Path to the created agent directory
|
|
132
|
+
"""
|
|
133
|
+
if tools is None:
|
|
134
|
+
tools = ["read_file", "write_file", "list_directory"]
|
|
135
|
+
if integrations is None:
|
|
136
|
+
integrations = []
|
|
137
|
+
|
|
138
|
+
# Create agent directory
|
|
139
|
+
agent_path = agent_dir / name
|
|
140
|
+
agent_path.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
# Create AGENT.md
|
|
143
|
+
agent_md = agent_path / "AGENT.md"
|
|
144
|
+
|
|
145
|
+
# Ensure description is properly quoted for YAML (contains colons, etc.)
|
|
146
|
+
# Use double quotes and escape any internal double quotes
|
|
147
|
+
safe_description = description.replace('"', '\\"')
|
|
148
|
+
|
|
149
|
+
# Build integrations section if any
|
|
150
|
+
integrations_yaml = ""
|
|
151
|
+
if integrations:
|
|
152
|
+
integrations_yaml = f"""integrations:
|
|
153
|
+
{chr(10).join(f' - {integration}' for integration in integrations)}"""
|
|
154
|
+
else:
|
|
155
|
+
integrations_yaml = "integrations: []"
|
|
156
|
+
|
|
157
|
+
content = f"""---
|
|
158
|
+
name: {name}
|
|
159
|
+
description: "{safe_description}"
|
|
160
|
+
model: {model}
|
|
161
|
+
tools:
|
|
162
|
+
{chr(10).join(f' - {tool}' for tool in tools)}
|
|
163
|
+
{integrations_yaml}
|
|
164
|
+
skills: []
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
{system_prompt}
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
with open(agent_md, "w", encoding="utf-8") as f:
|
|
171
|
+
f.write(content)
|
|
172
|
+
|
|
173
|
+
return agent_path
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
DEFAULT_WRITER_PROMPT = """# Writer Agent
|
|
177
|
+
|
|
178
|
+
You are a professional content writer. You help users create well-structured,
|
|
179
|
+
engaging content including blog posts, articles, and documentation.
|
|
180
|
+
|
|
181
|
+
## IMPORTANT: Always Save Your Work
|
|
182
|
+
|
|
183
|
+
**You MUST always save your written content to a file using the write_file tool.**
|
|
184
|
+
- Use kebab-case for filenames (e.g., `my-blog-post.md`)
|
|
185
|
+
- Always use the `.md` extension for blog posts and articles
|
|
186
|
+
- Save the file AFTER you finish writing the content
|
|
187
|
+
|
|
188
|
+
## Guidelines
|
|
189
|
+
|
|
190
|
+
1. If the request is clear, start writing immediately
|
|
191
|
+
2. Structure content with clear headings and sections
|
|
192
|
+
3. Use proper markdown formatting
|
|
193
|
+
4. **Always save the final content to a .md file**
|
|
194
|
+
|
|
195
|
+
## Writing Style
|
|
196
|
+
|
|
197
|
+
- Clear and concise
|
|
198
|
+
- Engaging but professional
|
|
199
|
+
- Well-researched (when applicable)
|
|
200
|
+
- SEO-friendly for blog posts
|
|
201
|
+
|
|
202
|
+
## Workflow
|
|
203
|
+
|
|
204
|
+
1. Write the full content in markdown format
|
|
205
|
+
2. **ALWAYS use write_file to save the content** - this is mandatory
|
|
206
|
+
3. After saving, give a BRIEF confirmation (1-2 sentences max)
|
|
207
|
+
|
|
208
|
+
## CRITICAL: After Saving a File
|
|
209
|
+
|
|
210
|
+
**After the write_file tool succeeds, you MUST:**
|
|
211
|
+
- Give a SHORT confirmation like "✓ Saved to filename.md"
|
|
212
|
+
- Optionally add ONE brief insight or suggestion
|
|
213
|
+
- **DO NOT repeat or summarize the content you just saved**
|
|
214
|
+
- **DO NOT show the content again**
|
|
215
|
+
- **Keep your response after saving to 2-3 sentences maximum**
|
|
216
|
+
|
|
217
|
+
Example good response after saving:
|
|
218
|
+
"✓ Saved your blog post to ai-revolution.md. The article covers 5 key areas where AI is transforming CS. Let me know if you'd like any revisions!"
|
|
219
|
+
|
|
220
|
+
Example BAD response (DO NOT DO THIS):
|
|
221
|
+
"Here is the blog post about AI... [repeating the entire content]"
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
DEFAULT_CODER_PROMPT = """# Coder Agent
|
|
226
|
+
|
|
227
|
+
You are an expert software developer. You help users write, review, and debug code
|
|
228
|
+
across various programming languages and frameworks.
|
|
229
|
+
|
|
230
|
+
## Guidelines
|
|
231
|
+
|
|
232
|
+
1. Understand the project context before making changes
|
|
233
|
+
2. Follow existing code style and conventions
|
|
234
|
+
3. Write clean, well-documented code
|
|
235
|
+
4. Consider edge cases and error handling
|
|
236
|
+
|
|
237
|
+
## Best Practices
|
|
238
|
+
|
|
239
|
+
- Use meaningful variable and function names
|
|
240
|
+
- Add comments for complex logic
|
|
241
|
+
- Follow SOLID principles
|
|
242
|
+
- Write testable code
|
|
243
|
+
|
|
244
|
+
## Output Format
|
|
245
|
+
|
|
246
|
+
When writing code:
|
|
247
|
+
1. Explain the approach first
|
|
248
|
+
2. Show the code with proper formatting
|
|
249
|
+
3. Explain any important decisions
|
|
250
|
+
4. Save files with appropriate names and extensions
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
AGENT_TEMPLATES = {
|
|
255
|
+
"writer": {
|
|
256
|
+
"description": "A helpful writing assistant that creates blog posts, articles, and documentation.",
|
|
257
|
+
"system_prompt": DEFAULT_WRITER_PROMPT,
|
|
258
|
+
},
|
|
259
|
+
"coder": {
|
|
260
|
+
"description": "An expert developer that helps write, review, and debug code.",
|
|
261
|
+
"system_prompt": DEFAULT_CODER_PROMPT,
|
|
262
|
+
},
|
|
263
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""WebSocket connection manager for communicating with the backend."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, AsyncGenerator, Callable, Optional
|
|
6
|
+
|
|
7
|
+
import websockets
|
|
8
|
+
from websockets.client import WebSocketClientProtocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConnectionManager:
|
|
12
|
+
"""Manages WebSocket connection to the Sudosu backend."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, backend_url: str):
|
|
15
|
+
self.backend_url = backend_url
|
|
16
|
+
self.ws: Optional[WebSocketClientProtocol] = None
|
|
17
|
+
self._connected = False
|
|
18
|
+
|
|
19
|
+
async def connect(self) -> bool:
|
|
20
|
+
"""Establish connection to the backend.
|
|
21
|
+
|
|
22
|
+
Timeout values increased to support long-running operations
|
|
23
|
+
like bulk email fetching (100+ emails) and multi-step automations.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
self.ws = await websockets.connect(
|
|
27
|
+
self.backend_url,
|
|
28
|
+
ping_interval=120, # 2 minutes (was 30s)
|
|
29
|
+
ping_timeout=60, # 1 minute (was 10s)
|
|
30
|
+
max_size=20_000_000, # 20MB for large responses
|
|
31
|
+
)
|
|
32
|
+
self._connected = True
|
|
33
|
+
return True
|
|
34
|
+
except Exception as e:
|
|
35
|
+
self._connected = False
|
|
36
|
+
raise ConnectionError(f"Failed to connect to backend: {e}")
|
|
37
|
+
|
|
38
|
+
async def disconnect(self) -> None:
|
|
39
|
+
"""Close the connection."""
|
|
40
|
+
if self.ws:
|
|
41
|
+
await self.ws.close()
|
|
42
|
+
self._connected = False
|
|
43
|
+
self.ws = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_connected(self) -> bool:
|
|
47
|
+
"""Check if connected to backend."""
|
|
48
|
+
return self._connected and self.ws is not None
|
|
49
|
+
|
|
50
|
+
async def send(self, message: dict) -> None:
|
|
51
|
+
"""Send a message to the backend."""
|
|
52
|
+
if not self.is_connected:
|
|
53
|
+
raise ConnectionError("Not connected to backend")
|
|
54
|
+
|
|
55
|
+
await self.ws.send(json.dumps(message))
|
|
56
|
+
|
|
57
|
+
async def receive(self) -> dict:
|
|
58
|
+
"""Receive a message from the backend."""
|
|
59
|
+
if not self.is_connected:
|
|
60
|
+
raise ConnectionError("Not connected to backend")
|
|
61
|
+
|
|
62
|
+
msg = await self.ws.recv()
|
|
63
|
+
return json.loads(msg)
|
|
64
|
+
|
|
65
|
+
async def invoke_agent(
|
|
66
|
+
self,
|
|
67
|
+
agent_config: dict,
|
|
68
|
+
message: str,
|
|
69
|
+
cwd: str,
|
|
70
|
+
session_id: Optional[str] = None,
|
|
71
|
+
thread_id: Optional[str] = None,
|
|
72
|
+
user_id: Optional[str] = None,
|
|
73
|
+
on_text: Optional[Callable[[str], None]] = None,
|
|
74
|
+
on_tool_call: Optional[Callable[[str, dict], Any]] = None,
|
|
75
|
+
on_status: Optional[Callable[[str], None]] = None,
|
|
76
|
+
on_special_message: Optional[Callable[[dict], Any]] = None,
|
|
77
|
+
) -> AsyncGenerator[dict, None]:
|
|
78
|
+
"""
|
|
79
|
+
Invoke an agent and stream the response with session context.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
agent_config: Agent configuration dict
|
|
83
|
+
message: User message
|
|
84
|
+
cwd: Current working directory
|
|
85
|
+
session_id: Session ID for memory continuity across agents
|
|
86
|
+
thread_id: Thread ID for specific conversation continuity
|
|
87
|
+
user_id: User ID for integration tools (Gmail, etc.)
|
|
88
|
+
on_text: Callback for text chunks
|
|
89
|
+
on_tool_call: Callback for tool calls (should return result)
|
|
90
|
+
on_status: Callback for status updates
|
|
91
|
+
on_special_message: Callback for special backend messages (consultation, etc.)
|
|
92
|
+
|
|
93
|
+
Yields:
|
|
94
|
+
Response messages from the backend
|
|
95
|
+
"""
|
|
96
|
+
# Build request with session info for memory
|
|
97
|
+
request = {
|
|
98
|
+
"type": "invoke",
|
|
99
|
+
"agent": agent_config,
|
|
100
|
+
"message": message,
|
|
101
|
+
"cwd": cwd,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Include session info if provided
|
|
105
|
+
if session_id:
|
|
106
|
+
request["session_id"] = session_id
|
|
107
|
+
if thread_id:
|
|
108
|
+
request["thread_id"] = thread_id
|
|
109
|
+
if user_id:
|
|
110
|
+
request["user_id"] = user_id
|
|
111
|
+
|
|
112
|
+
# Send invoke request
|
|
113
|
+
await self.send(request)
|
|
114
|
+
|
|
115
|
+
# Stream responses
|
|
116
|
+
while True:
|
|
117
|
+
try:
|
|
118
|
+
data = await self.receive()
|
|
119
|
+
msg_type = data.get("type")
|
|
120
|
+
|
|
121
|
+
if msg_type == "text":
|
|
122
|
+
if on_text:
|
|
123
|
+
on_text(data.get("content", ""))
|
|
124
|
+
yield data
|
|
125
|
+
|
|
126
|
+
elif msg_type == "tool_call":
|
|
127
|
+
if on_tool_call:
|
|
128
|
+
tool_name = data.get("tool")
|
|
129
|
+
tool_args = data.get("args", {})
|
|
130
|
+
|
|
131
|
+
if on_status:
|
|
132
|
+
on_status(f"Executing {tool_name}...")
|
|
133
|
+
|
|
134
|
+
# Execute tool and get result
|
|
135
|
+
result = await on_tool_call(tool_name, tool_args)
|
|
136
|
+
|
|
137
|
+
# Send result back to backend
|
|
138
|
+
await self.send({
|
|
139
|
+
"type": "tool_result",
|
|
140
|
+
"tool": tool_name,
|
|
141
|
+
"result": result,
|
|
142
|
+
})
|
|
143
|
+
yield data
|
|
144
|
+
|
|
145
|
+
elif msg_type == "status":
|
|
146
|
+
if on_status:
|
|
147
|
+
on_status(data.get("message", ""))
|
|
148
|
+
yield data
|
|
149
|
+
|
|
150
|
+
elif msg_type == "done":
|
|
151
|
+
yield data
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
elif msg_type == "error":
|
|
155
|
+
yield data
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
elif msg_type == "get_available_agents":
|
|
159
|
+
# Backend is requesting available agents for consultation
|
|
160
|
+
if on_special_message:
|
|
161
|
+
response = await on_special_message(data)
|
|
162
|
+
if response:
|
|
163
|
+
await self.send(response)
|
|
164
|
+
yield data
|
|
165
|
+
|
|
166
|
+
elif msg_type == "consultation_route":
|
|
167
|
+
# Backend has decided to route via consultation
|
|
168
|
+
if on_special_message:
|
|
169
|
+
await on_special_message(data)
|
|
170
|
+
yield data
|
|
171
|
+
|
|
172
|
+
elif msg_type == "background_queued":
|
|
173
|
+
# Task has been queued for background execution
|
|
174
|
+
if on_special_message:
|
|
175
|
+
await on_special_message(data)
|
|
176
|
+
yield data
|
|
177
|
+
# Background tasks complete immediately from client perspective
|
|
178
|
+
# The actual work happens in the background
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
else:
|
|
182
|
+
yield data
|
|
183
|
+
|
|
184
|
+
except websockets.exceptions.ConnectionClosed:
|
|
185
|
+
yield {"type": "error", "message": "Connection closed"}
|
|
186
|
+
break
|
|
187
|
+
except Exception as e:
|
|
188
|
+
yield {"type": "error", "message": str(e)}
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def create_connection(backend_url: str) -> ConnectionManager:
|
|
193
|
+
"""Create and connect to the backend."""
|
|
194
|
+
manager = ConnectionManager(backend_url)
|
|
195
|
+
await manager.connect()
|
|
196
|
+
return manager
|