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,275 @@
1
+ """
2
+ Credential manager for secure storage and retrieval of API keys and tokens.
3
+
4
+ Credentials are encrypted using Fernet (AES-128-CBC) and stored per-agent.
5
+ """
6
+
7
+ import getpass
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+
12
+ from cryptography.fernet import Fernet, InvalidToken
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.prompt import Confirm
16
+
17
+ console = Console()
18
+
19
+
20
+ class CredentialManager:
21
+ """
22
+ Manages secure credential storage and retrieval.
23
+
24
+ Features:
25
+ - Encrypted storage using Fernet (AES-128-CBC)
26
+ - Per-agent credential isolation
27
+ - Environment variable fallback
28
+ - Interactive prompting with hidden input
29
+ - Persistence choice for users
30
+
31
+ Directory structure:
32
+ .supyagent/credentials/.key # Encryption key (600 permissions)
33
+ .supyagent/credentials/<agent>.enc # Encrypted credentials
34
+ """
35
+
36
+ def __init__(self, base_dir: Path | None = None):
37
+ """
38
+ Initialize the credential manager.
39
+
40
+ Args:
41
+ base_dir: Base directory for credential storage
42
+ """
43
+ if base_dir is None:
44
+ base_dir = Path(".supyagent/credentials")
45
+ self.base_dir = base_dir
46
+ self.base_dir.mkdir(parents=True, exist_ok=True)
47
+ self._fernet = self._get_fernet()
48
+ self._cache: dict[str, dict[str, str]] = {}
49
+
50
+ def _get_fernet(self) -> Fernet:
51
+ """Get or create the encryption key."""
52
+ key_file = self.base_dir / ".key"
53
+
54
+ if key_file.exists():
55
+ key = key_file.read_bytes()
56
+ else:
57
+ key = Fernet.generate_key()
58
+ key_file.write_bytes(key)
59
+ # Set restrictive permissions (owner read/write only)
60
+ try:
61
+ key_file.chmod(0o600)
62
+ except OSError:
63
+ pass # Windows doesn't support chmod the same way
64
+
65
+ return Fernet(key)
66
+
67
+ def _cred_path(self, agent: str) -> Path:
68
+ """Get the path to an agent's credential file."""
69
+ return self.base_dir / f"{agent}.enc"
70
+
71
+ def _load_credentials(self, agent: str) -> dict[str, str]:
72
+ """
73
+ Load and decrypt credentials for an agent.
74
+
75
+ Args:
76
+ agent: Agent name
77
+
78
+ Returns:
79
+ Dict of credential name -> value
80
+ """
81
+ if agent in self._cache:
82
+ return self._cache[agent]
83
+
84
+ path = self._cred_path(agent)
85
+ if not path.exists():
86
+ self._cache[agent] = {}
87
+ return {}
88
+
89
+ try:
90
+ encrypted = path.read_bytes()
91
+ decrypted = self._fernet.decrypt(encrypted)
92
+ creds = json.loads(decrypted)
93
+ self._cache[agent] = creds
94
+ return creds
95
+ except (InvalidToken, json.JSONDecodeError):
96
+ # Corrupted or invalid file
97
+ self._cache[agent] = {}
98
+ return {}
99
+
100
+ def _save_credentials(self, agent: str, creds: dict[str, str]) -> None:
101
+ """
102
+ Encrypt and save credentials.
103
+
104
+ Args:
105
+ agent: Agent name
106
+ creds: Dict of credentials to save
107
+ """
108
+ encrypted = self._fernet.encrypt(json.dumps(creds).encode())
109
+ path = self._cred_path(agent)
110
+ path.write_bytes(encrypted)
111
+ # Set restrictive permissions
112
+ try:
113
+ path.chmod(0o600)
114
+ except OSError:
115
+ pass
116
+ self._cache[agent] = creds
117
+
118
+ def get(self, agent: str, name: str) -> str | None:
119
+ """
120
+ Get a credential value.
121
+
122
+ Checks in order:
123
+ 1. Environment variables
124
+ 2. Stored credentials
125
+
126
+ Args:
127
+ agent: Agent name
128
+ name: Credential name (e.g., "OPENAI_API_KEY")
129
+
130
+ Returns:
131
+ Credential value or None if not found
132
+ """
133
+ # First check environment
134
+ if name in os.environ:
135
+ return os.environ[name]
136
+
137
+ # Then check stored credentials
138
+ creds = self._load_credentials(agent)
139
+ return creds.get(name)
140
+
141
+ def set(
142
+ self,
143
+ agent: str,
144
+ name: str,
145
+ value: str,
146
+ persist: bool = True,
147
+ ) -> None:
148
+ """
149
+ Set a credential value.
150
+
151
+ Args:
152
+ agent: Agent name
153
+ name: Credential name
154
+ value: Credential value
155
+ persist: If True, save to encrypted storage. If False, only set in environment.
156
+ """
157
+ if persist:
158
+ creds = self._load_credentials(agent)
159
+ creds[name] = value
160
+ self._save_credentials(agent, creds)
161
+ else:
162
+ # Session-only: just set in environment
163
+ os.environ[name] = value
164
+
165
+ def has(self, agent: str, name: str) -> bool:
166
+ """
167
+ Check if a credential exists.
168
+
169
+ Args:
170
+ agent: Agent name
171
+ name: Credential name
172
+
173
+ Returns:
174
+ True if credential exists
175
+ """
176
+ return self.get(agent, name) is not None
177
+
178
+ def prompt_for_credential(
179
+ self,
180
+ name: str,
181
+ description: str,
182
+ service: str | None = None,
183
+ ) -> tuple[str, bool] | None:
184
+ """
185
+ Interactively prompt user for a credential.
186
+
187
+ Displays a nice panel and uses hidden input for the value.
188
+
189
+ Args:
190
+ name: Credential name
191
+ description: Why this credential is needed
192
+ service: Optional service name
193
+
194
+ Returns:
195
+ Tuple of (value, should_persist) or None if user skipped
196
+ """
197
+ console.print()
198
+
199
+ # Build the panel content
200
+ service_line = f"\nService: [cyan]{service}[/cyan]" if service else ""
201
+ content = f"[bold]{name}[/bold]{service_line}\n\n{description}"
202
+
203
+ console.print(
204
+ Panel(
205
+ content,
206
+ title="🔑 Credential Required",
207
+ border_style="yellow",
208
+ )
209
+ )
210
+
211
+ # Get the value with hidden input
212
+ value = getpass.getpass("Enter value (or press Enter to skip): ")
213
+
214
+ if not value:
215
+ console.print("[dim]Skipped[/dim]")
216
+ return None
217
+
218
+ # Ask about persistence
219
+ persist = Confirm.ask("Save for future sessions?", default=True)
220
+
221
+ return value, persist
222
+
223
+ def list_credentials(self, agent: str) -> list[str]:
224
+ """
225
+ List stored credential names for an agent.
226
+
227
+ Args:
228
+ agent: Agent name
229
+
230
+ Returns:
231
+ List of credential names
232
+ """
233
+ creds = self._load_credentials(agent)
234
+ return list(creds.keys())
235
+
236
+ def delete(self, agent: str, name: str) -> bool:
237
+ """
238
+ Delete a stored credential.
239
+
240
+ Args:
241
+ agent: Agent name
242
+ name: Credential name
243
+
244
+ Returns:
245
+ True if deleted, False if not found
246
+ """
247
+ creds = self._load_credentials(agent)
248
+ if name in creds:
249
+ del creds[name]
250
+ self._save_credentials(agent, creds)
251
+ return True
252
+ return False
253
+
254
+ def get_all_for_tools(self, agent: str) -> dict[str, str]:
255
+ """
256
+ Get all credentials for tool execution.
257
+
258
+ Combines environment variables with stored credentials
259
+ (environment takes precedence).
260
+
261
+ Args:
262
+ agent: Agent name
263
+
264
+ Returns:
265
+ Dict of all available credentials
266
+ """
267
+ # Start with stored credentials
268
+ creds = dict(self._load_credentials(agent))
269
+
270
+ # Override with environment variables (they take precedence)
271
+ for name in creds:
272
+ if name in os.environ:
273
+ creds[name] = os.environ[name]
274
+
275
+ return creds
@@ -0,0 +1,286 @@
1
+ """
2
+ Delegation Manager for agent-to-agent task delegation.
3
+
4
+ Enables agents to invoke other agents (delegates) to perform subtasks,
5
+ supporting multi-agent orchestration patterns.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from supyagent.core.context import DelegationContext, summarize_conversation
14
+ from supyagent.core.executor import ExecutionRunner
15
+ from supyagent.core.registry import AgentRegistry
16
+ from supyagent.models.agent_config import AgentNotFoundError, load_agent_config
17
+
18
+ if TYPE_CHECKING:
19
+ from supyagent.core.agent import Agent
20
+
21
+
22
+ class DelegationManager:
23
+ """
24
+ Manages agent-to-agent delegation.
25
+
26
+ Provides tools that allow a parent agent to delegate tasks to
27
+ child agents (delegates), with proper context passing.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ registry: AgentRegistry,
33
+ parent_agent: "Agent",
34
+ grandparent_instance_id: str | None = None,
35
+ ):
36
+ """
37
+ Initialize the delegation manager.
38
+
39
+ Args:
40
+ registry: Agent registry for tracking instances
41
+ parent_agent: The parent agent that will do the delegating
42
+ grandparent_instance_id: Instance ID of the agent that created this one (if any)
43
+ """
44
+ self.registry = registry
45
+ self.parent = parent_agent
46
+
47
+ # Always register this agent, optionally with a grandparent
48
+ self.parent_id = registry.register(parent_agent, parent_id=grandparent_instance_id)
49
+
50
+ def get_delegation_tools(self) -> list[dict[str, Any]]:
51
+ """
52
+ Generate tool schemas for each delegatable agent.
53
+
54
+ Returns:
55
+ List of OpenAI-format tool definitions
56
+ """
57
+ tools: list[dict[str, Any]] = []
58
+
59
+ # Get delegate configurations
60
+ for delegate_name in self.parent.config.delegates:
61
+ try:
62
+ delegate_config = load_agent_config(delegate_name)
63
+ except AgentNotFoundError:
64
+ continue
65
+
66
+ tool = {
67
+ "type": "function",
68
+ "function": {
69
+ "name": f"delegate_to_{delegate_name}",
70
+ "description": (
71
+ f"Delegate a task to the {delegate_name} agent. "
72
+ f"{delegate_config.description}"
73
+ ),
74
+ "parameters": {
75
+ "type": "object",
76
+ "properties": {
77
+ "task": {
78
+ "type": "string",
79
+ "description": "The task to delegate to this agent",
80
+ },
81
+ "context": {
82
+ "type": "string",
83
+ "description": (
84
+ "Optional context from the current conversation "
85
+ "to pass along"
86
+ ),
87
+ },
88
+ },
89
+ "required": ["task"],
90
+ },
91
+ },
92
+ }
93
+ tools.append(tool)
94
+
95
+ # Add generic spawn tool
96
+ if self.parent.config.delegates:
97
+ tools.append({
98
+ "type": "function",
99
+ "function": {
100
+ "name": "spawn_agent",
101
+ "description": (
102
+ "Create and run a new agent instance for a specific task. "
103
+ f"Available agents: {', '.join(self.parent.config.delegates)}"
104
+ ),
105
+ "parameters": {
106
+ "type": "object",
107
+ "properties": {
108
+ "agent_type": {
109
+ "type": "string",
110
+ "description": "The type of agent to spawn",
111
+ "enum": self.parent.config.delegates,
112
+ },
113
+ "task": {
114
+ "type": "string",
115
+ "description": "The task for the agent to perform",
116
+ },
117
+ },
118
+ "required": ["agent_type", "task"],
119
+ },
120
+ },
121
+ })
122
+
123
+ return tools
124
+
125
+ def is_delegation_tool(self, tool_name: str) -> bool:
126
+ """Check if a tool name is a delegation tool."""
127
+ return (
128
+ tool_name.startswith("delegate_to_")
129
+ or tool_name == "spawn_agent"
130
+ )
131
+
132
+ def execute_delegation(self, tool_call: Any) -> dict[str, Any]:
133
+ """
134
+ Execute a delegation tool call.
135
+
136
+ Args:
137
+ tool_call: The tool call from the LLM
138
+
139
+ Returns:
140
+ Result dict with ok/data or ok/error
141
+ """
142
+ name = tool_call.function.name
143
+
144
+ try:
145
+ args = json.loads(tool_call.function.arguments)
146
+ except json.JSONDecodeError:
147
+ return {"ok": False, "error": "Invalid JSON in tool arguments"}
148
+
149
+ if name == "spawn_agent":
150
+ return self._spawn_agent(
151
+ args.get("agent_type", ""),
152
+ args.get("task", ""),
153
+ )
154
+
155
+ if name.startswith("delegate_to_"):
156
+ agent_name = name[len("delegate_to_"):]
157
+ return self._delegate_task(
158
+ agent_name,
159
+ args.get("task", ""),
160
+ args.get("context"),
161
+ )
162
+
163
+ return {"ok": False, "error": f"Unknown delegation tool: {name}"}
164
+
165
+ def _build_context(
166
+ self,
167
+ task: str,
168
+ extra_context: str | None = None,
169
+ ) -> DelegationContext:
170
+ """Build context to pass to a delegate."""
171
+ # Get conversation summary if we have messages
172
+ summary = None
173
+ if hasattr(self.parent, "messages") and self.parent.messages:
174
+ summary = summarize_conversation(
175
+ self.parent.messages,
176
+ self.parent.llm,
177
+ )
178
+
179
+ context = DelegationContext(
180
+ parent_agent=self.parent.config.name,
181
+ parent_task=task,
182
+ conversation_summary=summary,
183
+ )
184
+
185
+ if extra_context:
186
+ context.relevant_facts.append(extra_context)
187
+
188
+ return context
189
+
190
+ def _delegate_task(
191
+ self,
192
+ agent_name: str,
193
+ task: str,
194
+ extra_context: str | None = None,
195
+ ) -> dict[str, Any]:
196
+ """
197
+ Delegate a task to another agent.
198
+
199
+ Args:
200
+ agent_name: Name of the agent to delegate to
201
+ task: The task to perform
202
+ extra_context: Optional additional context
203
+
204
+ Returns:
205
+ Result dict
206
+ """
207
+ # Verify it's in the delegates list
208
+ if agent_name not in self.parent.config.delegates:
209
+ return {
210
+ "ok": False,
211
+ "error": f"Agent '{agent_name}' is not in the delegates list",
212
+ }
213
+
214
+ # Load the delegate config
215
+ try:
216
+ config = load_agent_config(agent_name)
217
+ except AgentNotFoundError:
218
+ return {"ok": False, "error": f"Agent '{agent_name}' not found"}
219
+
220
+ # Build context
221
+ context = self._build_context(task, extra_context)
222
+ full_task = f"{context.to_prompt()}\n\n---\n\nYour task:\n{task}"
223
+
224
+ # Check delegation depth
225
+ parent_depth = self.registry.get_depth(self.parent_id)
226
+ if parent_depth >= AgentRegistry.MAX_DEPTH:
227
+ return {
228
+ "ok": False,
229
+ "error": (
230
+ f"Maximum delegation depth ({AgentRegistry.MAX_DEPTH}) reached. "
231
+ "Cannot delegate further."
232
+ ),
233
+ }
234
+
235
+ try:
236
+ if config.type == "execution":
237
+ # Use execution runner for execution agents
238
+ runner = ExecutionRunner(config)
239
+ result = runner.run(full_task, output_format="json")
240
+ else:
241
+ # For interactive agents, create a new instance
242
+ from supyagent.core.agent import Agent
243
+
244
+ sub_agent = Agent(
245
+ config,
246
+ registry=self.registry,
247
+ parent_instance_id=self.parent_id,
248
+ )
249
+
250
+ response = sub_agent.send_message(full_task)
251
+ result = {"ok": True, "data": response}
252
+
253
+ # Mark as completed
254
+ if sub_agent.instance_id:
255
+ self.registry.mark_completed(sub_agent.instance_id)
256
+
257
+ return result
258
+
259
+ except Exception as e:
260
+ return {"ok": False, "error": f"Delegation failed: {str(e)}"}
261
+
262
+ def _spawn_agent(
263
+ self,
264
+ agent_type: str,
265
+ task: str,
266
+ ) -> dict[str, Any]:
267
+ """
268
+ Spawn a new agent instance.
269
+
270
+ Args:
271
+ agent_type: Type of agent to spawn
272
+ task: Initial task for the agent
273
+
274
+ Returns:
275
+ Result dict
276
+ """
277
+ if agent_type not in self.parent.config.delegates:
278
+ return {
279
+ "ok": False,
280
+ "error": (
281
+ f"Cannot spawn '{agent_type}' - not in delegates list. "
282
+ f"Available: {', '.join(self.parent.config.delegates)}"
283
+ ),
284
+ }
285
+
286
+ return self._delegate_task(agent_type, task)