supyagent 0.1.0__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.
Potentially problematic release.
This version of supyagent might be problematic. Click here for more details.
- supyagent/__init__.py +5 -0
- supyagent/__main__.py +8 -0
- supyagent/cli/__init__.py +1 -0
- supyagent/cli/main.py +946 -0
- supyagent/core/__init__.py +21 -0
- supyagent/core/agent.py +379 -0
- supyagent/core/context.py +158 -0
- supyagent/core/credentials.py +275 -0
- supyagent/core/delegation.py +286 -0
- supyagent/core/executor.py +232 -0
- supyagent/core/llm.py +73 -0
- supyagent/core/registry.py +238 -0
- supyagent/core/session_manager.py +233 -0
- supyagent/core/tools.py +235 -0
- supyagent/models/__init__.py +6 -0
- supyagent/models/agent_config.py +86 -0
- supyagent/models/session.py +43 -0
- supyagent/utils/__init__.py +1 -0
- supyagent/utils/paths.py +31 -0
- supyagent-0.1.0.dist-info/METADATA +328 -0
- supyagent-0.1.0.dist-info/RECORD +24 -0
- supyagent-0.1.0.dist-info/WHEEL +4 -0
- supyagent-0.1.0.dist-info/entry_points.txt +2 -0
- supyagent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session manager for persistent conversation storage.
|
|
3
|
+
|
|
4
|
+
Sessions are stored as JSONL files with the following format:
|
|
5
|
+
- First line: Session metadata with type="meta"
|
|
6
|
+
- Subsequent lines: Messages in chronological order
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from supyagent.models.session import Message, Session, SessionMeta
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SessionManager:
|
|
18
|
+
"""
|
|
19
|
+
Manages session persistence using JSONL files.
|
|
20
|
+
|
|
21
|
+
Directory structure:
|
|
22
|
+
.supyagent/sessions/<agent>/<session_id>.jsonl
|
|
23
|
+
.supyagent/sessions/<agent>/current.json
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, base_dir: Path | None = None):
|
|
27
|
+
"""
|
|
28
|
+
Initialize the session manager.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
base_dir: Base directory for session storage (default: .supyagent/sessions)
|
|
32
|
+
"""
|
|
33
|
+
if base_dir is None:
|
|
34
|
+
base_dir = Path(".supyagent/sessions")
|
|
35
|
+
self.base_dir = base_dir
|
|
36
|
+
|
|
37
|
+
def _agent_dir(self, agent: str) -> Path:
|
|
38
|
+
"""Get the directory for an agent's sessions, creating if needed."""
|
|
39
|
+
path = self.base_dir / agent
|
|
40
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
return path
|
|
42
|
+
|
|
43
|
+
def _session_path(self, agent: str, session_id: str) -> Path:
|
|
44
|
+
"""Get the path to a session file."""
|
|
45
|
+
return self._agent_dir(agent) / f"{session_id}.jsonl"
|
|
46
|
+
|
|
47
|
+
def _current_path(self, agent: str) -> Path:
|
|
48
|
+
"""Get the path to the current session pointer file."""
|
|
49
|
+
return self._agent_dir(agent) / "current.json"
|
|
50
|
+
|
|
51
|
+
def create_session(self, agent: str, model: str) -> Session:
|
|
52
|
+
"""
|
|
53
|
+
Create a new session for an agent.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
agent: Agent name
|
|
57
|
+
model: Model being used
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The newly created session
|
|
61
|
+
"""
|
|
62
|
+
meta = SessionMeta(agent=agent, model=model)
|
|
63
|
+
session = Session(meta=meta)
|
|
64
|
+
self._save_session(session)
|
|
65
|
+
self._set_current(agent, meta.session_id)
|
|
66
|
+
return session
|
|
67
|
+
|
|
68
|
+
def load_session(self, agent: str, session_id: str) -> Session | None:
|
|
69
|
+
"""
|
|
70
|
+
Load a session from disk.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
agent: Agent name
|
|
74
|
+
session_id: Session ID to load
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The loaded session, or None if not found
|
|
78
|
+
"""
|
|
79
|
+
path = self._session_path(agent, session_id)
|
|
80
|
+
if not path.exists():
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
messages: list[Message] = []
|
|
84
|
+
meta: SessionMeta | None = None
|
|
85
|
+
|
|
86
|
+
with open(path) as f:
|
|
87
|
+
for line in f:
|
|
88
|
+
line = line.strip()
|
|
89
|
+
if not line:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
data = json.loads(line)
|
|
93
|
+
|
|
94
|
+
if data.get("type") == "meta":
|
|
95
|
+
# Remove the type field before parsing as SessionMeta
|
|
96
|
+
data.pop("type")
|
|
97
|
+
meta = SessionMeta(**data)
|
|
98
|
+
else:
|
|
99
|
+
messages.append(Message(**data))
|
|
100
|
+
|
|
101
|
+
if meta is None:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
return Session(meta=meta, messages=messages)
|
|
105
|
+
|
|
106
|
+
def get_current_session(self, agent: str) -> Session | None:
|
|
107
|
+
"""
|
|
108
|
+
Get the current active session for an agent.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
agent: Agent name
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The current session, or None if no current session
|
|
115
|
+
"""
|
|
116
|
+
current_path = self._current_path(agent)
|
|
117
|
+
if not current_path.exists():
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
with open(current_path) as f:
|
|
121
|
+
data = json.load(f)
|
|
122
|
+
|
|
123
|
+
return self.load_session(agent, data["session_id"])
|
|
124
|
+
|
|
125
|
+
def _set_current(self, agent: str, session_id: str) -> None:
|
|
126
|
+
"""Set the current session for an agent."""
|
|
127
|
+
with open(self._current_path(agent), "w") as f:
|
|
128
|
+
json.dump({"session_id": session_id}, f)
|
|
129
|
+
|
|
130
|
+
def append_message(self, session: Session, message: Message) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Append a message to a session and persist it.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
session: The session to append to
|
|
136
|
+
message: The message to append
|
|
137
|
+
"""
|
|
138
|
+
session.messages.append(message)
|
|
139
|
+
session.meta.updated_at = message.ts
|
|
140
|
+
|
|
141
|
+
# Append to file
|
|
142
|
+
path = self._session_path(session.meta.agent, session.meta.session_id)
|
|
143
|
+
with open(path, "a") as f:
|
|
144
|
+
f.write(message.model_dump_json() + "\n")
|
|
145
|
+
|
|
146
|
+
# Update title if this is the first user message
|
|
147
|
+
if message.type == "user" and session.meta.title is None:
|
|
148
|
+
self._update_title(session, message.content or "")
|
|
149
|
+
|
|
150
|
+
def _update_title(self, session: Session, first_message: str) -> None:
|
|
151
|
+
"""Generate and save a title from the first user message."""
|
|
152
|
+
# Simple truncation for now
|
|
153
|
+
title = first_message[:50]
|
|
154
|
+
if len(first_message) > 50:
|
|
155
|
+
title += "..."
|
|
156
|
+
|
|
157
|
+
session.meta.title = title
|
|
158
|
+
|
|
159
|
+
# Rewrite the session file to update meta
|
|
160
|
+
self._save_session(session)
|
|
161
|
+
|
|
162
|
+
def _save_session(self, session: Session) -> None:
|
|
163
|
+
"""Save the full session to disk."""
|
|
164
|
+
path = self._session_path(session.meta.agent, session.meta.session_id)
|
|
165
|
+
|
|
166
|
+
with open(path, "w") as f:
|
|
167
|
+
# Write meta first with type marker
|
|
168
|
+
meta_dict: dict[str, Any] = session.meta.model_dump()
|
|
169
|
+
meta_dict["type"] = "meta"
|
|
170
|
+
# Convert datetime to string
|
|
171
|
+
meta_dict["created_at"] = session.meta.created_at.isoformat()
|
|
172
|
+
meta_dict["updated_at"] = session.meta.updated_at.isoformat()
|
|
173
|
+
f.write(json.dumps(meta_dict) + "\n")
|
|
174
|
+
|
|
175
|
+
# Write messages
|
|
176
|
+
for msg in session.messages:
|
|
177
|
+
f.write(msg.model_dump_json() + "\n")
|
|
178
|
+
|
|
179
|
+
def list_sessions(self, agent: str) -> list[SessionMeta]:
|
|
180
|
+
"""
|
|
181
|
+
List all sessions for an agent.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
agent: Agent name
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
List of session metadata, sorted by updated_at (newest first)
|
|
188
|
+
"""
|
|
189
|
+
agent_dir = self._agent_dir(agent)
|
|
190
|
+
sessions: list[SessionMeta] = []
|
|
191
|
+
|
|
192
|
+
for path in agent_dir.glob("*.jsonl"):
|
|
193
|
+
try:
|
|
194
|
+
with open(path) as f:
|
|
195
|
+
first_line = f.readline().strip()
|
|
196
|
+
if not first_line:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
data = json.loads(first_line)
|
|
200
|
+
if data.get("type") == "meta":
|
|
201
|
+
data.pop("type")
|
|
202
|
+
sessions.append(SessionMeta(**data))
|
|
203
|
+
except (json.JSONDecodeError, KeyError):
|
|
204
|
+
# Skip invalid session files
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
return sorted(sessions, key=lambda s: s.updated_at, reverse=True)
|
|
208
|
+
|
|
209
|
+
def delete_session(self, agent: str, session_id: str) -> bool:
|
|
210
|
+
"""
|
|
211
|
+
Delete a session.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
agent: Agent name
|
|
215
|
+
session_id: Session ID to delete
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
True if deleted, False if not found
|
|
219
|
+
"""
|
|
220
|
+
path = self._session_path(agent, session_id)
|
|
221
|
+
if path.exists():
|
|
222
|
+
path.unlink()
|
|
223
|
+
|
|
224
|
+
# If this was the current session, clear the current pointer
|
|
225
|
+
current_path = self._current_path(agent)
|
|
226
|
+
if current_path.exists():
|
|
227
|
+
with open(current_path) as f:
|
|
228
|
+
data = json.load(f)
|
|
229
|
+
if data.get("session_id") == session_id:
|
|
230
|
+
current_path.unlink()
|
|
231
|
+
|
|
232
|
+
return True
|
|
233
|
+
return False
|
supyagent/core/tools.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Supypowers tool integration.
|
|
3
|
+
|
|
4
|
+
Discovers and executes tools from supypowers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from supyagent.models.agent_config import ToolPermissions
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Special tool that allows LLM to request credentials from user
|
|
15
|
+
REQUEST_CREDENTIAL_TOOL: dict[str, Any] = {
|
|
16
|
+
"type": "function",
|
|
17
|
+
"function": {
|
|
18
|
+
"name": "request_credential",
|
|
19
|
+
"description": (
|
|
20
|
+
"Request an API key, token, or other credential from the user. "
|
|
21
|
+
"Use this when you need authentication to use a service or API. "
|
|
22
|
+
"The user will be prompted to enter the credential securely."
|
|
23
|
+
),
|
|
24
|
+
"parameters": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"name": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "The environment variable name (e.g., SLACK_API_TOKEN, OPENAI_API_KEY)",
|
|
30
|
+
},
|
|
31
|
+
"description": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Explain to the user why this credential is needed and how to get it",
|
|
34
|
+
},
|
|
35
|
+
"service": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": "The service this credential is for (e.g., 'Slack', 'GitHub', 'OpenAI')",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
"required": ["name", "description"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_credential_request(tool_call: Any) -> bool:
|
|
47
|
+
"""Check if a tool call is a credential request."""
|
|
48
|
+
return tool_call.function.name == "request_credential"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def discover_tools() -> list[dict[str, Any]]:
|
|
52
|
+
"""
|
|
53
|
+
Discover available tools from supypowers.
|
|
54
|
+
|
|
55
|
+
Runs `supypowers docs --format json` and parses the output.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
List of tool definitions from supypowers
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["supypowers", "docs", "--format", "json"],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
timeout=30,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
return json.loads(result.stdout)
|
|
72
|
+
|
|
73
|
+
except FileNotFoundError:
|
|
74
|
+
# supypowers not installed
|
|
75
|
+
return []
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
return []
|
|
78
|
+
except subprocess.TimeoutExpired:
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def execute_tool(
|
|
83
|
+
script: str,
|
|
84
|
+
func: str,
|
|
85
|
+
args: dict[str, Any],
|
|
86
|
+
secrets: dict[str, str] | None = None,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
"""
|
|
89
|
+
Execute a supypowers function.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
script: Script name (e.g., 'web_search')
|
|
93
|
+
func: Function name (e.g., 'search')
|
|
94
|
+
args: Function arguments as a dict
|
|
95
|
+
secrets: Optional secrets to pass as environment variables
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Result dict with 'ok' and 'data' or 'error'
|
|
99
|
+
"""
|
|
100
|
+
cmd = ["supypowers", "run", f"{script}:{func}", json.dumps(args)]
|
|
101
|
+
|
|
102
|
+
# Add secrets
|
|
103
|
+
if secrets:
|
|
104
|
+
for key, value in secrets.items():
|
|
105
|
+
cmd.extend(["--secrets", f"{key}={value}"])
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
result = subprocess.run(
|
|
109
|
+
cmd,
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True,
|
|
112
|
+
timeout=300, # 5 minute timeout for tool execution
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if result.returncode != 0:
|
|
116
|
+
# Try to parse error from stderr or stdout
|
|
117
|
+
error_msg = result.stderr or result.stdout or "Unknown error"
|
|
118
|
+
return {"ok": False, "error": error_msg.strip()}
|
|
119
|
+
|
|
120
|
+
return json.loads(result.stdout)
|
|
121
|
+
|
|
122
|
+
except FileNotFoundError:
|
|
123
|
+
return {"ok": False, "error": "supypowers not installed"}
|
|
124
|
+
except json.JSONDecodeError as e:
|
|
125
|
+
return {"ok": False, "error": f"Invalid JSON response: {e}"}
|
|
126
|
+
except subprocess.TimeoutExpired:
|
|
127
|
+
return {"ok": False, "error": "Tool execution timed out"}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _matches_pattern(name: str, pattern: str) -> bool:
|
|
131
|
+
"""
|
|
132
|
+
Check if a tool name matches a permission pattern.
|
|
133
|
+
|
|
134
|
+
Patterns:
|
|
135
|
+
- "web_search:*" matches all functions in web_search
|
|
136
|
+
- "web_search:search" matches exactly web_search:search
|
|
137
|
+
- "*" matches everything
|
|
138
|
+
"""
|
|
139
|
+
if pattern == "*":
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
if pattern.endswith(":*"):
|
|
143
|
+
script_pattern = pattern[:-2]
|
|
144
|
+
script_name = name.split("__")[0] if "__" in name else name.split(":")[0]
|
|
145
|
+
return script_name == script_pattern
|
|
146
|
+
|
|
147
|
+
# Exact match (convert : to __ for comparison)
|
|
148
|
+
normalized_pattern = pattern.replace(":", "__")
|
|
149
|
+
return name == normalized_pattern or name == pattern
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def filter_tools(
|
|
153
|
+
tools: list[dict[str, Any]],
|
|
154
|
+
permissions: ToolPermissions,
|
|
155
|
+
) -> list[dict[str, Any]]:
|
|
156
|
+
"""
|
|
157
|
+
Filter tools based on permissions.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
tools: List of tool definitions
|
|
161
|
+
permissions: Allow/deny patterns
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Filtered list of tools
|
|
165
|
+
"""
|
|
166
|
+
if not permissions.allow and not permissions.deny:
|
|
167
|
+
# No restrictions - allow all
|
|
168
|
+
return tools
|
|
169
|
+
|
|
170
|
+
filtered = []
|
|
171
|
+
|
|
172
|
+
for tool in tools:
|
|
173
|
+
name = tool.get("function", {}).get("name", "")
|
|
174
|
+
|
|
175
|
+
# Check deny list first
|
|
176
|
+
denied = any(_matches_pattern(name, pattern) for pattern in permissions.deny)
|
|
177
|
+
if denied:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Check allow list (if specified)
|
|
181
|
+
if permissions.allow:
|
|
182
|
+
allowed = any(_matches_pattern(name, pattern) for pattern in permissions.allow)
|
|
183
|
+
if not allowed:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
filtered.append(tool)
|
|
187
|
+
|
|
188
|
+
return filtered
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def supypowers_to_openai_tools(sp_tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
192
|
+
"""
|
|
193
|
+
Convert supypowers tool definitions to OpenAI function calling format.
|
|
194
|
+
|
|
195
|
+
Supypowers docs output format:
|
|
196
|
+
{
|
|
197
|
+
"script": "hello",
|
|
198
|
+
"function": "hello",
|
|
199
|
+
"description": "...",
|
|
200
|
+
"input_schema": {...}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
OpenAI format:
|
|
204
|
+
{
|
|
205
|
+
"type": "function",
|
|
206
|
+
"function": {
|
|
207
|
+
"name": "hello__hello",
|
|
208
|
+
"description": "...",
|
|
209
|
+
"parameters": {...}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
"""
|
|
213
|
+
openai_tools = []
|
|
214
|
+
|
|
215
|
+
for sp_tool in sp_tools:
|
|
216
|
+
script = sp_tool.get("script", "")
|
|
217
|
+
func = sp_tool.get("function", "")
|
|
218
|
+
description = sp_tool.get("description", "No description")
|
|
219
|
+
input_schema = sp_tool.get("input_schema", {"type": "object", "properties": {}})
|
|
220
|
+
|
|
221
|
+
# Use double underscore to join script:function (since : isn't allowed in function names)
|
|
222
|
+
name = f"{script}__{func}"
|
|
223
|
+
|
|
224
|
+
openai_tool = {
|
|
225
|
+
"type": "function",
|
|
226
|
+
"function": {
|
|
227
|
+
"name": name,
|
|
228
|
+
"description": description,
|
|
229
|
+
"parameters": input_schema,
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
openai_tools.append(openai_tool)
|
|
234
|
+
|
|
235
|
+
return openai_tools
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent configuration models.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ModelConfig(BaseModel):
|
|
13
|
+
"""LLM model configuration."""
|
|
14
|
+
|
|
15
|
+
provider: str = Field(..., description="LiteLLM model identifier (e.g., 'anthropic/claude-3-5-sonnet-20241022')")
|
|
16
|
+
temperature: float = Field(default=0.7, ge=0, le=2)
|
|
17
|
+
max_tokens: int = Field(default=4096, gt=0)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolPermissions(BaseModel):
|
|
21
|
+
"""Tool permission settings."""
|
|
22
|
+
|
|
23
|
+
allow: list[str] = Field(default_factory=list, description="Allowed tool patterns (e.g., 'web_search:*')")
|
|
24
|
+
deny: list[str] = Field(default_factory=list, description="Denied tool patterns")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CredentialSpec(BaseModel):
|
|
28
|
+
"""Credential specification."""
|
|
29
|
+
|
|
30
|
+
name: str = Field(..., description="Environment variable name")
|
|
31
|
+
description: str = Field(default="", description="Description of what this credential is for")
|
|
32
|
+
required: bool = Field(default=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AgentConfig(BaseModel):
|
|
36
|
+
"""
|
|
37
|
+
Agent configuration loaded from YAML.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name: str = Field(..., min_length=1, max_length=50)
|
|
41
|
+
description: str = Field(default="")
|
|
42
|
+
version: str = Field(default="1.0")
|
|
43
|
+
type: Literal["interactive", "execution"] = Field(default="interactive")
|
|
44
|
+
model: ModelConfig
|
|
45
|
+
system_prompt: str = Field(..., min_length=1)
|
|
46
|
+
tools: ToolPermissions = Field(default_factory=ToolPermissions)
|
|
47
|
+
delegates: list[str] = Field(default_factory=list)
|
|
48
|
+
credentials: list[CredentialSpec] = Field(default_factory=list)
|
|
49
|
+
limits: dict = Field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AgentNotFoundError(Exception):
|
|
53
|
+
"""Raised when an agent configuration is not found."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, name: str):
|
|
56
|
+
self.name = name
|
|
57
|
+
super().__init__(f"Agent '{name}' not found. Check agents/ directory.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_agent_config(name: str, agents_dir: Path | None = None) -> AgentConfig:
|
|
61
|
+
"""
|
|
62
|
+
Load and validate an agent configuration from YAML.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
name: Agent name (without .yaml extension)
|
|
66
|
+
agents_dir: Directory containing agent YAML files (default: ./agents)
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Validated AgentConfig
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
AgentNotFoundError: If agent YAML doesn't exist
|
|
73
|
+
ValidationError: If YAML is invalid
|
|
74
|
+
"""
|
|
75
|
+
if agents_dir is None:
|
|
76
|
+
agents_dir = Path("agents")
|
|
77
|
+
|
|
78
|
+
config_path = agents_dir / f"{name}.yaml"
|
|
79
|
+
|
|
80
|
+
if not config_path.exists():
|
|
81
|
+
raise AgentNotFoundError(name)
|
|
82
|
+
|
|
83
|
+
with open(config_path) as f:
|
|
84
|
+
data = yaml.safe_load(f)
|
|
85
|
+
|
|
86
|
+
return AgentConfig(**data)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session models for persistent conversation history.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _utcnow() -> datetime:
|
|
13
|
+
"""Get current UTC time."""
|
|
14
|
+
return datetime.now(UTC)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SessionMeta(BaseModel):
|
|
18
|
+
"""Metadata for a session."""
|
|
19
|
+
|
|
20
|
+
session_id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
|
21
|
+
agent: str
|
|
22
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
23
|
+
updated_at: datetime = Field(default_factory=_utcnow)
|
|
24
|
+
model: str
|
|
25
|
+
title: str | None = None # Auto-generated from first message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Message(BaseModel):
|
|
29
|
+
"""A single message in the conversation."""
|
|
30
|
+
|
|
31
|
+
type: Literal["user", "assistant", "tool_result", "system"]
|
|
32
|
+
content: str | None = None
|
|
33
|
+
tool_calls: list[dict[str, Any]] | None = None
|
|
34
|
+
tool_call_id: str | None = None
|
|
35
|
+
name: str | None = None # For tool results
|
|
36
|
+
ts: datetime = Field(default_factory=_utcnow)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Session(BaseModel):
|
|
40
|
+
"""A conversation session with history."""
|
|
41
|
+
|
|
42
|
+
meta: SessionMeta
|
|
43
|
+
messages: list[Message] = Field(default_factory=list)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility functions for supyagent."""
|
supyagent/utils/paths.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Path utilities for supyagent.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_agents_dir() -> Path:
|
|
9
|
+
"""Get the agents directory."""
|
|
10
|
+
return Path("agents")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_supyagent_dir() -> Path:
|
|
14
|
+
"""Get the .supyagent runtime directory."""
|
|
15
|
+
path = Path(".supyagent")
|
|
16
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_sessions_dir() -> Path:
|
|
21
|
+
"""Get the sessions directory."""
|
|
22
|
+
path = get_supyagent_dir() / "sessions"
|
|
23
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_credentials_dir() -> Path:
|
|
28
|
+
"""Get the credentials directory."""
|
|
29
|
+
path = get_supyagent_dir() / "credentials"
|
|
30
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
return path
|