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.
- 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/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/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.2.dist-info → webagents-0.2.3.dist-info}/METADATA +8 -25
- {webagents-0.2.2.dist-info → webagents-0.2.3.dist-info}/RECORD +30 -20
- webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
- {webagents-0.2.2.dist-info → webagents-0.2.3.dist-info}/WHEEL +0 -0
- {webagents-0.2.2.dist-info → webagents-0.2.3.dist-info}/entry_points.txt +0 -0
- {webagents-0.2.2.dist-info → webagents-0.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -247,74 +247,104 @@ def prompt(priority: int = 50, scope: Union[str, List[str]] = "all"):
|
|
247
247
|
return decorator
|
248
248
|
|
249
249
|
|
250
|
-
def handoff(
|
251
|
-
|
252
|
-
|
250
|
+
def handoff(
|
251
|
+
name: Optional[str] = None,
|
252
|
+
prompt: Optional[str] = None,
|
253
|
+
scope: Union[str, List[str]] = "all",
|
254
|
+
priority: int = 50,
|
255
|
+
auto_tool: bool = False,
|
256
|
+
auto_tool_description: Optional[str] = None
|
257
|
+
):
|
258
|
+
"""Decorator to mark functions as handoff handlers for automatic registration
|
259
|
+
|
260
|
+
Handoff functions handle chat completions and can be:
|
261
|
+
1. Async generators (streaming native) - work in both streaming and non-streaming modes
|
262
|
+
2. Regular async functions (non-streaming native) - work in both streaming and non-streaming modes
|
253
263
|
|
254
264
|
Args:
|
255
|
-
name:
|
256
|
-
|
257
|
-
|
265
|
+
name: Handoff identifier (defaults to function name)
|
266
|
+
prompt: Description/guidance about when to use this handoff
|
267
|
+
- Used as handoff description for discovery
|
268
|
+
- Added to dynamic prompts to guide the LLM
|
258
269
|
scope: Access scope - "all", "owner", "admin", or list of scopes
|
270
|
+
priority: Execution priority - lower numbers = higher priority (determines default)
|
271
|
+
First handoff with lowest priority becomes the default completion handler
|
272
|
+
auto_tool: If True, automatically create a tool to invoke this handoff dynamically
|
273
|
+
auto_tool_description: Description for the auto-generated tool (defaults to generic description)
|
259
274
|
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
275
|
+
Examples:
|
276
|
+
# Local LLM handoff (non-streaming)
|
277
|
+
@handoff(name="gpt4", prompt="Primary LLM for general reasoning", priority=10)
|
278
|
+
async def chat_completion(self, messages, tools=None, **kwargs) -> Dict[str, Any]:
|
279
|
+
return await llm_api_call(messages, tools)
|
280
|
+
|
281
|
+
# Remote agent handoff (streaming)
|
282
|
+
@handoff(name="specialist", prompt="Hand off to specialist for complex tasks", priority=20)
|
283
|
+
async def remote_handoff(self, messages, tools=None, **kwargs) -> AsyncGenerator[Dict, None]:
|
284
|
+
async for chunk in nli_stream(agent_url, messages):
|
285
|
+
yield chunk
|
286
|
+
|
287
|
+
# With context injection
|
288
|
+
@handoff(name="custom", priority=15)
|
289
|
+
async def my_handoff(self, messages, tools=None, context=None, **kwargs) -> Dict:
|
290
|
+
api_key = context.get("api_key")
|
291
|
+
return await custom_llm_call(messages, api_key)
|
266
292
|
"""
|
267
293
|
def decorator(func: Callable) -> Callable:
|
268
|
-
#
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
294
|
+
# Validate function type
|
295
|
+
is_async_gen = inspect.isasyncgenfunction(func)
|
296
|
+
is_async = inspect.iscoroutinefunction(func)
|
297
|
+
|
298
|
+
if not is_async_gen and not is_async:
|
299
|
+
raise ValueError(
|
300
|
+
f"Handoff '{func.__name__}' must be async function or async generator. "
|
301
|
+
f"Got: {type(func).__name__}"
|
302
|
+
)
|
274
303
|
|
275
304
|
# Check if function expects context injection
|
276
305
|
sig = inspect.signature(func)
|
277
306
|
has_context_param = 'context' in sig.parameters
|
278
307
|
|
308
|
+
# Create wrapper if context injection needed
|
279
309
|
if has_context_param:
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
310
|
+
if is_async_gen:
|
311
|
+
# Async generator with context
|
312
|
+
@functools.wraps(func)
|
313
|
+
async def async_gen_wrapper(*args, **kwargs):
|
314
|
+
if 'context' not in kwargs:
|
315
|
+
from ...server.context.context_vars import get_context
|
316
|
+
context = get_context()
|
317
|
+
kwargs['context'] = context
|
318
|
+
|
319
|
+
async for item in func(*args, **kwargs):
|
320
|
+
yield item
|
287
321
|
|
288
|
-
|
289
|
-
|
322
|
+
wrapper = async_gen_wrapper
|
323
|
+
else:
|
324
|
+
# Regular async function with context
|
325
|
+
@functools.wraps(func)
|
326
|
+
async def async_wrapper(*args, **kwargs):
|
327
|
+
if 'context' not in kwargs:
|
328
|
+
from ...server.context.context_vars import get_context
|
329
|
+
context = get_context()
|
330
|
+
kwargs['context'] = context
|
331
|
+
|
290
332
|
return await func(*args, **kwargs)
|
291
|
-
else:
|
292
|
-
return func(*args, **kwargs)
|
293
|
-
|
294
|
-
@functools.wraps(func)
|
295
|
-
def sync_wrapper(*args, **kwargs):
|
296
|
-
# Inject context if requested and not provided
|
297
|
-
if 'context' not in kwargs:
|
298
|
-
from ...server.context.context_vars import get_context
|
299
|
-
context = get_context()
|
300
|
-
kwargs['context'] = context
|
301
333
|
|
302
|
-
return func(*args, **kwargs)
|
303
|
-
|
304
|
-
# Return appropriate wrapper
|
305
|
-
if inspect.iscoroutinefunction(func):
|
306
334
|
wrapper = async_wrapper
|
307
|
-
else:
|
308
|
-
wrapper = sync_wrapper
|
309
335
|
else:
|
336
|
+
# No context injection needed
|
310
337
|
wrapper = func
|
311
338
|
|
312
|
-
#
|
339
|
+
# Mark function with metadata for BaseAgent discovery
|
313
340
|
wrapper._webagents_is_handoff = True
|
314
|
-
wrapper._handoff_type = handoff_type
|
315
|
-
wrapper._handoff_scope = scope
|
316
341
|
wrapper._handoff_name = name or func.__name__
|
317
|
-
wrapper.
|
342
|
+
wrapper._handoff_prompt = prompt or func.__doc__ or f"Handoff: {func.__name__}"
|
343
|
+
wrapper._handoff_scope = scope
|
344
|
+
wrapper._handoff_priority = priority
|
345
|
+
wrapper._handoff_is_generator = is_async_gen
|
346
|
+
wrapper._handoff_auto_tool = auto_tool
|
347
|
+
wrapper._handoff_auto_tool_description = auto_tool_description or f"Switch to {name or func.__name__} handoff"
|
318
348
|
|
319
349
|
return wrapper
|
320
350
|
|
@@ -423,4 +453,170 @@ def http(subpath: str, method: str = "get", scope: Union[str, List[str]] = "all"
|
|
423
453
|
|
424
454
|
return wrapper
|
425
455
|
|
456
|
+
return decorator
|
457
|
+
|
458
|
+
|
459
|
+
def widget(func: Optional[Callable] = None, *, name: Optional[str] = None, description: Optional[str] = None, template: Optional[str] = None, scope: Union[str, List[str]] = "all", auto_escape: bool = True):
|
460
|
+
"""Decorator to mark functions as widgets for automatic registration
|
461
|
+
|
462
|
+
Widgets are interactive HTML components rendered in sandboxed iframes on the frontend.
|
463
|
+
They support both Jinja2 templates and inline HTML strings.
|
464
|
+
|
465
|
+
Can be used as:
|
466
|
+
@widget
|
467
|
+
def my_widget(self, param: str) -> str: ...
|
468
|
+
|
469
|
+
Or:
|
470
|
+
@widget(name="custom", description="Custom widget", scope="owner")
|
471
|
+
def my_widget(self, param: str) -> str: ...
|
472
|
+
|
473
|
+
Args:
|
474
|
+
name: Optional override for widget name (defaults to function name)
|
475
|
+
description: Widget description (defaults to function docstring)
|
476
|
+
template: Optional path to Jinja2 template file
|
477
|
+
scope: Access scope - "all", "owner", "admin", or list of scopes
|
478
|
+
auto_escape: Automatically HTML-escape string arguments (default: True)
|
479
|
+
Set to False if you're passing pre-rendered safe HTML
|
480
|
+
|
481
|
+
The decorated function should return HTML wrapped in <widget> tags:
|
482
|
+
|
483
|
+
@widget(scope="all") # auto_escape=True by default
|
484
|
+
def my_widget(self, param: str, context: Context = None) -> str:
|
485
|
+
# param is automatically escaped! No need for html.escape()
|
486
|
+
html = f"<div>{param}</div>"
|
487
|
+
return f'<widget kind="webagents" id="my_widget">{html}</widget>'
|
488
|
+
|
489
|
+
For pre-rendered HTML, disable auto-escaping:
|
490
|
+
|
491
|
+
@widget(scope="all", auto_escape=False)
|
492
|
+
def unsafe_widget(self, raw_html: str) -> str:
|
493
|
+
# raw_html is NOT escaped - use with caution!
|
494
|
+
return f'<widget kind="webagents" id="unsafe">{raw_html}</widget>'
|
495
|
+
|
496
|
+
Widgets are only included in LLM context for browser requests (detected via User-Agent).
|
497
|
+
"""
|
498
|
+
def decorator(f: Callable) -> Callable:
|
499
|
+
# Generate widget schema for LLM awareness
|
500
|
+
sig = inspect.signature(f)
|
501
|
+
parameters = {}
|
502
|
+
required = []
|
503
|
+
|
504
|
+
for param_name, param in sig.parameters.items():
|
505
|
+
# Skip 'self' and 'context' parameters from schema
|
506
|
+
if param_name in ('self', 'context'):
|
507
|
+
continue
|
508
|
+
|
509
|
+
param_type = "string" # Default type
|
510
|
+
param_desc = f"Parameter {param_name}"
|
511
|
+
|
512
|
+
# Try to infer type from annotation
|
513
|
+
if param.annotation != inspect.Parameter.empty:
|
514
|
+
if param.annotation == int:
|
515
|
+
param_type = "integer"
|
516
|
+
elif param.annotation == float:
|
517
|
+
param_type = "number"
|
518
|
+
elif param.annotation == bool:
|
519
|
+
param_type = "boolean"
|
520
|
+
elif param.annotation == list:
|
521
|
+
param_type = "array"
|
522
|
+
elif param.annotation == dict:
|
523
|
+
param_type = "object"
|
524
|
+
|
525
|
+
parameters[param_name] = {
|
526
|
+
"type": param_type,
|
527
|
+
"description": param_desc
|
528
|
+
}
|
529
|
+
|
530
|
+
# Mark as required if no default value
|
531
|
+
if param.default == inspect.Parameter.empty:
|
532
|
+
required.append(param_name)
|
533
|
+
|
534
|
+
# Create widget schema (similar to tool schema for LLM)
|
535
|
+
widget_schema = {
|
536
|
+
"type": "function",
|
537
|
+
"function": {
|
538
|
+
"name": name or f.__name__,
|
539
|
+
"description": description or f.__doc__ or f"Widget: {f.__name__}",
|
540
|
+
"parameters": {
|
541
|
+
"type": "object",
|
542
|
+
"properties": parameters,
|
543
|
+
"required": required
|
544
|
+
}
|
545
|
+
}
|
546
|
+
}
|
547
|
+
|
548
|
+
# Mark function with metadata for BaseAgent discovery
|
549
|
+
f._webagents_is_widget = True
|
550
|
+
f._webagents_widget_definition = widget_schema
|
551
|
+
f._widget_scope = scope
|
552
|
+
f._widget_scope_was_set = func is None # If func is None, decorator was called with params
|
553
|
+
f._widget_name = name or f.__name__
|
554
|
+
f._widget_description = description or f.__doc__ or f"Widget: {f.__name__}"
|
555
|
+
f._widget_template = template
|
556
|
+
|
557
|
+
# Check if function expects context injection
|
558
|
+
has_context_param = 'context' in sig.parameters
|
559
|
+
|
560
|
+
@functools.wraps(f)
|
561
|
+
async def async_wrapper(*args, **kwargs):
|
562
|
+
# Inject context if function expects it
|
563
|
+
if has_context_param and 'context' not in kwargs:
|
564
|
+
from webagents.server.context.context_vars import get_context
|
565
|
+
kwargs['context'] = get_context()
|
566
|
+
|
567
|
+
# Auto-escape string arguments if enabled
|
568
|
+
if auto_escape:
|
569
|
+
import html
|
570
|
+
# Escape all string kwargs (except 'context' and 'self')
|
571
|
+
escaped_kwargs = {}
|
572
|
+
for key, value in kwargs.items():
|
573
|
+
if key not in ('context', 'self') and isinstance(value, str):
|
574
|
+
escaped_kwargs[key] = html.escape(value)
|
575
|
+
else:
|
576
|
+
escaped_kwargs[key] = value
|
577
|
+
kwargs = escaped_kwargs
|
578
|
+
|
579
|
+
# Call original function
|
580
|
+
if inspect.iscoroutinefunction(f):
|
581
|
+
return await f(*args, **kwargs)
|
582
|
+
else:
|
583
|
+
return f(*args, **kwargs)
|
584
|
+
|
585
|
+
@functools.wraps(f)
|
586
|
+
def sync_wrapper(*args, **kwargs):
|
587
|
+
# Inject context if function expects it
|
588
|
+
if has_context_param and 'context' not in kwargs:
|
589
|
+
from webagents.server.context.context_vars import get_context
|
590
|
+
kwargs['context'] = get_context()
|
591
|
+
|
592
|
+
# Auto-escape string arguments if enabled
|
593
|
+
if auto_escape:
|
594
|
+
import html
|
595
|
+
# Escape all string kwargs (except 'context' and 'self')
|
596
|
+
escaped_kwargs = {}
|
597
|
+
for key, value in kwargs.items():
|
598
|
+
if key not in ('context', 'self') and isinstance(value, str):
|
599
|
+
escaped_kwargs[key] = html.escape(value)
|
600
|
+
else:
|
601
|
+
escaped_kwargs[key] = value
|
602
|
+
kwargs = escaped_kwargs
|
603
|
+
|
604
|
+
# Call original function
|
605
|
+
return f(*args, **kwargs)
|
606
|
+
|
607
|
+
# Preserve metadata on wrapper
|
608
|
+
wrapper = async_wrapper if inspect.iscoroutinefunction(f) else sync_wrapper
|
609
|
+
wrapper._webagents_is_widget = True
|
610
|
+
wrapper._webagents_widget_definition = widget_schema
|
611
|
+
wrapper._widget_scope = scope
|
612
|
+
wrapper._widget_scope_was_set = func is None
|
613
|
+
wrapper._widget_name = name or f.__name__
|
614
|
+
wrapper._widget_description = description or f.__doc__ or f"Widget: {f.__name__}"
|
615
|
+
wrapper._widget_template = template
|
616
|
+
|
617
|
+
return wrapper
|
618
|
+
|
619
|
+
# Support both @widget and @widget()
|
620
|
+
if func is not None:
|
621
|
+
return decorator(func)
|
426
622
|
return decorator
|
@@ -0,0 +1,150 @@
|
|
1
|
+
"""Widget template renderer for HTML templates with Jinja2 support"""
|
2
|
+
|
3
|
+
import os
|
4
|
+
import warnings
|
5
|
+
from typing import Dict, Any, Optional
|
6
|
+
from jinja2 import Environment, FileSystemLoader, Template, TemplateNotFound
|
7
|
+
import html
|
8
|
+
|
9
|
+
|
10
|
+
class WidgetTemplateRenderer:
|
11
|
+
"""Renders WebAgents widget HTML from Jinja2 templates or inline HTML strings
|
12
|
+
|
13
|
+
This renderer is specifically for WebAgents HTML widgets (kind="webagents"),
|
14
|
+
NOT for OpenAI ChatKit widgets (kind="openai").
|
15
|
+
|
16
|
+
Supports both file-based templates and inline HTML with variable substitution.
|
17
|
+
Ensures proper HTML escaping for data attributes to prevent XSS.
|
18
|
+
|
19
|
+
Example:
|
20
|
+
# File-based template
|
21
|
+
renderer = WidgetTemplateRenderer(template_dir="widgets")
|
22
|
+
html = renderer.render("music_player.html", {"title": "Song Name"})
|
23
|
+
|
24
|
+
# Inline HTML
|
25
|
+
renderer = WidgetTemplateRenderer()
|
26
|
+
html = renderer.render_inline("<div>{{ title }}</div>", {"title": "Song Name"})
|
27
|
+
"""
|
28
|
+
|
29
|
+
def __init__(self, template_dir: Optional[str] = None):
|
30
|
+
"""Initialize widget renderer
|
31
|
+
|
32
|
+
Args:
|
33
|
+
template_dir: Directory containing Jinja2 template files (optional)
|
34
|
+
"""
|
35
|
+
self.template_dir = template_dir or "widgets"
|
36
|
+
|
37
|
+
# Initialize Jinja2 environment if template directory exists
|
38
|
+
if os.path.exists(self.template_dir):
|
39
|
+
self.env = Environment(
|
40
|
+
loader=FileSystemLoader(self.template_dir),
|
41
|
+
autoescape=True # Auto-escape HTML for security
|
42
|
+
)
|
43
|
+
else:
|
44
|
+
self.env = None
|
45
|
+
|
46
|
+
def render(self, template_name: str, context: Dict[str, Any]) -> str:
|
47
|
+
"""Render widget HTML from a template file
|
48
|
+
|
49
|
+
Args:
|
50
|
+
template_name: Name of template file (e.g., "music_player.html")
|
51
|
+
context: Dictionary of variables to pass to template
|
52
|
+
|
53
|
+
Returns:
|
54
|
+
Rendered HTML string
|
55
|
+
|
56
|
+
Raises:
|
57
|
+
TemplateNotFound: If template file doesn't exist
|
58
|
+
ValueError: If template directory not configured
|
59
|
+
"""
|
60
|
+
if not self.env:
|
61
|
+
raise ValueError(f"Template directory '{self.template_dir}' not found")
|
62
|
+
|
63
|
+
template = self.env.get_template(template_name)
|
64
|
+
return template.render(**context)
|
65
|
+
|
66
|
+
def render_inline(self, html_string: str, context: Dict[str, Any]) -> str:
|
67
|
+
"""Render widget HTML from an inline string
|
68
|
+
|
69
|
+
Args:
|
70
|
+
html_string: HTML string with Jinja2 template syntax
|
71
|
+
context: Dictionary of variables to pass to template
|
72
|
+
|
73
|
+
Returns:
|
74
|
+
Rendered HTML string
|
75
|
+
"""
|
76
|
+
template = Template(html_string, autoescape=True)
|
77
|
+
return template.render(**context)
|
78
|
+
|
79
|
+
@staticmethod
|
80
|
+
def escape_data(data: Any) -> str:
|
81
|
+
"""Escape data for safe inclusion in HTML attributes
|
82
|
+
|
83
|
+
Args:
|
84
|
+
data: Data to escape (will be converted to string)
|
85
|
+
|
86
|
+
Returns:
|
87
|
+
HTML-escaped string safe for attribute values
|
88
|
+
"""
|
89
|
+
return html.escape(str(data), quote=True)
|
90
|
+
|
91
|
+
@staticmethod
|
92
|
+
def inject_tailwind_cdn(html_content: str) -> str:
|
93
|
+
"""Inject Tailwind CSS CDN if not already present
|
94
|
+
|
95
|
+
Args:
|
96
|
+
html_content: HTML content to check and modify
|
97
|
+
|
98
|
+
Returns:
|
99
|
+
HTML content with Tailwind CDN injected in <head>
|
100
|
+
"""
|
101
|
+
# Check if Tailwind is already present
|
102
|
+
if 'tailwindcss.com' in html_content:
|
103
|
+
return html_content
|
104
|
+
|
105
|
+
# Check if there's a <head> tag
|
106
|
+
if '<head>' in html_content:
|
107
|
+
# Inject after <head>
|
108
|
+
return html_content.replace(
|
109
|
+
'<head>',
|
110
|
+
'<head>\n <script src="https://cdn.tailwindcss.com"></script>',
|
111
|
+
1 # Only replace first occurrence
|
112
|
+
)
|
113
|
+
elif '<html>' in html_content:
|
114
|
+
# Inject after <html>
|
115
|
+
return html_content.replace(
|
116
|
+
'<html>',
|
117
|
+
'<html>\n<head>\n <script src="https://cdn.tailwindcss.com"></script>\n</head>',
|
118
|
+
1
|
119
|
+
)
|
120
|
+
else:
|
121
|
+
# No HTML structure, wrap in basic HTML with Tailwind
|
122
|
+
return f"""<!DOCTYPE html>
|
123
|
+
<html>
|
124
|
+
<head>
|
125
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
126
|
+
</head>
|
127
|
+
<body>
|
128
|
+
{html_content}
|
129
|
+
</body>
|
130
|
+
</html>"""
|
131
|
+
|
132
|
+
|
133
|
+
# Backward compatibility alias with deprecation warning
|
134
|
+
class ChatKitRenderer(WidgetTemplateRenderer):
|
135
|
+
"""Deprecated: Use WidgetTemplateRenderer instead.
|
136
|
+
|
137
|
+
This class was misnamed - it renders WebAgents HTML widgets using Jinja2,
|
138
|
+
NOT OpenAI ChatKit widgets. Use WidgetTemplateRenderer for clarity.
|
139
|
+
"""
|
140
|
+
|
141
|
+
def __init__(self, *args, **kwargs):
|
142
|
+
warnings.warn(
|
143
|
+
"ChatKitRenderer is deprecated and will be removed in a future version. "
|
144
|
+
"Use WidgetTemplateRenderer instead. Note: This class renders WebAgents HTML widgets, "
|
145
|
+
"not OpenAI ChatKit widgets.",
|
146
|
+
DeprecationWarning,
|
147
|
+
stacklevel=2
|
148
|
+
)
|
149
|
+
super().__init__(*args, **kwargs)
|
150
|
+
|