webagents 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.
- webagents/__init__.py +18 -0
- webagents/__main__.py +55 -0
- webagents/agents/__init__.py +13 -0
- webagents/agents/core/__init__.py +19 -0
- webagents/agents/core/base_agent.py +1834 -0
- webagents/agents/core/handoffs.py +293 -0
- webagents/agents/handoffs/__init__.py +0 -0
- webagents/agents/interfaces/__init__.py +0 -0
- webagents/agents/lifecycle/__init__.py +0 -0
- webagents/agents/skills/__init__.py +109 -0
- webagents/agents/skills/base.py +136 -0
- webagents/agents/skills/core/__init__.py +8 -0
- webagents/agents/skills/core/guardrails/__init__.py +0 -0
- webagents/agents/skills/core/llm/__init__.py +0 -0
- webagents/agents/skills/core/llm/anthropic/__init__.py +1 -0
- webagents/agents/skills/core/llm/litellm/__init__.py +10 -0
- webagents/agents/skills/core/llm/litellm/skill.py +538 -0
- webagents/agents/skills/core/llm/openai/__init__.py +1 -0
- webagents/agents/skills/core/llm/xai/__init__.py +1 -0
- webagents/agents/skills/core/mcp/README.md +375 -0
- webagents/agents/skills/core/mcp/__init__.py +15 -0
- webagents/agents/skills/core/mcp/skill.py +731 -0
- webagents/agents/skills/core/memory/__init__.py +11 -0
- webagents/agents/skills/core/memory/long_term_memory/__init__.py +10 -0
- webagents/agents/skills/core/memory/long_term_memory/memory_skill.py +639 -0
- webagents/agents/skills/core/memory/short_term_memory/__init__.py +9 -0
- webagents/agents/skills/core/memory/short_term_memory/skill.py +341 -0
- webagents/agents/skills/core/memory/vector_memory/skill.py +447 -0
- webagents/agents/skills/core/planning/__init__.py +9 -0
- webagents/agents/skills/core/planning/planner.py +343 -0
- webagents/agents/skills/ecosystem/__init__.py +0 -0
- webagents/agents/skills/ecosystem/crewai/__init__.py +1 -0
- webagents/agents/skills/ecosystem/database/__init__.py +1 -0
- webagents/agents/skills/ecosystem/filesystem/__init__.py +0 -0
- webagents/agents/skills/ecosystem/google/__init__.py +0 -0
- webagents/agents/skills/ecosystem/google/calendar/__init__.py +6 -0
- webagents/agents/skills/ecosystem/google/calendar/skill.py +306 -0
- webagents/agents/skills/ecosystem/n8n/__init__.py +0 -0
- webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
- webagents/agents/skills/ecosystem/web/__init__.py +0 -0
- webagents/agents/skills/ecosystem/zapier/__init__.py +0 -0
- webagents/agents/skills/robutler/__init__.py +11 -0
- webagents/agents/skills/robutler/auth/README.md +63 -0
- webagents/agents/skills/robutler/auth/__init__.py +17 -0
- webagents/agents/skills/robutler/auth/skill.py +354 -0
- webagents/agents/skills/robutler/crm/__init__.py +18 -0
- webagents/agents/skills/robutler/crm/skill.py +368 -0
- webagents/agents/skills/robutler/discovery/README.md +281 -0
- webagents/agents/skills/robutler/discovery/__init__.py +16 -0
- webagents/agents/skills/robutler/discovery/skill.py +230 -0
- webagents/agents/skills/robutler/kv/__init__.py +6 -0
- webagents/agents/skills/robutler/kv/skill.py +80 -0
- webagents/agents/skills/robutler/message_history/__init__.py +9 -0
- webagents/agents/skills/robutler/message_history/skill.py +270 -0
- webagents/agents/skills/robutler/messages/__init__.py +0 -0
- webagents/agents/skills/robutler/nli/__init__.py +13 -0
- webagents/agents/skills/robutler/nli/skill.py +687 -0
- webagents/agents/skills/robutler/notifications/__init__.py +5 -0
- webagents/agents/skills/robutler/notifications/skill.py +141 -0
- webagents/agents/skills/robutler/payments/__init__.py +41 -0
- webagents/agents/skills/robutler/payments/exceptions.py +255 -0
- webagents/agents/skills/robutler/payments/skill.py +610 -0
- webagents/agents/skills/robutler/storage/__init__.py +10 -0
- webagents/agents/skills/robutler/storage/files/__init__.py +9 -0
- webagents/agents/skills/robutler/storage/files/skill.py +445 -0
- webagents/agents/skills/robutler/storage/json/__init__.py +9 -0
- webagents/agents/skills/robutler/storage/json/skill.py +336 -0
- webagents/agents/skills/robutler/storage/kv/skill.py +88 -0
- webagents/agents/skills/robutler/storage.py +389 -0
- webagents/agents/tools/__init__.py +0 -0
- webagents/agents/tools/decorators.py +426 -0
- webagents/agents/tracing/__init__.py +0 -0
- webagents/agents/workflows/__init__.py +0 -0
- webagents/scripts/__init__.py +0 -0
- webagents/server/__init__.py +28 -0
- webagents/server/context/__init__.py +0 -0
- webagents/server/context/context_vars.py +121 -0
- webagents/server/core/__init__.py +0 -0
- webagents/server/core/app.py +843 -0
- webagents/server/core/middleware.py +69 -0
- webagents/server/core/models.py +98 -0
- webagents/server/core/monitoring.py +59 -0
- webagents/server/endpoints/__init__.py +0 -0
- webagents/server/interfaces/__init__.py +0 -0
- webagents/server/middleware.py +330 -0
- webagents/server/models.py +92 -0
- webagents/server/monitoring.py +659 -0
- webagents/utils/__init__.py +0 -0
- webagents/utils/logging.py +359 -0
- webagents-0.1.0.dist-info/METADATA +230 -0
- webagents-0.1.0.dist-info/RECORD +94 -0
- webagents-0.1.0.dist-info/WHEEL +4 -0
- webagents-0.1.0.dist-info/entry_points.txt +2 -0
- webagents-0.1.0.dist-info/licenses/LICENSE +20 -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
|
File without changes
|
@@ -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('&','&').replace('<','<').replace('>','>').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>WebAgents – 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
|
File without changes
|