Coverage for agentos/marketplace/__init__.py: 0%
386 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-02 09:59 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-02 09:59 +0800
1"""
2AgentOS Marketplace — Agent template registry and discovery hub.
4v1.14.4: Central marketplace for discovering, publishing, and installing
5 agent templates, workflows, and plugins.
7Key features:
8- Template registry with semantic search
9- Versioned agent templates with dependency resolution
10- Public + private registries
11- One-click install from marketplace
12- Agent ratings, reviews, and usage stats
13- Template validation and compatibility checking
14- CLI and programmatic API
15"""
17import asyncio
18import hashlib
19import json
20import logging
21import os
22import shutil
23import tempfile
24import time
25from abc import ABC, abstractmethod
26from dataclasses import dataclass, field
27from enum import Enum, auto
28from pathlib import Path
29from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Set, Tuple, Union
30from urllib.parse import urlparse
32logger = logging.getLogger(__name__)
35# ---------------------------------------------------------------------------
36# Data types
37# ---------------------------------------------------------------------------
39class TemplateCategory(Enum):
40 CHAT = "chat"
41 CODING = "coding"
42 ANALYSIS = "analysis"
43 AUTOMATION = "automation"
44 RESEARCH = "research"
45 CREATIVE = "creative"
46 ENTERPRISE = "enterprise"
47 UTILITY = "utility"
48 OTHER = "other"
51class TemplateStatus(Enum):
52 DRAFT = "draft"
53 PUBLISHED = "published"
54 DEPRECATED = "deprecated"
55 ARCHIVED = "archived"
56 UNDER_REVIEW = "under_review"
59@dataclass
60class TemplateDependency:
61 """A dependency required by a template."""
62 name: str
63 version_spec: str = "*" # PEP 440 version specifier
64 optional: bool = False
65 description: str = ""
68@dataclass
69class TemplateVersion:
70 """A specific version of a template."""
71 version: str # SemVer
72 changelog: str = ""
73 min_agentos_version: str = "1.0.0"
74 files: Dict[str, str] = field(default_factory=dict) # path → content
75 dependencies: List[TemplateDependency] = field(default_factory=list)
76 metadata: Dict[str, Any] = field(default_factory=dict)
77 published_at: float = 0.0
78 download_count: int = 0
81@dataclass
82class TemplateReview:
83 """User review of a template."""
84 user_id: str
85 rating: float # 1.0 - 5.0
86 comment: str = ""
87 timestamp: float = field(default_factory=time.time)
88 helpful_count: int = 0
91@dataclass
92class AgentTemplate:
93 """An agent template in the marketplace."""
94 # Identity
95 id: str
96 name: str
97 version: str
98 author: str
99 description: str = ""
100 category: TemplateCategory = TemplateCategory.OTHER
101 tags: List[str] = field(default_factory=list)
102 icon_url: str = ""
104 # Status
105 status: TemplateStatus = TemplateStatus.PUBLISHED
107 # Content
108 versions: List[TemplateVersion] = field(default_factory=list)
109 readme: str = ""
110 license: str = "MIT"
112 # Engagement
113 stars: int = 0
114 downloads: int = 0
115 reviews: List[TemplateReview] = field(default_factory=list)
117 # Compatibility
118 compatible_agentos_versions: str = ">=1.0.0"
119 requires: List[TemplateDependency] = field(default_factory=list)
121 # Metadata
122 created_at: float = field(default_factory=time.time)
123 updated_at: float = field(default_factory=time.time)
124 source_url: str = ""
125 documentation_url: str = ""
126 metadata: Dict[str, Any] = field(default_factory=dict)
128 @property
129 def rating(self) -> float:
130 """Average rating."""
131 if not self.reviews:
132 return 0.0
133 return sum(r.rating for r in self.reviews) / len(self.reviews)
135 @property
136 def latest_version(self) -> Optional[TemplateVersion]:
137 """Get the latest published version."""
138 published = [v for v in self.versions if v.published_at > 0]
139 if not published:
140 return None
141 return max(published, key=lambda v: v.published_at)
144@dataclass
145class MarketSearchQuery:
146 """Search query for the marketplace."""
147 keywords: str = ""
148 category: Optional[TemplateCategory] = None
149 tags: List[str] = field(default_factory=list)
150 min_rating: float = 0.0
151 min_stars: int = 0
152 author: Optional[str] = None
153 sort_by: str = "relevance" # relevance, downloads, rating, stars, updated
154 sort_order: str = "desc"
155 limit: int = 20
156 offset: int = 0
159@dataclass
160class MarketSearchResult:
161 """Search result from the marketplace."""
162 template: AgentTemplate
163 score: float = 0.0
164 matched_tags: List[str] = field(default_factory=list)
167# ---------------------------------------------------------------------------
168# Registry backends
169# ---------------------------------------------------------------------------
171class MarketRegistryBackend(ABC):
172 """Abstract backend for template storage and retrieval."""
174 @abstractmethod
175 async def list_templates(
176 self, query: Optional[MarketSearchQuery] = None
177 ) -> List[MarketSearchResult]:
178 ...
180 @abstractmethod
181 async def get_template(self, template_id: str) -> Optional[AgentTemplate]:
182 ...
184 @abstractmethod
185 async def publish_template(self, template: AgentTemplate) -> bool:
186 ...
188 @abstractmethod
189 async def unpublish_template(self, template_id: str) -> bool:
190 ...
192 @abstractmethod
193 async def add_review(self, template_id: str, review: TemplateReview) -> bool:
194 ...
196 @abstractmethod
197 async def get_stats(self) -> Dict[str, Any]:
198 ...
201class InMemoryMarketBackend(MarketRegistryBackend):
202 """In-memory registry for development and testing."""
204 def __init__(self):
205 self._templates: Dict[str, AgentTemplate] = {}
207 async def list_templates(
208 self, query: Optional[MarketSearchQuery] = None
209 ) -> List[MarketSearchResult]:
210 results = []
211 for tpl in self._templates.values():
212 if tpl.status != TemplateStatus.PUBLISHED:
213 continue
215 score = 0.0
216 matched_tags = []
218 if query:
219 # Keyword search
220 if query.keywords:
221 kw_lower = query.keywords.lower()
222 text = f"{tpl.name} {tpl.description} {' '.join(tpl.tags)}".lower()
223 if kw_lower in text:
224 score += 10.0
226 # Category filter
227 if query.category and tpl.category != query.category:
228 continue
230 # Tag filter
231 if query.tags:
232 matched_tags = [t for t in query.tags if t in tpl.tags]
233 if not matched_tags:
234 continue
235 score += len(matched_tags) * 2.0
237 # Rating filter
238 if query.min_rating > 0 and tpl.rating < query.min_rating:
239 continue
241 # Stars filter
242 if query.min_stars > 0 and tpl.stars < query.min_stars:
243 continue
245 # Author filter
246 if query.author and tpl.author != query.author:
247 continue
249 results.append(MarketSearchResult(
250 template=tpl,
251 score=score,
252 matched_tags=matched_tags,
253 ))
255 # Sort
256 if query:
257 sort_key = query.sort_by
258 reverse = query.sort_order == "desc"
259 if sort_key == "downloads":
260 results.sort(key=lambda r: r.template.downloads, reverse=reverse)
261 elif sort_key == "rating":
262 results.sort(key=lambda r: r.template.rating, reverse=reverse)
263 elif sort_key == "stars":
264 results.sort(key=lambda r: r.template.stars, reverse=reverse)
265 elif sort_key == "updated":
266 results.sort(key=lambda r: r.template.updated_at, reverse=reverse)
267 else: # relevance
268 results.sort(key=lambda r: r.score, reverse=reverse)
270 # Paginate
271 results = results[query.offset:query.offset + query.limit]
273 return results
275 async def get_template(self, template_id: str) -> Optional[AgentTemplate]:
276 return self._templates.get(template_id)
278 async def publish_template(self, template: AgentTemplate) -> bool:
279 template.updated_at = time.time()
280 self._templates[template.id] = template
281 return True
283 async def unpublish_template(self, template_id: str) -> bool:
284 if template_id in self._templates:
285 self._templates[template_id].status = TemplateStatus.ARCHIVED
286 return True
287 return False
289 async def add_review(self, template_id: str, review: TemplateReview) -> bool:
290 tpl = self._templates.get(template_id)
291 if not tpl:
292 return False
293 tpl.reviews.append(review)
294 return True
296 async def get_stats(self) -> Dict[str, Any]:
297 total = len(self._templates)
298 by_category = {}
299 for tpl in self._templates.values():
300 cat = tpl.category.value
301 by_category[cat] = by_category.get(cat, 0) + 1
302 return {
303 "total_templates": total,
304 "total_downloads": sum(t.downloads for t in self._templates.values()),
305 "by_category": by_category,
306 }
309class FileMarketBackend(MarketRegistryBackend):
310 """JSON-file-based registry for local/CI usage."""
312 def __init__(self, storage_dir: Union[str, Path]):
313 self._dir = Path(storage_dir)
314 self._dir.mkdir(parents=True, exist_ok=True)
315 self._index_file = self._dir / "index.json"
316 self._templates: Dict[str, AgentTemplate] = {}
317 self._load()
319 def _load(self) -> None:
320 if self._index_file.exists():
321 with open(self._index_file, "r") as f:
322 data = json.load(f)
323 for raw in data.get("templates", []):
324 tpl = self._dict_to_template(raw)
325 self._templates[tpl.id] = tpl
327 def _save(self) -> None:
328 data = {
329 "updated_at": time.time(),
330 "templates": [self._template_to_dict(t) for t in self._templates.values()],
331 }
332 with open(self._index_file, "w") as f:
333 json.dump(data, f, indent=2, default=str)
335 def _template_to_dict(self, tpl: AgentTemplate) -> Dict[str, Any]:
336 return {
337 "id": tpl.id,
338 "name": tpl.name,
339 "version": tpl.version,
340 "author": tpl.author,
341 "description": tpl.description,
342 "category": tpl.category.value,
343 "tags": tpl.tags,
344 "status": tpl.status.value,
345 "stars": tpl.stars,
346 "downloads": tpl.downloads,
347 "rating": tpl.rating,
348 "review_count": len(tpl.reviews),
349 "compatible_agentos_versions": tpl.compatible_agentos_versions,
350 "created_at": tpl.created_at,
351 "updated_at": tpl.updated_at,
352 }
354 def _dict_to_template(self, d: Dict[str, Any]) -> AgentTemplate:
355 return AgentTemplate(
356 id=d["id"],
357 name=d["name"],
358 version=d.get("version", "1.0.0"),
359 author=d["author"],
360 description=d.get("description", ""),
361 category=TemplateCategory(d.get("category", "other")),
362 tags=d.get("tags", []),
363 status=TemplateStatus(d.get("status", "published")),
364 stars=d.get("stars", 0),
365 downloads=d.get("downloads", 0),
366 compatible_agentos_versions=d.get("compatible_agentos_versions", ">=1.0.0"),
367 created_at=d.get("created_at", 0),
368 updated_at=d.get("updated_at", 0),
369 )
371 # Delegate to in-memory backend
372 async def list_templates(self, query=None):
373 backend = InMemoryMarketBackend()
374 backend._templates = dict(self._templates)
375 return await backend.list_templates(query)
377 async def get_template(self, template_id):
378 return self._templates.get(template_id)
380 async def publish_template(self, template):
381 tpl = AgentTemplate(**{
382 k: v for k, v in template.__dict__.items()
383 if k in AgentTemplate.__dataclass_fields__
384 })
385 template.updated_at = time.time()
386 self._templates[template.id] = template
387 self._save()
388 return True
390 async def unpublish_template(self, template_id):
391 if template_id in self._templates:
392 self._templates[template_id].status = TemplateStatus.ARCHIVED
393 self._save()
394 return True
395 return False
397 async def add_review(self, template_id, review):
398 tpl = self._templates.get(template_id)
399 if not tpl:
400 return False
401 tpl.reviews.append(review)
402 self._save()
403 return True
405 async def get_stats(self):
406 backend = InMemoryMarketBackend()
407 backend._templates = dict(self._templates)
408 return await backend.get_stats()
411# ---------------------------------------------------------------------------
412# Remote registry client
413# ---------------------------------------------------------------------------
415class RemoteMarketClient:
416 """HTTP client for remote marketplace registries."""
418 def __init__(self, base_url: str, api_key: Optional[str] = None):
419 self.base_url = base_url.rstrip("/")
420 self.api_key = api_key
422 async def search(self, query: MarketSearchQuery) -> List[MarketSearchResult]:
423 """Search the remote marketplace."""
424 import urllib.request
425 import urllib.parse
427 params = {}
428 if query.keywords:
429 params["q"] = query.keywords
430 if query.category:
431 params["category"] = query.category.value
432 if query.tags:
433 params["tags"] = ",".join(query.tags)
434 if query.limit:
435 params["limit"] = str(query.limit)
437 url = f"{self.base_url}/api/v1/templates"
438 if params:
439 url += "?" + urllib.parse.urlencode(params)
441 # Use asyncio-compatible HTTP
442 import aiohttp
443 async with aiohttp.ClientSession() as session:
444 headers = {}
445 if self.api_key:
446 headers["Authorization"] = f"Bearer {self.api_key}"
447 async with session.get(url, headers=headers) as resp:
448 data = await resp.json()
449 return [
450 MarketSearchResult(
451 template=AgentTemplate(**item["template"]),
452 score=item.get("score", 0),
453 )
454 for item in data.get("results", [])
455 ]
457 async def get_template(self, template_id: str) -> Optional[AgentTemplate]:
458 """Fetch a template from the remote registry."""
459 import aiohttp
460 async with aiohttp.ClientSession() as session:
461 headers = {}
462 if self.api_key:
463 headers["Authorization"] = f"Bearer {self.api_key}"
464 async with session.get(
465 f"{self.base_url}/api/v1/templates/{template_id}",
466 headers=headers,
467 ) as resp:
468 if resp.status == 404:
469 return None
470 data = await resp.json()
471 return AgentTemplate(**data)
473 async def download_template(
474 self, template_id: str, target_dir: Union[str, Path]
475 ) -> bool:
476 """Download and extract a template to a local directory."""
477 import aiohttp
478 target = Path(target_dir)
479 target.mkdir(parents=True, exist_ok=True)
481 async with aiohttp.ClientSession() as session:
482 headers = {}
483 if self.api_key:
484 headers["Authorization"] = f"Bearer {self.api_key}"
485 async with session.get(
486 f"{self.base_url}/api/v1/templates/{template_id}/download",
487 headers=headers,
488 ) as resp:
489 if resp.status != 200:
490 return False
492 import tarfile
493 import io
494 data = await resp.read()
495 with tarfile.open(fileobj=io.BytesIO(data)) as tar:
496 tar.extractall(path=target)
497 return True
500# ---------------------------------------------------------------------------
501# Marketplace Manager
502# ---------------------------------------------------------------------------
504class MarketplaceManager:
505 """Central marketplace management — search, install, publish."""
507 def __init__(
508 self,
509 local_backend: Optional[MarketRegistryBackend] = None,
510 remote_clients: Optional[List[RemoteMarketClient]] = None,
511 install_dir: Union[str, Path] = "~/.agentos/templates",
512 ):
513 self.local = local_backend or InMemoryMarketBackend()
514 self.remote_clients = remote_clients or []
515 self.install_dir = Path(install_dir).expanduser()
516 self.install_dir.mkdir(parents=True, exist_ok=True)
518 async def search(
519 self, query: MarketSearchQuery, include_remote: bool = True
520 ) -> List[MarketSearchResult]:
521 """Search local and remote registries."""
522 results = await self.local.list_templates(query)
524 if include_remote:
525 for client in self.remote_clients:
526 try:
527 remote_results = await client.search(query)
528 results.extend(remote_results)
529 except Exception as e:
530 logger.warning(f"Remote search failed: {e}")
532 # Deduplicate by template ID
533 seen: Set[str] = set()
534 deduped = []
535 for r in results:
536 if r.template.id not in seen:
537 seen.add(r.template.id)
538 deduped.append(r)
540 return deduped
542 async def install(self, template_id: str, version: Optional[str] = None) -> Path:
543 """Install a template from local or remote registry."""
544 # Check local first
545 tpl = await self.local.get_template(template_id)
547 # Try remote
548 if not tpl:
549 for client in self.remote_clients:
550 try:
551 tpl = await client.get_template(template_id)
552 if tpl:
553 break
554 except Exception:
555 continue
557 if not tpl:
558 raise ValueError(f"Template '{template_id}' not found in any registry")
560 # Install to local directory
561 tpl_dir = self.install_dir / template_id
562 if version:
563 tpl_dir = tpl_dir / version
565 tpl_dir.mkdir(parents=True, exist_ok=True)
567 # Write template files
568 latest = tpl.latest_version
569 if latest:
570 for filepath, content in latest.files.items():
571 full_path = tpl_dir / filepath
572 full_path.parent.mkdir(parents=True, exist_ok=True)
573 with open(full_path, "w") as f:
574 f.write(content)
576 # Record installation
577 tpl.downloads += 1
578 tpl.updated_at = time.time()
580 return tpl_dir
582 async def publish(
583 self,
584 template: AgentTemplate,
585 to_remote: bool = False,
586 ) -> bool:
587 """Publish a template to registries."""
588 # Always publish to local
589 ok = await self.local.publish_template(template)
590 if not ok:
591 return False
593 # Optionally push to remote
594 if to_remote:
595 for client in self.remote_clients:
596 try:
597 # Remote publishing would use a POST endpoint
598 pass
599 except Exception as e:
600 logger.error(f"Remote publish failed: {e}")
602 return True
604 async def get_stats(self) -> Dict[str, Any]:
605 """Get marketplace statistics."""
606 return await self.local.get_stats()
608 async def get_featured(self, limit: int = 10) -> List[AgentTemplate]:
609 """Get featured/popular templates."""
610 query = MarketSearchQuery(sort_by="downloads", limit=limit)
611 results = await self.local.list_templates(query)
612 return [r.template for r in results]
614 async def get_by_category(
615 self, category: TemplateCategory, limit: int = 20
616 ) -> List[AgentTemplate]:
617 """Get templates by category."""
618 query = MarketSearchQuery(category=category, limit=limit)
619 results = await self.local.list_templates(query)
620 return [r.template for r in results]
623# ---------------------------------------------------------------------------
624# Template builder
625# ---------------------------------------------------------------------------
627class TemplateBuilder:
628 """Helper to build AgentTemplate objects programmatically."""
630 def __init__(self, name: str, author: str, version: str = "1.0.0"):
631 self._template = AgentTemplate(
632 id=hashlib.sha256(f"{author}/{name}".encode()).hexdigest()[:16],
633 name=name,
634 version=version,
635 author=author,
636 )
638 def description(self, text: str) -> "TemplateBuilder":
639 self._template.description = text
640 return self
642 def category(self, cat: TemplateCategory) -> "TemplateBuilder":
643 self._template.category = cat
644 return self
646 def tags(self, *tags: str) -> "TemplateBuilder":
647 self._template.tags = list(tags)
648 return self
650 def add_version(
651 self, version: str, files: Dict[str, str], changelog: str = ""
652 ) -> "TemplateBuilder":
653 tv = TemplateVersion(
654 version=version,
655 changelog=changelog,
656 files=files,
657 published_at=time.time(),
658 )
659 self._template.versions.append(tv)
660 return self
662 def add_dependency(
663 self, name: str, version_spec: str = "*", optional: bool = False
664 ) -> "TemplateBuilder":
665 self._template.requires.append(
666 TemplateDependency(name=name, version_spec=version_spec, optional=optional)
667 )
668 return self
670 def add_review(self, user_id: str, rating: float, comment: str = "") -> "TemplateBuilder":
671 self._template.reviews.append(
672 TemplateReview(user_id=user_id, rating=rating, comment=comment)
673 )
674 return self
676 def build(self) -> AgentTemplate:
677 if not self._template.description:
678 raise ValueError("Template must have a description")
679 return self._template
682# ---------------------------------------------------------------------------
683# Pre-seeded templates
684# ---------------------------------------------------------------------------
686def seed_default_templates(manager: MarketplaceManager) -> None:
687 """Seed the marketplace with default templates."""
688 templates = [
689 TemplateBuilder("Conversational Agent", "AgentOS Team")
690 .description("General-purpose conversational agent with memory and tool use")
691 .category(TemplateCategory.CHAT)
692 .tags("chat", "conversation", "memory")
693 .add_version("1.0.0", {
694 "agent.yaml": "name: conversational-agent\ntype: chat\nmemory: enabled",
695 "main.py": "from agentos import Agent\n\nagent = Agent(...)",
696 })
697 .build(),
699 TemplateBuilder("Code Review Assistant", "AgentOS Team")
700 .description("AI-powered code reviewer with PR integration")
701 .category(TemplateCategory.CODING)
702 .tags("code", "review", "github", "pr")
703 .add_version("1.0.0", {
704 "agent.yaml": "name: code-reviewer\ntype: coding\n",
705 "review.py": "async def review_pr(pr_url): ...",
706 })
707 .build(),
709 TemplateBuilder("Research Analyst", "AgentOS Team")
710 .description("Multi-source research agent with deep analysis capabilities")
711 .category(TemplateCategory.RESEARCH)
712 .tags("research", "analysis", "web")
713 .add_version("1.0.0", {
714 "agent.yaml": "name: research-analyst\ntype: research\n",
715 "analyst.py": "async def deep_research(topic): ...",
716 })
717 .build(),
719 TemplateBuilder("Data Pipeline Agent", "AgentOS Team")
720 .description("Automated ETL and data processing pipeline")
721 .category(TemplateCategory.AUTOMATION)
722 .tags("etl", "data", "pipeline", "automation")
723 .add_version("1.0.0", {
724 "agent.yaml": "name: data-pipeline\ntype: automation\n",
725 "pipeline.py": "async def run_pipeline(config): ...",
726 })
727 .build(),
729 TemplateBuilder("Document Writer", "AgentOS Team")
730 .description("Professional document generation from outlines or templates")
731 .category(TemplateCategory.CREATIVE)
732 .tags("writing", "document", "report")
733 .add_version("1.0.0", {
734 "agent.yaml": "name: doc-writer\ntype: creative\n",
735 "writer.py": "async def generate_doc(outline): ...",
736 })
737 .build(),
738 ]
740 async def _seed():
741 for tpl in templates:
742 await manager.local.publish_template(tpl)
744 try:
745 asyncio.get_event_loop().run_until_complete(_seed())
746 except RuntimeError:
747 asyncio.run(_seed())
750# ---------------------------------------------------------------------------
751# Export
752# ---------------------------------------------------------------------------
754__all__ = [
755 # Enums
756 "TemplateCategory",
757 "TemplateStatus",
758 # Data types
759 "TemplateDependency",
760 "TemplateVersion",
761 "TemplateReview",
762 "AgentTemplate",
763 "MarketSearchQuery",
764 "MarketSearchResult",
765 # Backends
766 "MarketRegistryBackend",
767 "InMemoryMarketBackend",
768 "FileMarketBackend",
769 "RemoteMarketClient",
770 # Manager
771 "MarketplaceManager",
772 "TemplateBuilder",
773 "seed_default_templates",
774]