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.

@@ -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
@@ -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,6 @@
1
+ """Data models for supyagent."""
2
+
3
+ from supyagent.models.agent_config import AgentConfig, load_agent_config
4
+ from supyagent.models.session import Message, Session, SessionMeta
5
+
6
+ __all__ = ["AgentConfig", "load_agent_config", "Message", "Session", "SessionMeta"]
@@ -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."""
@@ -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