Coverage for agentos/marketplace/__init__.py: 0%
382 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 21:19 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 21:19 +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 time
22from abc import ABC, abstractmethod
23from dataclasses import dataclass, field
24from enum import Enum
25from pathlib import Path
26from typing import Any
28logger = logging.getLogger(__name__)
31# ---------------------------------------------------------------------------
32# Data types
33# ---------------------------------------------------------------------------
36class TemplateCategory(Enum):
37 CHAT = "chat"
38 CODING = "coding"
39 ANALYSIS = "analysis"
40 AUTOMATION = "automation"
41 RESEARCH = "research"
42 CREATIVE = "creative"
43 ENTERPRISE = "enterprise"
44 UTILITY = "utility"
45 OTHER = "other"
48class TemplateStatus(Enum):
49 DRAFT = "draft"
50 PUBLISHED = "published"
51 DEPRECATED = "deprecated"
52 ARCHIVED = "archived"
53 UNDER_REVIEW = "under_review"
56@dataclass
57class TemplateDependency:
58 """A dependency required by a template."""
60 name: str
61 version_spec: str = "*" # PEP 440 version specifier
62 optional: bool = False
63 description: str = ""
66@dataclass
67class TemplateVersion:
68 """A specific version of a template."""
70 version: str # SemVer
71 changelog: str = ""
72 min_agentos_version: str = "1.0.0"
73 files: dict[str, str] = field(default_factory=dict) # path → content
74 dependencies: list[TemplateDependency] = field(default_factory=list)
75 metadata: dict[str, Any] = field(default_factory=dict)
76 published_at: float = 0.0
77 download_count: int = 0
80@dataclass
81class TemplateReview:
82 """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."""
95 # Identity
96 id: str
97 name: str
98 version: str
99 author: str
100 description: str = ""
101 category: TemplateCategory = TemplateCategory.OTHER
102 tags: list[str] = field(default_factory=list)
103 icon_url: str = ""
105 # Status
106 status: TemplateStatus = TemplateStatus.PUBLISHED
108 # Content
109 versions: list[TemplateVersion] = field(default_factory=list)
110 readme: str = ""
111 license: str = "MIT"
113 # Engagement
114 stars: int = 0
115 downloads: int = 0
116 reviews: list[TemplateReview] = field(default_factory=list)
118 # Compatibility
119 compatible_agentos_versions: str = ">=1.0.0"
120 requires: list[TemplateDependency] = field(default_factory=list)
122 # Metadata
123 created_at: float = field(default_factory=time.time)
124 updated_at: float = field(default_factory=time.time)
125 source_url: str = ""
126 documentation_url: str = ""
127 metadata: dict[str, Any] = field(default_factory=dict)
129 @property
130 def rating(self) -> float:
131 """Average rating."""
132 if not self.reviews:
133 return 0.0
134 return sum(r.rating for r in self.reviews) / len(self.reviews)
136 @property
137 def latest_version(self) -> TemplateVersion | None:
138 """Get the latest published version."""
139 published = [v for v in self.versions if v.published_at > 0]
140 if not published:
141 return None
142 return max(published, key=lambda v: v.published_at)
145@dataclass
146class MarketSearchQuery:
147 """Search query for the marketplace."""
149 keywords: str = ""
150 category: TemplateCategory | None = None
151 tags: list[str] = field(default_factory=list)
152 min_rating: float = 0.0
153 min_stars: int = 0
154 author: str | None = None
155 sort_by: str = "relevance" # relevance, downloads, rating, stars, updated
156 sort_order: str = "desc"
157 limit: int = 20
158 offset: int = 0
161@dataclass
162class MarketSearchResult:
163 """Search result from the marketplace."""
165 template: AgentTemplate
166 score: float = 0.0
167 matched_tags: list[str] = field(default_factory=list)
170# ---------------------------------------------------------------------------
171# Registry backends
172# ---------------------------------------------------------------------------
175class MarketRegistryBackend(ABC):
176 """Abstract backend for template storage and retrieval."""
178 @abstractmethod
179 async def list_templates(
180 self, query: MarketSearchQuery | None = None
181 ) -> list[MarketSearchResult]: ...
183 @abstractmethod
184 async def get_template(self, template_id: str) -> AgentTemplate | None: ...
186 @abstractmethod
187 async def publish_template(self, template: AgentTemplate) -> bool: ...
189 @abstractmethod
190 async def unpublish_template(self, template_id: str) -> bool: ...
192 @abstractmethod
193 async def add_review(self, template_id: str, review: TemplateReview) -> bool: ...
195 @abstractmethod
196 async def get_stats(self) -> dict[str, Any]: ...
199class InMemoryMarketBackend(MarketRegistryBackend):
200 """In-memory registry for development and testing."""
202 def __init__(self):
203 self._templates: dict[str, AgentTemplate] = {}
205 async def list_templates(
206 self, query: MarketSearchQuery | None = None
207 ) -> list[MarketSearchResult]:
208 results = []
209 for tpl in self._templates.values():
210 if tpl.status != TemplateStatus.PUBLISHED:
211 continue
213 score = 0.0
214 matched_tags = []
216 if query:
217 # Keyword search
218 if query.keywords:
219 kw_lower = query.keywords.lower()
220 text = f"{tpl.name} {tpl.description} {' '.join(tpl.tags)}".lower()
221 if kw_lower in text:
222 score += 10.0
224 # Category filter
225 if query.category and tpl.category != query.category:
226 continue
228 # Tag filter
229 if query.tags:
230 matched_tags = [t for t in query.tags if t in tpl.tags]
231 if not matched_tags:
232 continue
233 score += len(matched_tags) * 2.0
235 # Rating filter
236 if query.min_rating > 0 and tpl.rating < query.min_rating:
237 continue
239 # Stars filter
240 if query.min_stars > 0 and tpl.stars < query.min_stars:
241 continue
243 # Author filter
244 if query.author and tpl.author != query.author:
245 continue
247 results.append(
248 MarketSearchResult(
249 template=tpl,
250 score=score,
251 matched_tags=matched_tags,
252 )
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) -> AgentTemplate | None:
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: 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) 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 AgentTemplate(
382 **{
383 k: v
384 for k, v in template.__dict__.items()
385 if k in AgentTemplate.__dataclass_fields__
386 }
387 )
388 template.updated_at = time.time()
389 self._templates[template.id] = template
390 self._save()
391 return True
393 async def unpublish_template(self, template_id):
394 if template_id in self._templates:
395 self._templates[template_id].status = TemplateStatus.ARCHIVED
396 self._save()
397 return True
398 return False
400 async def add_review(self, template_id, review):
401 tpl = self._templates.get(template_id)
402 if not tpl:
403 return False
404 tpl.reviews.append(review)
405 self._save()
406 return True
408 async def get_stats(self):
409 backend = InMemoryMarketBackend()
410 backend._templates = dict(self._templates)
411 return await backend.get_stats()
414# ---------------------------------------------------------------------------
415# Remote registry client
416# ---------------------------------------------------------------------------
419class RemoteMarketClient:
420 """HTTP client for remote marketplace registries."""
422 def __init__(self, base_url: str, api_key: str | None = None):
423 self.base_url = base_url.rstrip("/")
424 self.api_key = api_key
426 async def search(self, query: MarketSearchQuery) -> list[MarketSearchResult]:
427 """Search the remote marketplace."""
428 import urllib.parse
429 import urllib.request
431 params = {}
432 if query.keywords:
433 params["q"] = query.keywords
434 if query.category:
435 params["category"] = query.category.value
436 if query.tags:
437 params["tags"] = ",".join(query.tags)
438 if query.limit:
439 params["limit"] = str(query.limit)
441 url = f"{self.base_url}/api/v1/templates"
442 if params:
443 url += "?" + urllib.parse.urlencode(params)
445 # Use asyncio-compatible HTTP
446 import aiohttp
448 async with aiohttp.ClientSession() as session:
449 headers = {}
450 if self.api_key:
451 headers["Authorization"] = f"Bearer {self.api_key}"
452 async with session.get(url, headers=headers) as resp:
453 data = await resp.json()
454 return [
455 MarketSearchResult(
456 template=AgentTemplate(**item["template"]),
457 score=item.get("score", 0),
458 )
459 for item in data.get("results", [])
460 ]
462 async def get_template(self, template_id: str) -> AgentTemplate | None:
463 """Fetch a template from the remote registry."""
464 import aiohttp
466 async with aiohttp.ClientSession() as session:
467 headers = {}
468 if self.api_key:
469 headers["Authorization"] = f"Bearer {self.api_key}"
470 async with session.get(
471 f"{self.base_url}/api/v1/templates/{template_id}",
472 headers=headers,
473 ) as resp:
474 if resp.status == 404:
475 return None
476 data = await resp.json()
477 return AgentTemplate(**data)
479 async def download_template(self, template_id: str, target_dir: str | Path) -> bool:
480 """Download and extract a template to a local directory."""
481 import aiohttp
483 target = Path(target_dir)
484 target.mkdir(parents=True, exist_ok=True)
486 async with aiohttp.ClientSession() as session:
487 headers = {}
488 if self.api_key:
489 headers["Authorization"] = f"Bearer {self.api_key}"
490 async with session.get(
491 f"{self.base_url}/api/v1/templates/{template_id}/download",
492 headers=headers,
493 ) as resp:
494 if resp.status != 200:
495 return False
497 import io
498 import tarfile
500 data = await resp.read()
501 with tarfile.open(fileobj=io.BytesIO(data)) as tar:
502 tar.extractall(path=target)
503 return True
506# ---------------------------------------------------------------------------
507# Marketplace Manager
508# ---------------------------------------------------------------------------
511class MarketplaceManager:
512 """Central marketplace management — search, install, publish."""
514 def __init__(
515 self,
516 local_backend: MarketRegistryBackend | None = None,
517 remote_clients: list[RemoteMarketClient] | None = None,
518 install_dir: str | Path = "~/.agentos/templates",
519 ):
520 self.local = local_backend or InMemoryMarketBackend()
521 self.remote_clients = remote_clients or []
522 self.install_dir = Path(install_dir).expanduser()
523 self.install_dir.mkdir(parents=True, exist_ok=True)
525 async def search(
526 self, query: MarketSearchQuery, include_remote: bool = True
527 ) -> list[MarketSearchResult]:
528 """Search local and remote registries."""
529 results = await self.local.list_templates(query)
531 if include_remote:
532 for client in self.remote_clients:
533 try:
534 remote_results = await client.search(query)
535 results.extend(remote_results)
536 except Exception as e:
537 logger.warning(f"Remote search failed: {e}")
539 # Deduplicate by template ID
540 seen: set[str] = set()
541 deduped = []
542 for r in results:
543 if r.template.id not in seen:
544 seen.add(r.template.id)
545 deduped.append(r)
547 return deduped
549 async def install(self, template_id: str, version: str | None = None) -> Path:
550 """Install a template from local or remote registry."""
551 # Check local first
552 tpl = await self.local.get_template(template_id)
554 # Try remote
555 if not tpl:
556 for client in self.remote_clients:
557 try:
558 tpl = await client.get_template(template_id)
559 if tpl:
560 break
561 except Exception:
562 continue
564 if not tpl:
565 raise ValueError(f"Template '{template_id}' not found in any registry")
567 # Install to local directory
568 tpl_dir = self.install_dir / template_id
569 if version:
570 tpl_dir = tpl_dir / version
572 tpl_dir.mkdir(parents=True, exist_ok=True)
574 # Write template files
575 latest = tpl.latest_version
576 if latest:
577 for filepath, content in latest.files.items():
578 full_path = tpl_dir / filepath
579 full_path.parent.mkdir(parents=True, exist_ok=True)
580 with open(full_path, "w") as f:
581 f.write(content)
583 # Record installation
584 tpl.downloads += 1
585 tpl.updated_at = time.time()
587 return tpl_dir
589 async def publish(
590 self,
591 template: AgentTemplate,
592 to_remote: bool = False,
593 ) -> bool:
594 """Publish a template to registries."""
595 # Always publish to local
596 ok = await self.local.publish_template(template)
597 if not ok:
598 return False
600 # Optionally push to remote
601 if to_remote:
602 for client in self.remote_clients:
603 try:
604 # Remote publishing would use a POST endpoint
605 pass
606 except Exception as e:
607 logger.error(f"Remote publish failed: {e}")
609 return True
611 async def get_stats(self) -> dict[str, Any]:
612 """Get marketplace statistics."""
613 return await self.local.get_stats()
615 async def get_featured(self, limit: int = 10) -> list[AgentTemplate]:
616 """Get featured/popular templates."""
617 query = MarketSearchQuery(sort_by="downloads", limit=limit)
618 results = await self.local.list_templates(query)
619 return [r.template for r in results]
621 async def get_by_category(
622 self, category: TemplateCategory, limit: int = 20
623 ) -> list[AgentTemplate]:
624 """Get templates by category."""
625 query = MarketSearchQuery(category=category, limit=limit)
626 results = await self.local.list_templates(query)
627 return [r.template for r in results]
630# ---------------------------------------------------------------------------
631# Template builder
632# ---------------------------------------------------------------------------
635class TemplateBuilder:
636 """Helper to build AgentTemplate objects programmatically."""
638 def __init__(self, name: str, author: str, version: str = "1.0.0"):
639 self._template = AgentTemplate(
640 id=hashlib.sha256(f"{author}/{name}".encode()).hexdigest()[:16],
641 name=name,
642 version=version,
643 author=author,
644 )
646 def description(self, text: str) -> "TemplateBuilder":
647 self._template.description = text
648 return self
650 def category(self, cat: TemplateCategory) -> "TemplateBuilder":
651 self._template.category = cat
652 return self
654 def tags(self, *tags: str) -> "TemplateBuilder":
655 self._template.tags = list(tags)
656 return self
658 def add_version(
659 self, version: str, files: dict[str, str], changelog: str = ""
660 ) -> "TemplateBuilder":
661 tv = TemplateVersion(
662 version=version,
663 changelog=changelog,
664 files=files,
665 published_at=time.time(),
666 )
667 self._template.versions.append(tv)
668 return self
670 def add_dependency(
671 self, name: str, version_spec: str = "*", optional: bool = False
672 ) -> "TemplateBuilder":
673 self._template.requires.append(
674 TemplateDependency(name=name, version_spec=version_spec, optional=optional)
675 )
676 return self
678 def add_review(self, user_id: str, rating: float, comment: str = "") -> "TemplateBuilder":
679 self._template.reviews.append(
680 TemplateReview(user_id=user_id, rating=rating, comment=comment)
681 )
682 return self
684 def build(self) -> AgentTemplate:
685 if not self._template.description:
686 raise ValueError("Template must have a description")
687 return self._template
690# ---------------------------------------------------------------------------
691# Pre-seeded templates
692# ---------------------------------------------------------------------------
695def seed_default_templates(manager: MarketplaceManager) -> None:
696 """Seed the marketplace with default templates."""
697 templates = [
698 TemplateBuilder("Conversational Agent", "AgentOS Team")
699 .description("General-purpose conversational agent with memory and tool use")
700 .category(TemplateCategory.CHAT)
701 .tags("chat", "conversation", "memory")
702 .add_version(
703 "1.0.0",
704 {
705 "agent.yaml": "name: conversational-agent\ntype: chat\nmemory: enabled",
706 "main.py": "from agentos import Agent\n\nagent = Agent(...)",
707 },
708 )
709 .build(),
710 TemplateBuilder("Code Review Assistant", "AgentOS Team")
711 .description("AI-powered code reviewer with PR integration")
712 .category(TemplateCategory.CODING)
713 .tags("code", "review", "github", "pr")
714 .add_version(
715 "1.0.0",
716 {
717 "agent.yaml": "name: code-reviewer\ntype: coding\n",
718 "review.py": "async def review_pr(pr_url): ...",
719 },
720 )
721 .build(),
722 TemplateBuilder("Research Analyst", "AgentOS Team")
723 .description("Multi-source research agent with deep analysis capabilities")
724 .category(TemplateCategory.RESEARCH)
725 .tags("research", "analysis", "web")
726 .add_version(
727 "1.0.0",
728 {
729 "agent.yaml": "name: research-analyst\ntype: research\n",
730 "analyst.py": "async def deep_research(topic): ...",
731 },
732 )
733 .build(),
734 TemplateBuilder("Data Pipeline Agent", "AgentOS Team")
735 .description("Automated ETL and data processing pipeline")
736 .category(TemplateCategory.AUTOMATION)
737 .tags("etl", "data", "pipeline", "automation")
738 .add_version(
739 "1.0.0",
740 {
741 "agent.yaml": "name: data-pipeline\ntype: automation\n",
742 "pipeline.py": "async def run_pipeline(config): ...",
743 },
744 )
745 .build(),
746 TemplateBuilder("Document Writer", "AgentOS Team")
747 .description("Professional document generation from outlines or templates")
748 .category(TemplateCategory.CREATIVE)
749 .tags("writing", "document", "report")
750 .add_version(
751 "1.0.0",
752 {
753 "agent.yaml": "name: doc-writer\ntype: creative\n",
754 "writer.py": "async def generate_doc(outline): ...",
755 },
756 )
757 .build(),
758 ]
760 async def _seed():
761 for tpl in templates:
762 await manager.local.publish_template(tpl)
764 try:
765 asyncio.get_event_loop().run_until_complete(_seed())
766 except RuntimeError:
767 asyncio.run(_seed())
770# ---------------------------------------------------------------------------
771# Export
772# ---------------------------------------------------------------------------
774__all__ = [
775 # Enums
776 "TemplateCategory",
777 "TemplateStatus",
778 # Data types
779 "TemplateDependency",
780 "TemplateVersion",
781 "TemplateReview",
782 "AgentTemplate",
783 "MarketSearchQuery",
784 "MarketSearchResult",
785 # Backends
786 "MarketRegistryBackend",
787 "InMemoryMarketBackend",
788 "FileMarketBackend",
789 "RemoteMarketClient",
790 # Manager
791 "MarketplaceManager",
792 "TemplateBuilder",
793 "seed_default_templates",
794]