webagents 0.2.0__py3-none-any.whl → 0.2.3__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 +9 -0
- webagents/agents/core/base_agent.py +865 -69
- webagents/agents/core/handoffs.py +14 -6
- webagents/agents/skills/base.py +33 -2
- webagents/agents/skills/core/llm/litellm/skill.py +906 -27
- webagents/agents/skills/core/memory/vector_memory/skill.py +8 -16
- webagents/agents/skills/ecosystem/crewai/__init__.py +3 -1
- webagents/agents/skills/ecosystem/crewai/skill.py +158 -0
- webagents/agents/skills/ecosystem/database/__init__.py +3 -1
- webagents/agents/skills/ecosystem/database/skill.py +522 -0
- webagents/agents/skills/ecosystem/mongodb/__init__.py +3 -0
- webagents/agents/skills/ecosystem/mongodb/skill.py +428 -0
- webagents/agents/skills/ecosystem/n8n/README.md +287 -0
- webagents/agents/skills/ecosystem/n8n/__init__.py +3 -0
- webagents/agents/skills/ecosystem/n8n/skill.py +341 -0
- webagents/agents/skills/ecosystem/openai/__init__.py +6 -0
- webagents/agents/skills/ecosystem/openai/skill.py +867 -0
- webagents/agents/skills/ecosystem/replicate/README.md +440 -0
- webagents/agents/skills/ecosystem/replicate/__init__.py +10 -0
- webagents/agents/skills/ecosystem/replicate/skill.py +517 -0
- webagents/agents/skills/ecosystem/x_com/README.md +401 -0
- webagents/agents/skills/ecosystem/x_com/__init__.py +3 -0
- webagents/agents/skills/ecosystem/x_com/skill.py +1048 -0
- webagents/agents/skills/ecosystem/zapier/README.md +363 -0
- webagents/agents/skills/ecosystem/zapier/__init__.py +3 -0
- webagents/agents/skills/ecosystem/zapier/skill.py +337 -0
- webagents/agents/skills/examples/__init__.py +6 -0
- webagents/agents/skills/examples/music_player.py +329 -0
- webagents/agents/skills/robutler/handoff/__init__.py +6 -0
- webagents/agents/skills/robutler/handoff/skill.py +191 -0
- webagents/agents/skills/robutler/nli/skill.py +180 -24
- webagents/agents/skills/robutler/payments/exceptions.py +27 -7
- webagents/agents/skills/robutler/payments/skill.py +64 -14
- webagents/agents/skills/robutler/storage/files/skill.py +2 -2
- webagents/agents/tools/decorators.py +243 -47
- webagents/agents/widgets/__init__.py +6 -0
- webagents/agents/widgets/renderer.py +150 -0
- webagents/server/core/app.py +130 -15
- webagents/server/core/models.py +1 -1
- webagents/utils/logging.py +13 -1
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/METADATA +16 -9
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/RECORD +45 -24
- webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/WHEEL +0 -0
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/entry_points.txt +0 -0
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,329 @@
|
|
1
|
+
"""Example Music Player Skill demonstrating widget functionality"""
|
2
|
+
|
3
|
+
import html
|
4
|
+
import json
|
5
|
+
from typing import Optional
|
6
|
+
from webagents.agents.skills.base import Skill
|
7
|
+
from webagents.agents.tools.decorators import widget
|
8
|
+
from webagents.server.context.context_vars import Context
|
9
|
+
|
10
|
+
|
11
|
+
class MusicPlayerSkill(Skill):
|
12
|
+
"""Example skill that demonstrates interactive widget rendering
|
13
|
+
|
14
|
+
This skill shows how to create an interactive music player widget
|
15
|
+
that can communicate with the chat interface via postMessage.
|
16
|
+
"""
|
17
|
+
|
18
|
+
def __init__(self, config: Optional[dict] = None):
|
19
|
+
super().__init__(config or {})
|
20
|
+
self.name = "MusicPlayer"
|
21
|
+
|
22
|
+
async def initialize(self, agent):
|
23
|
+
"""Initialize the music player skill"""
|
24
|
+
await super().initialize(agent)
|
25
|
+
# Logger is now available after super().initialize()
|
26
|
+
if hasattr(self, 'logger'):
|
27
|
+
self.logger.info("🎵 MusicPlayerSkill initialized")
|
28
|
+
|
29
|
+
@widget(
|
30
|
+
name="play_music",
|
31
|
+
description="Display an interactive music player for a given track. Use this to play music for the user.",
|
32
|
+
scope="all"
|
33
|
+
)
|
34
|
+
async def play_music(
|
35
|
+
self,
|
36
|
+
song_url: str,
|
37
|
+
title: str,
|
38
|
+
artist: str = "Unknown Artist",
|
39
|
+
album: str = "Unknown Album",
|
40
|
+
context: Context = None
|
41
|
+
) -> str:
|
42
|
+
"""Create an interactive music player widget
|
43
|
+
|
44
|
+
Args:
|
45
|
+
song_url: URL of the audio file to play
|
46
|
+
title: Title of the song
|
47
|
+
artist: Artist name (optional)
|
48
|
+
album: Album name (optional)
|
49
|
+
context: Request context (auto-injected)
|
50
|
+
|
51
|
+
Returns:
|
52
|
+
HTML widget wrapped in <widget> tags
|
53
|
+
"""
|
54
|
+
# Get the frontend origin for absolute URLs (needed for blob iframe context)
|
55
|
+
# Note: We need the CHAT UI origin (where /api/proxy-media lives), not the agents API origin
|
56
|
+
request_origin = 'http://localhost:3001' # Default for development (chat UI port)
|
57
|
+
|
58
|
+
if context and hasattr(context, 'request') and context.request:
|
59
|
+
# Try to get the Referer or Origin header which points to the frontend
|
60
|
+
request_headers = getattr(context.request, 'headers', {})
|
61
|
+
if hasattr(request_headers, 'get'):
|
62
|
+
# Check Referer header first (shows where the request came from)
|
63
|
+
referer = request_headers.get('referer') or request_headers.get('origin')
|
64
|
+
if hasattr(self, 'logger'):
|
65
|
+
self.logger.debug(f"🌐 Request headers - referer: {referer}, origin: {request_headers.get('origin')}")
|
66
|
+
if referer:
|
67
|
+
try:
|
68
|
+
from urllib.parse import urlparse
|
69
|
+
parsed_referer = urlparse(referer)
|
70
|
+
request_origin = f"{parsed_referer.scheme}://{parsed_referer.netloc}"
|
71
|
+
if hasattr(self, 'logger'):
|
72
|
+
self.logger.debug(f"🌐 Extracted origin from referer: {request_origin}")
|
73
|
+
except Exception as e:
|
74
|
+
if hasattr(self, 'logger'):
|
75
|
+
self.logger.warning(f"⚠️ Failed to parse referer: {e}")
|
76
|
+
pass # Use default if parsing fails
|
77
|
+
|
78
|
+
# Proxy external audio URLs to bypass CORS restrictions
|
79
|
+
# Only proxy truly external URLs (not localhost or relative paths)
|
80
|
+
from urllib.parse import quote, urlparse
|
81
|
+
proxied_song_url = song_url
|
82
|
+
if song_url and (song_url.startswith('http://') or song_url.startswith('https://')):
|
83
|
+
# Check if it's not a same-origin URL (localhost or relative)
|
84
|
+
parsed = urlparse(song_url)
|
85
|
+
is_localhost = parsed.hostname in ('localhost', '127.0.0.1')
|
86
|
+
|
87
|
+
if not is_localhost:
|
88
|
+
# Use absolute URL for blob iframe context (relative URLs don't work in blob:)
|
89
|
+
proxied_song_url = f"{request_origin}/api/proxy-media?url={quote(song_url)}"
|
90
|
+
if hasattr(self, 'logger'):
|
91
|
+
self.logger.debug(f"🎵 Proxying external audio: {song_url} -> {proxied_song_url}")
|
92
|
+
|
93
|
+
# Create a modern, compact music player with custom controls
|
94
|
+
html_content = f"""<!DOCTYPE html>
|
95
|
+
<html>
|
96
|
+
<head>
|
97
|
+
<meta charset="UTF-8">
|
98
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
99
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
100
|
+
<style>
|
101
|
+
body {{
|
102
|
+
margin: 0;
|
103
|
+
padding: 0;
|
104
|
+
background: transparent;
|
105
|
+
}}
|
106
|
+
.progress-bar {{
|
107
|
+
transition: width 0.1s linear;
|
108
|
+
}}
|
109
|
+
.play-button {{
|
110
|
+
transition: all 0.2s ease;
|
111
|
+
}}
|
112
|
+
.play-button:hover {{
|
113
|
+
transform: scale(1.05);
|
114
|
+
}}
|
115
|
+
.volume-slider {{
|
116
|
+
-webkit-appearance: none;
|
117
|
+
appearance: none;
|
118
|
+
background: transparent;
|
119
|
+
cursor: pointer;
|
120
|
+
}}
|
121
|
+
.volume-slider::-webkit-slider-thumb {{
|
122
|
+
-webkit-appearance: none;
|
123
|
+
appearance: none;
|
124
|
+
width: 12px;
|
125
|
+
height: 12px;
|
126
|
+
background: white;
|
127
|
+
border-radius: 50%;
|
128
|
+
cursor: pointer;
|
129
|
+
}}
|
130
|
+
.volume-slider::-moz-range-thumb {{
|
131
|
+
width: 12px;
|
132
|
+
height: 12px;
|
133
|
+
background: white;
|
134
|
+
border-radius: 50%;
|
135
|
+
border: none;
|
136
|
+
cursor: pointer;
|
137
|
+
}}
|
138
|
+
</style>
|
139
|
+
</head>
|
140
|
+
<body>
|
141
|
+
<!-- Hidden audio element -->
|
142
|
+
<audio id="audioPlayer" preload="metadata">
|
143
|
+
<source src="{proxied_song_url}" type="audio/mpeg">
|
144
|
+
</audio>
|
145
|
+
|
146
|
+
<!-- Compact Music Player -->
|
147
|
+
<div class="w-full bg-gradient-to-r from-purple-900 via-purple-800 to-indigo-900 rounded-xl shadow-2xl overflow-hidden">
|
148
|
+
<div class="p-4">
|
149
|
+
<!-- Top Row: Song Info + Controls -->
|
150
|
+
<div class="flex items-center gap-4 mb-3">
|
151
|
+
<!-- Album Art Icon -->
|
152
|
+
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-purple-400 to-pink-500 rounded-lg flex items-center justify-center shadow-lg">
|
153
|
+
<svg class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
|
154
|
+
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z"/>
|
155
|
+
</svg>
|
156
|
+
</div>
|
157
|
+
|
158
|
+
<!-- Song Info -->
|
159
|
+
<div class="flex-1 min-w-0">
|
160
|
+
<h3 class="text-white font-bold text-base truncate">{title}</h3>
|
161
|
+
<p class="text-purple-200 text-sm truncate">{artist}</p>
|
162
|
+
</div>
|
163
|
+
|
164
|
+
<!-- Play/Pause Button -->
|
165
|
+
<button id="playPauseBtn" class="play-button flex-shrink-0 w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-lg hover:shadow-xl">
|
166
|
+
<svg id="playIcon" class="w-6 h-6 text-purple-900" fill="currentColor" viewBox="0 0 20 20">
|
167
|
+
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"/>
|
168
|
+
</svg>
|
169
|
+
<svg id="pauseIcon" class="w-6 h-6 text-purple-900 hidden" fill="currentColor" viewBox="0 0 20 20">
|
170
|
+
<path d="M5.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75A.75.75 0 007.25 3h-1.5zM12.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75a.75.75 0 00-.75-.75h-1.5z"/>
|
171
|
+
</svg>
|
172
|
+
</button>
|
173
|
+
</div>
|
174
|
+
|
175
|
+
<!-- Progress Bar -->
|
176
|
+
<div class="mb-2">
|
177
|
+
<div class="flex items-center gap-2 text-xs text-purple-200 mb-1">
|
178
|
+
<span id="currentTime">0:00</span>
|
179
|
+
<div class="flex-1 h-1.5 bg-purple-950 rounded-full overflow-hidden cursor-pointer" id="progressContainer">
|
180
|
+
<div id="progressBar" class="progress-bar h-full bg-gradient-to-r from-pink-400 to-purple-400 rounded-full" style="width: 0%"></div>
|
181
|
+
</div>
|
182
|
+
<span id="duration">0:00</span>
|
183
|
+
</div>
|
184
|
+
</div>
|
185
|
+
|
186
|
+
<!-- Bottom Row: Volume + Actions -->
|
187
|
+
<div class="flex items-center gap-2">
|
188
|
+
<!-- Volume Control -->
|
189
|
+
<div class="flex items-center gap-2 flex-1 min-w-0 max-w-[140px]">
|
190
|
+
<svg class="w-4 h-4 text-purple-300 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
191
|
+
<path d="M10.5 3.75a.75.75 0 00-1.264-.546L5.203 7H3.167a.75.75 0 00-.7.48A6.985 6.985 0 002 10c0 .887.165 1.737.468 2.52a.75.75 0 00.7.48h2.036l4.033 3.796a.75.75 0 001.264-.546V3.75zM16.45 5.05a.75.75 0 00-1.06 1.06 5.5 5.5 0 010 7.78.75.75 0 001.06 1.06 7 7 0 000-9.9z"/>
|
192
|
+
</svg>
|
193
|
+
<input type="range" id="volumeSlider" class="volume-slider flex-1 h-1 bg-purple-700 rounded-lg min-w-0" min="0" max="100" value="70">
|
194
|
+
</div>
|
195
|
+
|
196
|
+
<!-- Action Buttons (responsive width) -->
|
197
|
+
<button onclick="sendChatMessage('Play next song')" class="px-3 py-1.5 bg-purple-700 hover:bg-purple-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
|
198
|
+
Next
|
199
|
+
</button>
|
200
|
+
<button onclick="sendChatMessage('More by {artist}')" class="px-3 py-1.5 bg-purple-700 hover:bg-purple-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
|
201
|
+
More
|
202
|
+
</button>
|
203
|
+
</div>
|
204
|
+
</div>
|
205
|
+
</div>
|
206
|
+
|
207
|
+
<script>
|
208
|
+
// Audio element and UI controls
|
209
|
+
const audio = document.getElementById('audioPlayer');
|
210
|
+
const playPauseBtn = document.getElementById('playPauseBtn');
|
211
|
+
const playIcon = document.getElementById('playIcon');
|
212
|
+
const pauseIcon = document.getElementById('pauseIcon');
|
213
|
+
const progressBar = document.getElementById('progressBar');
|
214
|
+
const progressContainer = document.getElementById('progressContainer');
|
215
|
+
const currentTimeEl = document.getElementById('currentTime');
|
216
|
+
const durationEl = document.getElementById('duration');
|
217
|
+
const volumeSlider = document.getElementById('volumeSlider');
|
218
|
+
|
219
|
+
// Format time as MM:SS
|
220
|
+
function formatTime(seconds) {{
|
221
|
+
if (isNaN(seconds)) return '0:00';
|
222
|
+
const mins = Math.floor(seconds / 60);
|
223
|
+
const secs = Math.floor(seconds % 60);
|
224
|
+
return `${{mins}}:${{secs.toString().padStart(2, '0')}}`;
|
225
|
+
}}
|
226
|
+
|
227
|
+
// Play/Pause toggle
|
228
|
+
playPauseBtn.addEventListener('click', () => {{
|
229
|
+
if (audio.paused) {{
|
230
|
+
audio.play();
|
231
|
+
playIcon.classList.add('hidden');
|
232
|
+
pauseIcon.classList.remove('hidden');
|
233
|
+
console.log('▶️ Playing');
|
234
|
+
}} else {{
|
235
|
+
audio.pause();
|
236
|
+
playIcon.classList.remove('hidden');
|
237
|
+
pauseIcon.classList.add('hidden');
|
238
|
+
console.log('⏸️ Paused');
|
239
|
+
}}
|
240
|
+
}});
|
241
|
+
|
242
|
+
// Update progress bar
|
243
|
+
audio.addEventListener('timeupdate', () => {{
|
244
|
+
if (audio.duration) {{
|
245
|
+
const progress = (audio.currentTime / audio.duration) * 100;
|
246
|
+
progressBar.style.width = progress + '%';
|
247
|
+
currentTimeEl.textContent = formatTime(audio.currentTime);
|
248
|
+
}}
|
249
|
+
}});
|
250
|
+
|
251
|
+
// Update duration when metadata loads
|
252
|
+
audio.addEventListener('loadedmetadata', () => {{
|
253
|
+
durationEl.textContent = formatTime(audio.duration);
|
254
|
+
console.log('✅ Duration loaded:', audio.duration, 'seconds');
|
255
|
+
}});
|
256
|
+
|
257
|
+
// Seek on progress bar click
|
258
|
+
progressContainer.addEventListener('click', (e) => {{
|
259
|
+
const rect = progressContainer.getBoundingClientRect();
|
260
|
+
const percent = (e.clientX - rect.left) / rect.width;
|
261
|
+
audio.currentTime = percent * audio.duration;
|
262
|
+
console.log('⏩ Seeked to:', formatTime(audio.currentTime));
|
263
|
+
}});
|
264
|
+
|
265
|
+
// Volume control (only if slider exists - hidden on mobile)
|
266
|
+
if (volumeSlider) {{
|
267
|
+
volumeSlider.addEventListener('input', (e) => {{
|
268
|
+
audio.volume = e.target.value / 100;
|
269
|
+
}});
|
270
|
+
}}
|
271
|
+
|
272
|
+
// Set initial volume
|
273
|
+
audio.volume = 0.7;
|
274
|
+
|
275
|
+
// Auto-reset play button on end
|
276
|
+
audio.addEventListener('ended', () => {{
|
277
|
+
playIcon.classList.remove('hidden');
|
278
|
+
pauseIcon.classList.add('hidden');
|
279
|
+
progressBar.style.width = '0%';
|
280
|
+
console.log('⏹️ Playback ended');
|
281
|
+
}});
|
282
|
+
|
283
|
+
// Send messages to chat
|
284
|
+
function sendChatMessage(text) {{
|
285
|
+
window.parent.postMessage({{
|
286
|
+
type: 'widget_message',
|
287
|
+
content: text
|
288
|
+
}}, '*');
|
289
|
+
console.log('💬 Sent message:', text);
|
290
|
+
}}
|
291
|
+
|
292
|
+
// Send height to parent
|
293
|
+
function sendHeight() {{
|
294
|
+
const height = document.body.scrollHeight;
|
295
|
+
window.parent.postMessage({{
|
296
|
+
type: 'widget_resize',
|
297
|
+
height: height
|
298
|
+
}}, '*');
|
299
|
+
}}
|
300
|
+
|
301
|
+
window.addEventListener('load', sendHeight);
|
302
|
+
setTimeout(sendHeight, 100);
|
303
|
+
|
304
|
+
// Error handling
|
305
|
+
audio.addEventListener('error', (e) => {{
|
306
|
+
console.error('❌ Audio error:', audio.error?.code, audio.error?.message);
|
307
|
+
alert('Failed to load audio. Please try a different track.');
|
308
|
+
}});
|
309
|
+
|
310
|
+
console.log('🎵 Custom music player initialized');
|
311
|
+
console.log('🎵 Audio src:', audio.querySelector('source')?.src);
|
312
|
+
</script>
|
313
|
+
</body>
|
314
|
+
</html>"""
|
315
|
+
|
316
|
+
# Prepare data attribute with widget metadata
|
317
|
+
widget_data = {
|
318
|
+
'song_url': song_url,
|
319
|
+
'title': title,
|
320
|
+
'artist': artist,
|
321
|
+
'album': album
|
322
|
+
}
|
323
|
+
|
324
|
+
# Escape data for safe HTML attribute embedding
|
325
|
+
escaped_data = html.escape(json.dumps(widget_data), quote=True)
|
326
|
+
|
327
|
+
# Return widget wrapped in <widget> tags with kind="webagents"
|
328
|
+
return f'<widget kind="webagents" id="music_player" data="{escaped_data}">{html_content}</widget>'
|
329
|
+
|
@@ -0,0 +1,191 @@
|
|
1
|
+
"""
|
2
|
+
Agent Handoff Skill - WebAgents V2.0
|
3
|
+
|
4
|
+
Enables handoff to remote agents via NLI (Network LLM Interface).
|
5
|
+
Provides streaming support for remote agent responses.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import json
|
9
|
+
import time
|
10
|
+
import uuid
|
11
|
+
from typing import Dict, Any, List, Optional, AsyncGenerator
|
12
|
+
|
13
|
+
from webagents.agents.skills.base import Skill, Handoff
|
14
|
+
from webagents.agents.tools.decorators import handoff
|
15
|
+
from webagents.utils.logging import get_logger
|
16
|
+
|
17
|
+
|
18
|
+
class AgentHandoffSkill(Skill):
|
19
|
+
"""
|
20
|
+
Skill for handing off to remote agents with streaming support
|
21
|
+
|
22
|
+
Uses NLI skill to communicate with remote agents and stream responses.
|
23
|
+
Automatically normalizes NLI responses to OpenAI-compatible format.
|
24
|
+
"""
|
25
|
+
|
26
|
+
def __init__(self, config: Dict[str, Any] = None):
|
27
|
+
super().__init__(config, scope="all")
|
28
|
+
self.default_agent_url = config.get('agent_url') if config else None
|
29
|
+
|
30
|
+
async def initialize(self, agent):
|
31
|
+
"""Initialize agent handoff skill"""
|
32
|
+
self.agent = agent
|
33
|
+
self.logger = get_logger('skill.handoff.agent', agent.name)
|
34
|
+
|
35
|
+
# Get NLI skill for remote communication
|
36
|
+
self.nli_skill = None # Will be set when needed
|
37
|
+
|
38
|
+
# Register handoff using decorator
|
39
|
+
# Priority=20 (lower than local LLM at priority=10)
|
40
|
+
agent.register_handoff(
|
41
|
+
Handoff(
|
42
|
+
target="remote_agent",
|
43
|
+
description="Hand off to remote specialist agent for tasks requiring specific expertise",
|
44
|
+
scope="all",
|
45
|
+
metadata={
|
46
|
+
'function': self.remote_agent_handoff,
|
47
|
+
'priority': 20,
|
48
|
+
'is_generator': True # Async generator for streaming
|
49
|
+
}
|
50
|
+
),
|
51
|
+
source="agent_handoff"
|
52
|
+
)
|
53
|
+
|
54
|
+
self.logger.info(f"📨 Registered remote agent handoff (priority=20)")
|
55
|
+
|
56
|
+
def _ensure_nli_skill(self):
|
57
|
+
"""Ensure NLI skill is available"""
|
58
|
+
if not self.nli_skill:
|
59
|
+
self.nli_skill = self.agent.skills.get('nli')
|
60
|
+
if not self.nli_skill:
|
61
|
+
raise ValueError("NLI skill required for remote agent handoffs")
|
62
|
+
|
63
|
+
@handoff(
|
64
|
+
name="remote_agent",
|
65
|
+
prompt="Hand off to remote specialist agent for complex or specialized tasks",
|
66
|
+
priority=20
|
67
|
+
)
|
68
|
+
async def remote_agent_handoff(
|
69
|
+
self,
|
70
|
+
messages: List[Dict[str, Any]],
|
71
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
72
|
+
agent_url: Optional[str] = None,
|
73
|
+
context=None,
|
74
|
+
**kwargs
|
75
|
+
) -> AsyncGenerator[Dict[str, Any], None]:
|
76
|
+
"""Stream responses from remote agent via NLI
|
77
|
+
|
78
|
+
Args:
|
79
|
+
messages: Conversation messages
|
80
|
+
tools: Available tools to pass to remote agent
|
81
|
+
agent_url: URL of remote agent (overrides default)
|
82
|
+
context: Request context (auto-injected)
|
83
|
+
**kwargs: Additional arguments
|
84
|
+
|
85
|
+
Yields:
|
86
|
+
OpenAI-compatible streaming chunks
|
87
|
+
"""
|
88
|
+
target_url = agent_url or self.default_agent_url
|
89
|
+
if not target_url:
|
90
|
+
# Try to get from context or raise error
|
91
|
+
if context:
|
92
|
+
target_url = context.get('handoff_agent_url')
|
93
|
+
|
94
|
+
if not target_url:
|
95
|
+
raise ValueError(
|
96
|
+
"agent_url required for remote handoff. "
|
97
|
+
"Provide via config, parameter, or context."
|
98
|
+
)
|
99
|
+
|
100
|
+
self._ensure_nli_skill()
|
101
|
+
|
102
|
+
self.logger.info(f"🔄 Starting remote agent handoff to: {target_url}")
|
103
|
+
|
104
|
+
# Stream from remote agent via NLI
|
105
|
+
try:
|
106
|
+
async for chunk in self.nli_skill.stream_message(
|
107
|
+
agent_url=target_url,
|
108
|
+
messages=messages,
|
109
|
+
tools=tools
|
110
|
+
):
|
111
|
+
# Normalize NLI chunk to OpenAI format
|
112
|
+
normalized_chunk = self._normalize_nli_chunk(chunk)
|
113
|
+
yield normalized_chunk
|
114
|
+
|
115
|
+
except Exception as e:
|
116
|
+
self.logger.error(f"Remote handoff failed: {e}")
|
117
|
+
# Yield error chunk
|
118
|
+
yield self._create_error_chunk(str(e))
|
119
|
+
|
120
|
+
def _normalize_nli_chunk(self, nli_chunk: Dict[str, Any]) -> Dict[str, Any]:
|
121
|
+
"""Convert NLI streaming chunk to OpenAI format
|
122
|
+
|
123
|
+
Args:
|
124
|
+
nli_chunk: Chunk from NLI stream
|
125
|
+
|
126
|
+
Returns:
|
127
|
+
OpenAI-compatible streaming chunk
|
128
|
+
"""
|
129
|
+
# If already in OpenAI format, pass through
|
130
|
+
if 'choices' in nli_chunk and isinstance(nli_chunk.get('choices'), list):
|
131
|
+
if nli_chunk['choices'] and 'delta' in nli_chunk['choices'][0]:
|
132
|
+
return nli_chunk
|
133
|
+
|
134
|
+
# Convert from NLI format to OpenAI streaming format
|
135
|
+
# NLI format might be: {"content": "...", "finish_reason": "stop", ...}
|
136
|
+
content = nli_chunk.get('content', '')
|
137
|
+
finish_reason = nli_chunk.get('finish_reason')
|
138
|
+
tool_calls = nli_chunk.get('tool_calls')
|
139
|
+
|
140
|
+
chunk = {
|
141
|
+
"id": nli_chunk.get('id') or f"chatcmpl-{uuid.uuid4().hex[:8]}",
|
142
|
+
"object": "chat.completion.chunk",
|
143
|
+
"created": nli_chunk.get('created') or int(time.time()),
|
144
|
+
"model": nli_chunk.get('model', 'remote-agent'),
|
145
|
+
"choices": [{
|
146
|
+
"index": 0,
|
147
|
+
"delta": {
|
148
|
+
"role": "assistant" if content or tool_calls else None,
|
149
|
+
},
|
150
|
+
"finish_reason": finish_reason
|
151
|
+
}]
|
152
|
+
}
|
153
|
+
|
154
|
+
# Add content if present
|
155
|
+
if content:
|
156
|
+
chunk["choices"][0]["delta"]["content"] = content
|
157
|
+
|
158
|
+
# Add tool calls if present
|
159
|
+
if tool_calls:
|
160
|
+
chunk["choices"][0]["delta"]["tool_calls"] = tool_calls
|
161
|
+
|
162
|
+
# Add usage if present (final chunk)
|
163
|
+
if 'usage' in nli_chunk:
|
164
|
+
chunk['usage'] = nli_chunk['usage']
|
165
|
+
|
166
|
+
return chunk
|
167
|
+
|
168
|
+
def _create_error_chunk(self, error_message: str) -> Dict[str, Any]:
|
169
|
+
"""Create error chunk in OpenAI format
|
170
|
+
|
171
|
+
Args:
|
172
|
+
error_message: Error message to include
|
173
|
+
|
174
|
+
Returns:
|
175
|
+
OpenAI-compatible error chunk
|
176
|
+
"""
|
177
|
+
return {
|
178
|
+
"id": f"chatcmpl-error-{uuid.uuid4().hex[:8]}",
|
179
|
+
"object": "chat.completion.chunk",
|
180
|
+
"created": int(time.time()),
|
181
|
+
"model": "remote-agent-error",
|
182
|
+
"choices": [{
|
183
|
+
"index": 0,
|
184
|
+
"delta": {
|
185
|
+
"role": "assistant",
|
186
|
+
"content": f"Error communicating with remote agent: {error_message}"
|
187
|
+
},
|
188
|
+
"finish_reason": "stop"
|
189
|
+
}]
|
190
|
+
}
|
191
|
+
|