webagents 0.2.2__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.
Files changed (31) hide show
  1. webagents/__init__.py +9 -0
  2. webagents/agents/core/base_agent.py +865 -69
  3. webagents/agents/core/handoffs.py +14 -6
  4. webagents/agents/skills/base.py +33 -2
  5. webagents/agents/skills/core/llm/litellm/skill.py +906 -27
  6. webagents/agents/skills/core/memory/vector_memory/skill.py +8 -16
  7. webagents/agents/skills/ecosystem/openai/__init__.py +6 -0
  8. webagents/agents/skills/ecosystem/openai/skill.py +867 -0
  9. webagents/agents/skills/ecosystem/replicate/README.md +440 -0
  10. webagents/agents/skills/ecosystem/replicate/__init__.py +10 -0
  11. webagents/agents/skills/ecosystem/replicate/skill.py +517 -0
  12. webagents/agents/skills/examples/__init__.py +6 -0
  13. webagents/agents/skills/examples/music_player.py +329 -0
  14. webagents/agents/skills/robutler/handoff/__init__.py +6 -0
  15. webagents/agents/skills/robutler/handoff/skill.py +191 -0
  16. webagents/agents/skills/robutler/nli/skill.py +180 -24
  17. webagents/agents/skills/robutler/payments/exceptions.py +27 -7
  18. webagents/agents/skills/robutler/payments/skill.py +64 -14
  19. webagents/agents/skills/robutler/storage/files/skill.py +2 -2
  20. webagents/agents/tools/decorators.py +243 -47
  21. webagents/agents/widgets/__init__.py +6 -0
  22. webagents/agents/widgets/renderer.py +150 -0
  23. webagents/server/core/app.py +130 -15
  24. webagents/server/core/models.py +1 -1
  25. webagents/utils/logging.py +13 -1
  26. {webagents-0.2.2.dist-info → webagents-0.2.3.dist-info}/METADATA +8 -25
  27. {webagents-0.2.2.dist-info → webagents-0.2.3.dist-info}/RECORD +30 -20
  28. webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
  29. {webagents-0.2.2.dist-info → webagents-0.2.3.dist-info}/WHEEL +0 -0
  30. {webagents-0.2.2.dist-info → webagents-0.2.3.dist-info}/entry_points.txt +0 -0
  31. {webagents-0.2.2.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,6 @@
1
+ """Agent Handoff Skill - Remote agent handoff via NLI"""
2
+
3
+ from .skill import AgentHandoffSkill
4
+
5
+ __all__ = ['AgentHandoffSkill']
6
+
@@ -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
+