webagents 0.1.12__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.
Files changed (96) hide show
  1. webagents/__init__.py +18 -0
  2. webagents/agents/__init__.py +13 -0
  3. webagents/agents/core/__init__.py +19 -0
  4. webagents/agents/core/base_agent.py +1834 -0
  5. webagents/agents/core/handoffs.py +293 -0
  6. webagents/agents/handoffs/__init__.py +0 -0
  7. webagents/agents/interfaces/__init__.py +0 -0
  8. webagents/agents/lifecycle/__init__.py +0 -0
  9. webagents/agents/skills/__init__.py +109 -0
  10. webagents/agents/skills/base.py +136 -0
  11. webagents/agents/skills/core/__init__.py +8 -0
  12. webagents/agents/skills/core/guardrails/__init__.py +0 -0
  13. webagents/agents/skills/core/llm/__init__.py +0 -0
  14. webagents/agents/skills/core/llm/anthropic/__init__.py +1 -0
  15. webagents/agents/skills/core/llm/litellm/__init__.py +10 -0
  16. webagents/agents/skills/core/llm/litellm/skill.py +538 -0
  17. webagents/agents/skills/core/llm/openai/__init__.py +1 -0
  18. webagents/agents/skills/core/llm/xai/__init__.py +1 -0
  19. webagents/agents/skills/core/mcp/README.md +375 -0
  20. webagents/agents/skills/core/mcp/__init__.py +15 -0
  21. webagents/agents/skills/core/mcp/skill.py +731 -0
  22. webagents/agents/skills/core/memory/__init__.py +11 -0
  23. webagents/agents/skills/core/memory/long_term_memory/__init__.py +10 -0
  24. webagents/agents/skills/core/memory/long_term_memory/memory_skill.py +639 -0
  25. webagents/agents/skills/core/memory/short_term_memory/__init__.py +9 -0
  26. webagents/agents/skills/core/memory/short_term_memory/skill.py +341 -0
  27. webagents/agents/skills/core/memory/vector_memory/skill.py +447 -0
  28. webagents/agents/skills/core/planning/__init__.py +9 -0
  29. webagents/agents/skills/core/planning/planner.py +343 -0
  30. webagents/agents/skills/ecosystem/__init__.py +0 -0
  31. webagents/agents/skills/ecosystem/crewai/__init__.py +1 -0
  32. webagents/agents/skills/ecosystem/database/__init__.py +1 -0
  33. webagents/agents/skills/ecosystem/filesystem/__init__.py +0 -0
  34. webagents/agents/skills/ecosystem/google/__init__.py +0 -0
  35. webagents/agents/skills/ecosystem/google/calendar/__init__.py +6 -0
  36. webagents/agents/skills/ecosystem/google/calendar/skill.py +306 -0
  37. webagents/agents/skills/ecosystem/n8n/__init__.py +0 -0
  38. webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
  39. webagents/agents/skills/ecosystem/web/__init__.py +0 -0
  40. webagents/agents/skills/ecosystem/zapier/__init__.py +0 -0
  41. webagents/agents/skills/robutler/__init__.py +11 -0
  42. webagents/agents/skills/robutler/auth/README.md +63 -0
  43. webagents/agents/skills/robutler/auth/__init__.py +17 -0
  44. webagents/agents/skills/robutler/auth/skill.py +354 -0
  45. webagents/agents/skills/robutler/crm/__init__.py +18 -0
  46. webagents/agents/skills/robutler/crm/skill.py +368 -0
  47. webagents/agents/skills/robutler/discovery/README.md +281 -0
  48. webagents/agents/skills/robutler/discovery/__init__.py +16 -0
  49. webagents/agents/skills/robutler/discovery/skill.py +230 -0
  50. webagents/agents/skills/robutler/kv/__init__.py +6 -0
  51. webagents/agents/skills/robutler/kv/skill.py +80 -0
  52. webagents/agents/skills/robutler/message_history/__init__.py +9 -0
  53. webagents/agents/skills/robutler/message_history/skill.py +270 -0
  54. webagents/agents/skills/robutler/messages/__init__.py +0 -0
  55. webagents/agents/skills/robutler/nli/__init__.py +13 -0
  56. webagents/agents/skills/robutler/nli/skill.py +687 -0
  57. webagents/agents/skills/robutler/notifications/__init__.py +5 -0
  58. webagents/agents/skills/robutler/notifications/skill.py +141 -0
  59. webagents/agents/skills/robutler/payments/__init__.py +41 -0
  60. webagents/agents/skills/robutler/payments/exceptions.py +255 -0
  61. webagents/agents/skills/robutler/payments/skill.py +610 -0
  62. webagents/agents/skills/robutler/storage/__init__.py +10 -0
  63. webagents/agents/skills/robutler/storage/files/__init__.py +9 -0
  64. webagents/agents/skills/robutler/storage/files/skill.py +445 -0
  65. webagents/agents/skills/robutler/storage/json/__init__.py +9 -0
  66. webagents/agents/skills/robutler/storage/json/skill.py +336 -0
  67. webagents/agents/skills/robutler/storage/kv/skill.py +88 -0
  68. webagents/agents/skills/robutler/storage.py +389 -0
  69. webagents/agents/tools/__init__.py +0 -0
  70. webagents/agents/tools/decorators.py +426 -0
  71. webagents/agents/tracing/__init__.py +0 -0
  72. webagents/agents/workflows/__init__.py +0 -0
  73. webagents/api/__init__.py +17 -0
  74. webagents/api/client.py +1207 -0
  75. webagents/api/types.py +253 -0
  76. webagents/scripts/__init__.py +0 -0
  77. webagents/server/__init__.py +28 -0
  78. webagents/server/context/__init__.py +0 -0
  79. webagents/server/context/context_vars.py +121 -0
  80. webagents/server/core/__init__.py +0 -0
  81. webagents/server/core/app.py +843 -0
  82. webagents/server/core/middleware.py +69 -0
  83. webagents/server/core/models.py +98 -0
  84. webagents/server/core/monitoring.py +59 -0
  85. webagents/server/endpoints/__init__.py +0 -0
  86. webagents/server/interfaces/__init__.py +0 -0
  87. webagents/server/middleware.py +330 -0
  88. webagents/server/models.py +92 -0
  89. webagents/server/monitoring.py +659 -0
  90. webagents/utils/__init__.py +0 -0
  91. webagents/utils/logging.py +359 -0
  92. webagents-0.1.12.dist-info/METADATA +99 -0
  93. webagents-0.1.12.dist-info/RECORD +96 -0
  94. webagents-0.1.12.dist-info/WHEEL +4 -0
  95. webagents-0.1.12.dist-info/entry_points.txt +2 -0
  96. webagents-0.1.12.dist-info/licenses/LICENSE +1 -0
@@ -0,0 +1,343 @@
1
+ """
2
+ PlannerSkill - Simple Task Planning and Todo Management
3
+
4
+ A straightforward planner that matches the TypeScript implementation.
5
+ Maintains a single plan state across multiple tool calls for consistent UX.
6
+ """
7
+
8
+ import json
9
+ import uuid
10
+ from typing import Dict, List, Any, Optional
11
+ from datetime import datetime
12
+ from dataclasses import dataclass, asdict
13
+
14
+ from ...base import Skill
15
+ from ....tools.decorators import tool
16
+
17
+
18
+ @dataclass
19
+ class PlannerTask:
20
+ """Represents a single task in a plan"""
21
+ id: str
22
+ title: str
23
+ description: Optional[str] = None
24
+ status: str = "pending" # pending, in_progress, completed, cancelled
25
+ created_at: str = None
26
+ updated_at: str = None
27
+
28
+ def __post_init__(self):
29
+ if self.created_at is None:
30
+ self.created_at = datetime.utcnow().isoformat()
31
+ if self.updated_at is None:
32
+ self.updated_at = self.created_at
33
+
34
+
35
+ @dataclass
36
+ class PlannerState:
37
+ """Represents the complete planner state"""
38
+ plan_id: str
39
+ title: str
40
+ description: Optional[str] = None
41
+ tasks: List[PlannerTask] = None
42
+ created_at: str = None
43
+ updated_at: str = None
44
+ status: str = "active" # active, completed, cancelled
45
+
46
+ def __post_init__(self):
47
+ if self.tasks is None:
48
+ self.tasks = []
49
+ if self.created_at is None:
50
+ self.created_at = datetime.utcnow().isoformat()
51
+ if self.updated_at is None:
52
+ self.updated_at = self.created_at
53
+
54
+
55
+ class PlannerSkill(Skill):
56
+ """
57
+ Simple task planning and todo management skill that matches the TypeScript implementation.
58
+
59
+ Features:
60
+ - Single plan state management
61
+ - Task status tracking (pending, in_progress, completed, cancelled)
62
+ - State persistence across tool calls
63
+ - Matches TypeScript planner_tool behavior
64
+ """
65
+
66
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
67
+ super().__init__(config)
68
+
69
+ async def initialize(self, agent_reference):
70
+ """Initialize with agent reference"""
71
+ await super().initialize(agent_reference)
72
+ self.agent = agent_reference
73
+
74
+ def _generate_id(self) -> str:
75
+ """Generate a random ID"""
76
+ return str(uuid.uuid4())[:8]
77
+
78
+ def _get_current_timestamp(self) -> str:
79
+ """Get current timestamp"""
80
+ return datetime.utcnow().isoformat()
81
+
82
+ def _get_action_message(self, action: str, title: str = None, total_tasks: int = 0, completed_tasks: int = 0) -> str:
83
+ """Generate action message"""
84
+ title_part = f' "{title}"' if title else ''
85
+
86
+ if action == 'create_plan':
87
+ return f"Created new plan{title_part} with {total_tasks} tasks. Start by marking the first task as 'in_progress'."
88
+ elif action == 'add_task':
89
+ return f"Added task{title_part} to the plan"
90
+ elif action == 'update_task':
91
+ return f"Started task{title_part}. Remember to mark this task as 'completed' when you finish explaining/doing this step."
92
+ elif action == 'complete_task':
93
+ next_task_part = '. Move to the next task.' if completed_tasks < total_tasks else ''
94
+ return f"✅ Completed task{title_part}. Progress: {completed_tasks}/{total_tasks}{next_task_part}"
95
+ elif action == 'cancel_task':
96
+ return f"Cancelled task{title_part}"
97
+ elif action == 'update_plan':
98
+ return f"Updated plan{title_part}"
99
+ else:
100
+ return 'Plan updated'
101
+
102
+ @tool(description="Create and manage a single task plan to break down complex work into manageable steps. Use this tool to organize your approach and track progress. CRITICAL: Always pass the complete 'state' object from previous tool results as 'current_state' to maintain the same plan across multiple calls.", scope="all")
103
+ async def planner_tool(
104
+ self,
105
+ action: str, # create_plan, add_task, update_task, complete_task, cancel_task, update_plan
106
+ title: Optional[str] = None,
107
+ description: Optional[str] = None,
108
+ task_id: Optional[str] = None,
109
+ tasks: Optional[List[Dict[str, str]]] = None, # [{"title": "...", "description": "..."}]
110
+ status: Optional[str] = None, # pending, in_progress, completed, cancelled
111
+ current_state: Optional[Dict[str, Any]] = None,
112
+ context=None
113
+ ) -> Dict[str, Any]:
114
+ """
115
+ Create and manage task plans to break down complex work into manageable steps.
116
+
117
+ Actions:
118
+ - create_plan: Create a new plan with initial tasks
119
+ - add_task: Add a task to existing plan
120
+ - update_task: Update task status
121
+ - complete_task: Mark task as completed
122
+ - cancel_task: Mark task as cancelled
123
+ - update_plan: Update plan details
124
+
125
+ Args:
126
+ action: The action to perform
127
+ title: Title for plan or task
128
+ description: Description for plan or task
129
+ task_id: ID of task to update (required for task operations)
130
+ tasks: List of initial tasks when creating plan
131
+ status: New status for task updates
132
+ current_state: CRITICAL - Complete state object from previous calls
133
+ """
134
+ try:
135
+ timestamp = self._get_current_timestamp()
136
+
137
+ # Handle state management
138
+ if action == 'create_plan':
139
+ # Create new plan
140
+ state = PlannerState(
141
+ plan_id=self._generate_id(),
142
+ title=title or 'New Plan',
143
+ description=description or '',
144
+ created_at=timestamp,
145
+ updated_at=timestamp,
146
+ status='active'
147
+ )
148
+
149
+ # Add initial tasks
150
+ if tasks:
151
+ for task_data in tasks:
152
+ task = PlannerTask(
153
+ id=self._generate_id(),
154
+ title=task_data.get('title', ''),
155
+ description=task_data.get('description'),
156
+ status='pending',
157
+ created_at=timestamp,
158
+ updated_at=timestamp
159
+ )
160
+ state.tasks.append(task)
161
+ else:
162
+ # For all other actions, require current_state
163
+ if not current_state:
164
+ return {
165
+ 'success': False,
166
+ 'action': action,
167
+ 'error': f"Action '{action}' requires current_state from previous planner_tool result. Please pass the complete 'state' object from the previous call.",
168
+ 'state': None
169
+ }
170
+
171
+ # Reconstruct state from current_state
172
+ state = PlannerState(
173
+ plan_id=current_state['plan_id'],
174
+ title=current_state['title'],
175
+ description=current_state.get('description'),
176
+ status=current_state['status'],
177
+ created_at=current_state['created_at'],
178
+ updated_at=current_state['updated_at']
179
+ )
180
+
181
+ # Reconstruct tasks
182
+ state.tasks = []
183
+ for task_data in current_state.get('tasks', []):
184
+ task = PlannerTask(
185
+ id=task_data['id'],
186
+ title=task_data['title'],
187
+ description=task_data.get('description'),
188
+ status=task_data['status'],
189
+ created_at=task_data['created_at'],
190
+ updated_at=task_data['updated_at']
191
+ )
192
+ state.tasks.append(task)
193
+
194
+ # Execute action
195
+ if action == 'create_plan':
196
+ # Already handled above
197
+ pass
198
+
199
+ elif action == 'add_task':
200
+ if not title:
201
+ raise Exception('Task title is required')
202
+
203
+ new_task = PlannerTask(
204
+ id=self._generate_id(),
205
+ title=title,
206
+ description=description,
207
+ status='pending',
208
+ created_at=timestamp,
209
+ updated_at=timestamp
210
+ )
211
+ state.tasks.append(new_task)
212
+ state.updated_at = timestamp
213
+
214
+ elif action == 'update_task':
215
+ if not task_id:
216
+ raise Exception('Task ID is required for task updates')
217
+
218
+ task = next((t for t in state.tasks if t.id == task_id), None)
219
+ if not task:
220
+ raise Exception(f'Task with ID {task_id} not found')
221
+
222
+ if title:
223
+ task.title = title
224
+ if description is not None:
225
+ task.description = description
226
+ if status:
227
+ task.status = status
228
+ task.updated_at = timestamp
229
+ state.updated_at = timestamp
230
+
231
+ elif action == 'complete_task':
232
+ if not task_id:
233
+ raise Exception('Task ID is required to complete a task')
234
+
235
+ task = next((t for t in state.tasks if t.id == task_id), None)
236
+ if not task:
237
+ raise Exception(f'Task with ID {task_id} not found')
238
+
239
+ task.status = 'completed'
240
+ task.updated_at = timestamp
241
+ state.updated_at = timestamp
242
+
243
+ # Check if all tasks are completed
244
+ all_completed = all(t.status in ['completed', 'cancelled'] for t in state.tasks)
245
+ if all_completed and state.tasks:
246
+ state.status = 'completed'
247
+
248
+ elif action == 'cancel_task':
249
+ if not task_id:
250
+ raise Exception('Task ID is required to cancel a task')
251
+
252
+ task = next((t for t in state.tasks if t.id == task_id), None)
253
+ if not task:
254
+ raise Exception(f'Task with ID {task_id} not found')
255
+
256
+ task.status = 'cancelled'
257
+ task.updated_at = timestamp
258
+ state.updated_at = timestamp
259
+
260
+ elif action == 'update_plan':
261
+ if title:
262
+ state.title = title
263
+ if description is not None:
264
+ state.description = description
265
+ if status and status in ['active', 'completed', 'cancelled']:
266
+ state.status = status
267
+ state.updated_at = timestamp
268
+
269
+ else:
270
+ raise Exception(f'Unknown action: {action}')
271
+
272
+ # Calculate summary stats
273
+ total_tasks = len(state.tasks)
274
+ completed_tasks = len([t for t in state.tasks if t.status == 'completed'])
275
+ in_progress_tasks = len([t for t in state.tasks if t.status == 'in_progress'])
276
+ pending_tasks = len([t for t in state.tasks if t.status == 'pending'])
277
+ cancelled_tasks = len([t for t in state.tasks if t.status == 'cancelled'])
278
+
279
+ # Convert state to dict for JSON serialization
280
+ state_dict = {
281
+ 'plan_id': state.plan_id,
282
+ 'title': state.title,
283
+ 'description': state.description,
284
+ 'tasks': [
285
+ {
286
+ 'id': task.id,
287
+ 'title': task.title,
288
+ 'description': task.description,
289
+ 'status': task.status,
290
+ 'created_at': task.created_at,
291
+ 'updated_at': task.updated_at
292
+ }
293
+ for task in state.tasks
294
+ ],
295
+ 'created_at': state.created_at,
296
+ 'updated_at': state.updated_at,
297
+ 'status': state.status
298
+ }
299
+
300
+ return {
301
+ 'success': True,
302
+ 'action': action,
303
+ 'state': state_dict,
304
+ 'summary': {
305
+ 'total_tasks': total_tasks,
306
+ 'completed_tasks': completed_tasks,
307
+ 'in_progress_tasks': in_progress_tasks,
308
+ 'pending_tasks': pending_tasks,
309
+ 'cancelled_tasks': cancelled_tasks,
310
+ 'progress_percentage': round((completed_tasks / total_tasks) * 100) if total_tasks > 0 else 0
311
+ },
312
+ 'message': self._get_action_message(action, title, total_tasks, completed_tasks),
313
+ '_reminder': "IMPORTANT: Use this 'state' object as 'current_state' parameter in your next planner_tool call to maintain the same plan!"
314
+ }
315
+
316
+ except Exception as e:
317
+ return {
318
+ 'success': False,
319
+ 'action': action,
320
+ 'error': str(e),
321
+ 'state': current_state
322
+ }
323
+
324
+ def get_dependencies(self) -> List[str]:
325
+ """Return list of dependencies"""
326
+ return []
327
+
328
+ def get_skill_info(self) -> Dict[str, Any]:
329
+ """Get comprehensive skill information"""
330
+ return {
331
+ "name": "PlannerSkill",
332
+ "description": "Simple task planning and todo management that matches TypeScript implementation",
333
+ "version": "3.0.0",
334
+ "capabilities": [
335
+ "Single plan state management",
336
+ "Task status tracking",
337
+ "State persistence across calls",
338
+ "TypeScript compatibility"
339
+ ],
340
+ "tools": [
341
+ "planner_tool"
342
+ ]
343
+ }
File without changes
@@ -0,0 +1 @@
1
+ # TODO: Implement CrewAI skill
@@ -0,0 +1 @@
1
+ # TODO: Implement Database skill
File without changes
@@ -0,0 +1,6 @@
1
+ from .skill import GoogleCalendarSkill
2
+
3
+ __all__ = ["GoogleCalendarSkill"]
4
+
5
+
6
+
@@ -0,0 +1,306 @@
1
+ import os
2
+ import json
3
+ import urllib.parse
4
+ from typing import Any, Dict, Optional, List
5
+ from datetime import datetime, timezone
6
+
7
+ import httpx
8
+
9
+ from webagents.agents.skills.base import Skill
10
+ from webagents.agents.tools.decorators import tool, prompt, http, hook
11
+ from webagents.server.context.context_vars import get_context
12
+ from webagents.utils.logging import get_logger, log_skill_event, log_tool_execution
13
+
14
+
15
+ class GoogleCalendarSkill(Skill):
16
+ """Google Calendar integration for the dedicated agent r-google-calendar.
17
+
18
+ Flow:
19
+ - Other agents call tools on r-google-calendar and present an ownership assertion (X-Owner-Assertion).
20
+ - On first use per user, this skill returns an AuthSub-style consent URL for the user to authorize access.
21
+ - After consent, Google redirects to our callback endpoint; we exchange code for a token and persist it.
22
+ - Tokens are stored via the JSON storage skill as simple key-value entries for now.
23
+ - Subsequent calendar operations use the stored token.
24
+ """
25
+
26
+ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
27
+ super().__init__(config or {}, scope="all")
28
+ self.logger = None
29
+ self.oauth_client_id = os.getenv("GOOGLE_CLIENT_ID", "")
30
+ self.oauth_client_secret = os.getenv("GOOGLE_CLIENT_SECRET", "")
31
+ self.oauth_redirect_path = "/oauth/google/calendar/callback"
32
+ self.oauth_scopes = [
33
+ "https://www.googleapis.com/auth/calendar.readonly",
34
+ "https://www.googleapis.com/auth/calendar.events.readonly",
35
+ ]
36
+ # Base URL where this agent is exposed (needed to form redirect URI)
37
+ # Single source of truth: AGENTS_BASE_URL. It may be provided with or without trailing /agents.
38
+ # We normalize to always include exactly one /agents segment.
39
+ env_agents = os.getenv("AGENTS_BASE_URL")
40
+ base_root = (env_agents or "http://localhost:2224").rstrip('/')
41
+ if base_root.endswith("/agents"):
42
+ self.agent_base_url = base_root
43
+ else:
44
+ self.agent_base_url = base_root + "/agents"
45
+
46
+ async def initialize(self, agent) -> None:
47
+ self.agent = agent
48
+ self.logger = get_logger('skill.google.calendar', agent.name)
49
+ log_skill_event(agent.name, 'google_calendar', 'initialized', {})
50
+
51
+ # ---------------- Helpers ----------------
52
+ def _redirect_uri(self) -> str:
53
+ # e.g., http://localhost:8000/agents/{agent-name}/oauth/google/calendar/callback
54
+ base = self.agent_base_url.rstrip('/')
55
+ return f"{base}/{self.agent.name}{self.oauth_redirect_path}"
56
+
57
+ async def _get_kv_skill(self):
58
+ # Prefer dedicated KV skill if present; fallback to json_storage
59
+ return self.agent.skills.get("kv") or self.agent.skills.get("json_storage")
60
+
61
+ def _token_filename(self, user_id: str) -> str:
62
+ return f"gcal_tokens_{user_id}.json"
63
+
64
+ async def _save_user_tokens(self, user_id: str, tokens: Dict[str, Any]) -> None:
65
+ kv_skill = await self._get_kv_skill()
66
+ if kv_skill and hasattr(kv_skill, 'kv_set'):
67
+ try:
68
+ await kv_skill.kv_set(key=self._token_filename(user_id), value=json.dumps(tokens), namespace="auth")
69
+ return
70
+ except Exception:
71
+ pass
72
+ # Fallback: in-memory
73
+ setattr(self.agent, '_gcal_tokens', getattr(self.agent, '_gcal_tokens', {}))
74
+ self.agent._gcal_tokens[user_id] = tokens
75
+
76
+ async def _load_user_tokens(self, user_id: str) -> Optional[Dict[str, Any]]:
77
+ kv_skill = await self._get_kv_skill()
78
+ if kv_skill and hasattr(kv_skill, 'kv_get'):
79
+ try:
80
+ stored = await kv_skill.kv_get(key=self._token_filename(user_id), namespace="auth")
81
+ if isinstance(stored, str) and stored.startswith('{'):
82
+ return json.loads(stored)
83
+ except Exception:
84
+ pass
85
+ mem = getattr(self.agent, '_gcal_tokens', {})
86
+ return mem.get(user_id)
87
+
88
+ def _build_auth_url(self, user_id: str) -> str:
89
+ q = {
90
+ "client_id": self.oauth_client_id,
91
+ "response_type": "code",
92
+ "redirect_uri": self._redirect_uri(),
93
+ "access_type": "offline",
94
+ "prompt": "consent",
95
+ "scope": " ".join(self.oauth_scopes),
96
+ # State can carry user id for correlation
97
+ "state": user_id,
98
+ }
99
+ return f"https://accounts.google.com/o/oauth2/v2/auth?{urllib.parse.urlencode(q)}"
100
+
101
+ async def _exchange_code_for_tokens(self, code: str) -> Dict[str, Any]:
102
+ token_url = "https://oauth2.googleapis.com/token"
103
+ payload = {
104
+ "client_id": self.oauth_client_id,
105
+ "client_secret": self.oauth_client_secret,
106
+ "code": code,
107
+ "grant_type": "authorization_code",
108
+ "redirect_uri": self._redirect_uri(),
109
+ }
110
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
111
+ async with httpx.AsyncClient(timeout=20) as client:
112
+ try:
113
+ resp = await client.post(token_url, data=payload, headers=headers)
114
+ resp.raise_for_status()
115
+ return resp.json()
116
+ except httpx.HTTPStatusError as e:
117
+ # Surface detailed Google error payload when available
118
+ detail = None
119
+ try:
120
+ detail = e.response.json()
121
+ except Exception:
122
+ try:
123
+ detail = {"text": e.response.text}
124
+ except Exception:
125
+ detail = {"error": str(e)}
126
+ raise Exception(f"Google token exchange failed: status={e.response.status_code}, redirect_uri={payload['redirect_uri']}, detail={detail}")
127
+
128
+ async def _refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
129
+ token_url = "https://oauth2.googleapis.com/token"
130
+ payload = {
131
+ "client_id": self.oauth_client_id,
132
+ "client_secret": self.oauth_client_secret,
133
+ "grant_type": "refresh_token",
134
+ "refresh_token": refresh_token,
135
+ }
136
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
137
+ async with httpx.AsyncClient(timeout=20) as client:
138
+ resp = await client.post(token_url, data=payload, headers=headers)
139
+ resp.raise_for_status()
140
+ return resp.json()
141
+
142
+ async def _get_user_id_from_context(self) -> Optional[str]:
143
+ try:
144
+ ctx = get_context()
145
+ if not ctx:
146
+ return None
147
+ auth = getattr(ctx, 'auth', None) or (ctx and ctx.get('auth'))
148
+ return getattr(auth, 'user_id', None)
149
+ except Exception:
150
+ return None
151
+
152
+ # ---------------- Prompts ----------------
153
+ @prompt(priority=40, scope=["owner", "all"]) # Visible to all for discoverability
154
+ def calendar_prompt(self) -> str:
155
+ return (
156
+ "Google Calendar skill is available. If calendar access is needed, call init_calendar_auth() to obtain an authorization link. "
157
+ "After the user authorizes, use list_events() to read calendar events."
158
+ )
159
+
160
+ # ---------------- HTTP callback handler ----------------
161
+ # Public callback: rely on state param and context-derived user id; no owner scope required
162
+ @http(subpath="/oauth/google/calendar/callback", method="get", scope=["all"]) # Accept from any caller; identity via state/context
163
+ async def oauth_callback(self, code: str = None, state: str = None) -> Dict[str, Any]:
164
+ from fastapi.responses import HTMLResponse
165
+ # Minimal HTML confirmation UI with brand-consistent styling (use Template to avoid brace issues)
166
+ def html(success: bool, message: str) -> str:
167
+ from string import Template
168
+ color_ok = "#16a34a" # green-600
169
+ color_err = "#dc2626" # red-600
170
+ accent = color_ok if success else color_err
171
+ title = "Calendar connected" if success else "Calendar connection failed"
172
+ # Basic HTML escape and escape $ for Template
173
+ safe_msg = (message or '')
174
+ safe_msg = safe_msg.replace('&','&amp;').replace('<','&lt;').replace('>','&gt;').replace('$','$$')
175
+ template = Template(
176
+ """<!doctype html>
177
+ <html lang=\"en\">
178
+ <head>
179
+ <meta charset=\"utf-8\" />
180
+ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
181
+ <title>Robutler – Google Calendar</title>
182
+ <style>
183
+ :root { color-scheme: light dark; }
184
+ html, body { height: 100%; margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
185
+ body { background: var(--bg, #0b0b0c); color: var(--fg, #e5e7eb); display: grid; place-items: center; }
186
+ @media (prefers-color-scheme: light) { body { --bg: #f7f7f8; --card: #ffffff; --border: #e5e7eb; --fg: #0f172a; } }
187
+ @media (prefers-color-scheme: dark) { body { --bg: #0b0b0c; --card: #111214; --border: #23252a; --fg: #e5e7eb; } }
188
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px 20px; width: min(92vw, 420px); box-shadow: 0 6px 24px rgba(0,0,0,.12); text-align: center; }
189
+ .icon { color: $accent; display:inline-flex; align-items:center; justify-content:center; margin-bottom: 12px; }
190
+ h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; color: var(--fg); }
191
+ p { margin: 0 0 16px; font-size: 13px; opacity: .78; line-height: 1.4; }
192
+ button { appearance: none; border: 1px solid var(--border); background: transparent; color: var(--fg); border-radius: 8px; padding: 8px 14px; font-size: 13px; cursor: pointer; }
193
+ button:hover { background: rgba(127,127,127,.12); }
194
+ .calendar { width: 44px; height: 44px; }
195
+ </style>
196
+ </head>
197
+ <body>
198
+ <div class=\"card\">
199
+ <div class=\"icon\">
200
+ <svg class=\"calendar\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">
201
+ <rect x=\"3\" y=\"4\" width=\"18\" height=\"17\" rx=\"3\" ry=\"3\" stroke=\"currentColor\" stroke-width=\"1.5\"/>
202
+ <path d=\"M3 8H21\" stroke=\"currentColor\" stroke-width=\"1.5\"/>
203
+ <rect x=\"7\" y=\"11\" width=\"4\" height=\"4\" fill=\"currentColor\" />
204
+ </svg>
205
+ </div>
206
+ <h1>$title</h1>
207
+ <p>$safe_msg</p>
208
+ <button id=\"ok\">OK</button>
209
+ </div>
210
+ <script>
211
+ (function(){
212
+ var payload = { type: 'google-calendar-connected', success: $postSuccess };
213
+ try {
214
+ if (window.opener && !window.opener.closed) { window.opener.postMessage(payload, '*'); }
215
+ else if (window.parent && window.parent !== window) { window.parent.postMessage(payload, '*'); }
216
+ } catch(e){}
217
+ var ok = document.getElementById('ok');
218
+ if (ok) ok.addEventListener('click', function(){
219
+ try { window.close(); } catch(e) {}
220
+ setTimeout(function(){ location.replace('about:blank'); }, 150);
221
+ });
222
+ })();
223
+ </script>
224
+ </body>
225
+ </html>
226
+ """
227
+ )
228
+ return template.safe_substitute(
229
+ title=title,
230
+ accent=accent,
231
+ safe_msg=safe_msg,
232
+ postSuccess=("true" if success else "false"),
233
+ )
234
+
235
+ if not code:
236
+ return HTMLResponse(content=html(False, "Missing authorization code. Please retry."), media_type="text/html")
237
+ if not self.oauth_client_id or not self.oauth_client_secret:
238
+ return HTMLResponse(content=html(False, "Server is missing Google OAuth credentials. Contact support."), media_type="text/html")
239
+ user_id = state or await self._get_user_id_from_context() or ""
240
+ try:
241
+ tokens = await self._exchange_code_for_tokens(code)
242
+ await self._save_user_tokens(user_id, tokens)
243
+ return HTMLResponse(content=html(True, "Your Google Calendar is now connected."), media_type="text/html")
244
+ except Exception as e:
245
+ return HTMLResponse(content=html(False, f"{str(e)}"), media_type="text/html")
246
+
247
+ # ---------------- Tools ----------------
248
+ # @tool(description="Initialize Google Calendar authorization. Returns a URL the user should open to grant access.")
249
+ # async def init_calendar_auth(self) -> str:
250
+ # user_id = await self._get_user_id_from_context()
251
+ # if not user_id:
252
+ # return "❌ Unable to resolve user identity for authorization"
253
+ # auth_url = self._build_auth_url(user_id)
254
+ # log_tool_execution(self.agent.name, 'google_calendar.init_calendar_auth', 'success', {"user_id": user_id})
255
+ # return f"Open this link to authorize access: {auth_url}"
256
+
257
+ @tool(description="Connects to Google calendar and lists upcoming calendar events.")
258
+ async def list_events(self, max_results: int = 10) -> str:
259
+ user_id = await self._get_user_id_from_context()
260
+ if not user_id:
261
+ return "❌ Missing user identity"
262
+ tokens = await self._load_user_tokens(user_id)
263
+ if not tokens or not tokens.get('access_token'):
264
+ auth_url = self._build_auth_url(user_id)
265
+ log_tool_execution(self.agent.name, 'google_calendar.init_calendar_auth', 'success', {"user_id": user_id})
266
+ return f"Open this link to authorize access: {auth_url}"
267
+ # return "❌ Not authorized. Call init_calendar_auth() first."
268
+ try:
269
+ access_token = tokens.get('access_token')
270
+ headers = {"Authorization": f"Bearer {access_token}"}
271
+ params = {
272
+ "maxResults": int(max_results),
273
+ "singleEvents": True,
274
+ "orderBy": "startTime",
275
+ "timeMin": datetime.now(timezone.utc).isoformat(),
276
+ }
277
+ async with httpx.AsyncClient(timeout=20) as client:
278
+ resp = await client.get("https://www.googleapis.com/calendar/v3/calendars/primary/events", headers=headers, params=params)
279
+ if resp.status_code == 401 and tokens.get('refresh_token'):
280
+ refreshed = await self._refresh_access_token(tokens['refresh_token'])
281
+ if 'access_token' in refreshed:
282
+ tokens['access_token'] = refreshed['access_token']
283
+ await self._save_user_tokens(user_id, tokens)
284
+ headers = {"Authorization": f"Bearer {tokens['access_token']}"}
285
+ resp = await client.get("https://www.googleapis.com/calendar/v3/calendars/primary/events", headers=headers, params=params)
286
+ if resp.status_code == 401:
287
+ return "❌ Token expired or invalid. Re-run init_calendar_auth()."
288
+ if resp.status_code == 403:
289
+ try:
290
+ return f"❌ Permission error (403). Details: {resp.json()}"
291
+ except Exception:
292
+ return f"❌ Permission error (403)."
293
+ resp.raise_for_status()
294
+ data = resp.json()
295
+ items = data.get('items', [])
296
+ if not items:
297
+ return "(no upcoming events)"
298
+ lines = []
299
+ for ev in items[: int(max_results)]:
300
+ start = (ev.get('start') or {}).get('dateTime') or (ev.get('start') or {}).get('date') or 'unknown'
301
+ lines.append(f"- {start} | {ev.get('summary', '(no title)')}")
302
+ return "\n".join(lines)
303
+ except Exception as e:
304
+ return f"❌ Failed to list events: {e}"
305
+
306
+
File without changes
File without changes