orcha-sdk 0.1.1__tar.gz
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.
- orcha_sdk-0.1.1/.gitignore +130 -0
- orcha_sdk-0.1.1/PKG-INFO +81 -0
- orcha_sdk-0.1.1/README.md +58 -0
- orcha_sdk-0.1.1/pyproject.toml +41 -0
- orcha_sdk-0.1.1/src/emerge/__init__.py +60 -0
- orcha_sdk-0.1.1/src/emerge/cli.py +311 -0
- orcha_sdk-0.1.1/src/emerge/client.py +71 -0
- orcha_sdk-0.1.1/src/emerge/manifest.py +56 -0
- orcha_sdk-0.1.1/src/emerge/sdk.py +129 -0
- orcha_sdk-0.1.1/src/emerge/server.py +190 -0
- orcha_sdk-0.1.1/src/emerge/templates/your-first-agent/README.md +31 -0
- orcha_sdk-0.1.1/src/emerge/templates/your-first-agent/agent.py +55 -0
- orcha_sdk-0.1.1/src/emerge/templates/your-first-agent/requirements.txt +1 -0
- orcha_sdk-0.1.1/tests/test_sdk.py +125 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib64/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
wheels/
|
|
18
|
+
*.egg-info/
|
|
19
|
+
.installed.cfg
|
|
20
|
+
*.egg
|
|
21
|
+
MANIFEST
|
|
22
|
+
|
|
23
|
+
# Virtual Environments
|
|
24
|
+
.env.*
|
|
25
|
+
.env
|
|
26
|
+
.venv/
|
|
27
|
+
env/
|
|
28
|
+
venv/
|
|
29
|
+
ENV/
|
|
30
|
+
|
|
31
|
+
# uv
|
|
32
|
+
.uv/
|
|
33
|
+
|
|
34
|
+
# IDEs
|
|
35
|
+
.vscode/
|
|
36
|
+
.idea/
|
|
37
|
+
*.swp
|
|
38
|
+
*.swo
|
|
39
|
+
*~
|
|
40
|
+
.DS_Store
|
|
41
|
+
|
|
42
|
+
# Testing
|
|
43
|
+
.pytest_cache/
|
|
44
|
+
.coverage
|
|
45
|
+
coverage.xml
|
|
46
|
+
htmlcov/
|
|
47
|
+
.tox/
|
|
48
|
+
*.cover
|
|
49
|
+
*.log
|
|
50
|
+
|
|
51
|
+
# Prisma
|
|
52
|
+
node_modules/
|
|
53
|
+
common/database/src/generated_client/
|
|
54
|
+
*.db
|
|
55
|
+
*.db-journal
|
|
56
|
+
|
|
57
|
+
# gRPC Generated
|
|
58
|
+
common/proto/src/*_pb2.py
|
|
59
|
+
common/proto/src/*_pb2_grpc.py
|
|
60
|
+
common/proto/src/*_pb2.pyi
|
|
61
|
+
|
|
62
|
+
# Logs
|
|
63
|
+
*.log
|
|
64
|
+
logs/
|
|
65
|
+
dist/
|
|
66
|
+
|
|
67
|
+
# Database
|
|
68
|
+
*.sqlite
|
|
69
|
+
*.sqlite3
|
|
70
|
+
|
|
71
|
+
# Environment
|
|
72
|
+
.env.local
|
|
73
|
+
.env.*.local
|
|
74
|
+
.env.production
|
|
75
|
+
.env.*
|
|
76
|
+
!.env.example
|
|
77
|
+
!.env.sandbox.example
|
|
78
|
+
|
|
79
|
+
# Docker
|
|
80
|
+
.dockerignore
|
|
81
|
+
|
|
82
|
+
# SSH keys / certificates / credentials (never commit these)
|
|
83
|
+
*.pem
|
|
84
|
+
*.key
|
|
85
|
+
*.p12
|
|
86
|
+
*.pfx
|
|
87
|
+
*_SERV_ACC*.json
|
|
88
|
+
*service-account*.json
|
|
89
|
+
*service_account*.json
|
|
90
|
+
GOOGLE_SERV_ACC*.json
|
|
91
|
+
*credentials*.json
|
|
92
|
+
*-credentials.json
|
|
93
|
+
gcp-*.json
|
|
94
|
+
startup.log
|
|
95
|
+
|
|
96
|
+
# OS
|
|
97
|
+
Thumbs.db
|
|
98
|
+
|
|
99
|
+
# Temporary files
|
|
100
|
+
*.tmp
|
|
101
|
+
*.temp
|
|
102
|
+
*.bak
|
|
103
|
+
.ruff_cache
|
|
104
|
+
.logs
|
|
105
|
+
ctx/
|
|
106
|
+
|
|
107
|
+
# Local-only docs — internal moat/strategy/pricing notes, PDFs, recordings.
|
|
108
|
+
# Populated by scripts/mirror-local-docs.sh; never meant to leave this machine.
|
|
109
|
+
docs-local/
|
|
110
|
+
|
|
111
|
+
# DAN / vision / network material — kept locally, not in the public repo
|
|
112
|
+
INCEPTION.md
|
|
113
|
+
VISION.md
|
|
114
|
+
docs/dev_docs/dan/
|
|
115
|
+
docs/dev_docs/EmergeOS-DAN.pdf
|
|
116
|
+
node/src/emerge_node/gossip.py
|
|
117
|
+
node/src/emerge_node/cli.py
|
|
118
|
+
node/tests/test_gossip_spike.py
|
|
119
|
+
.cdv-runs/
|
|
120
|
+
.wrangler/
|
|
121
|
+
kimi files/
|
|
122
|
+
kimi-export-*.md
|
|
123
|
+
.superpowers/
|
|
124
|
+
|
|
125
|
+
# Editor/harness rule files and local font cache — machine-local, never the repo's
|
|
126
|
+
.cursor/
|
|
127
|
+
.fonts/
|
|
128
|
+
|
|
129
|
+
# Landing site lives in solvent-labs-org/orcha-landing; local checkout kept here for convenience.
|
|
130
|
+
deploy/landing-site/
|
orcha_sdk-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: orcha-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Orcha agent SDK + CLI — register an agent in three lines.
|
|
5
|
+
Project-URL: Homepage, https://github.com/solvent-labs-org/metaorcha
|
|
6
|
+
Project-URL: Repository, https://github.com/solvent-labs-org/metaorcha
|
|
7
|
+
Project-URL: Issues, https://github.com/solvent-labs-org/metaorcha/issues
|
|
8
|
+
Author: Orcha
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: a2a,ai-agents,emerge,mcp,orcha,orchestration
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: pyyaml>=6.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# orcha-sdk
|
|
25
|
+
|
|
26
|
+
The developer SDK + `emerge` CLI for [Orcha](https://github.com/solvent-labs-org/metaorcha) —
|
|
27
|
+
an orchestration runtime that plans, routes, and executes one goal across agents
|
|
28
|
+
speaking MCP, A2A, and COMPUTER_USE (ACP is accepted as an A2A-routed alias).
|
|
29
|
+
|
|
30
|
+
Register an agent in three lines:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import emerge
|
|
34
|
+
|
|
35
|
+
@emerge.agent(name="My Agent", description="What I do")
|
|
36
|
+
def handle(task: str) -> str:
|
|
37
|
+
return f"handled: {task}"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then serve it:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
orcha-sdk run # serve locally + register against the local registry
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
No clone, no venv setup — just run it:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uvx orcha-sdk init my-agent
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or install it into a project:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install orcha-sdk
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
(Import name is `emerge`; distribution name is `orcha-sdk`.)
|
|
61
|
+
|
|
62
|
+
## CLI
|
|
63
|
+
|
|
64
|
+
| Command | What it does |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `orcha-sdk init "My Agent"` | scaffold a new agent from a template |
|
|
67
|
+
| `orcha-sdk run [module]` | serve decorated agents locally + register them against `http://localhost:8000` |
|
|
68
|
+
| `orcha-sdk publish [module] --registry <url>` | register against a remote registry |
|
|
69
|
+
|
|
70
|
+
`orcha-sdk run --no-register` serves without registering. Set `ORCHA_REGISTRY_URL`
|
|
71
|
+
and `ORCHA_PAT` to point at and authenticate against a non-local registry.
|
|
72
|
+
|
|
73
|
+
## How it works
|
|
74
|
+
|
|
75
|
+
The decorator records your handler and declared skills. `orcha-sdk run` serves an
|
|
76
|
+
A2A-compatible HTTP endpoint (`/health`, `/.well-known/agent.json`, JSON-RPC
|
|
77
|
+
`message/send` / `tasks/get`) using only the standard library, and uploads a
|
|
78
|
+
generated `emerge.yaml` to the registry. The runtime's planner can then discover
|
|
79
|
+
and orchestrate your agent alongside agents that speak other protocols.
|
|
80
|
+
|
|
81
|
+
Apache 2.0 runtime, MIT SDK. See the [main repo](https://github.com/solvent-labs-org/metaorcha).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# orcha-sdk
|
|
2
|
+
|
|
3
|
+
The developer SDK + `emerge` CLI for [Orcha](https://github.com/solvent-labs-org/metaorcha) —
|
|
4
|
+
an orchestration runtime that plans, routes, and executes one goal across agents
|
|
5
|
+
speaking MCP, A2A, and COMPUTER_USE (ACP is accepted as an A2A-routed alias).
|
|
6
|
+
|
|
7
|
+
Register an agent in three lines:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import emerge
|
|
11
|
+
|
|
12
|
+
@emerge.agent(name="My Agent", description="What I do")
|
|
13
|
+
def handle(task: str) -> str:
|
|
14
|
+
return f"handled: {task}"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then serve it:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
orcha-sdk run # serve locally + register against the local registry
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
No clone, no venv setup — just run it:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uvx orcha-sdk init my-agent
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install it into a project:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install orcha-sdk
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
(Import name is `emerge`; distribution name is `orcha-sdk`.)
|
|
38
|
+
|
|
39
|
+
## CLI
|
|
40
|
+
|
|
41
|
+
| Command | What it does |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `orcha-sdk init "My Agent"` | scaffold a new agent from a template |
|
|
44
|
+
| `orcha-sdk run [module]` | serve decorated agents locally + register them against `http://localhost:8000` |
|
|
45
|
+
| `orcha-sdk publish [module] --registry <url>` | register against a remote registry |
|
|
46
|
+
|
|
47
|
+
`orcha-sdk run --no-register` serves without registering. Set `ORCHA_REGISTRY_URL`
|
|
48
|
+
and `ORCHA_PAT` to point at and authenticate against a non-local registry.
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
The decorator records your handler and declared skills. `orcha-sdk run` serves an
|
|
53
|
+
A2A-compatible HTTP endpoint (`/health`, `/.well-known/agent.json`, JSON-RPC
|
|
54
|
+
`message/send` / `tasks/get`) using only the standard library, and uploads a
|
|
55
|
+
generated `emerge.yaml` to the registry. The runtime's planner can then discover
|
|
56
|
+
and orchestrate your agent alongside agents that speak other protocols.
|
|
57
|
+
|
|
58
|
+
Apache 2.0 runtime, MIT SDK. See the [main repo](https://github.com/solvent-labs-org/metaorcha).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "orcha-sdk"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Orcha agent SDK + CLI — register an agent in three lines."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Orcha" }]
|
|
9
|
+
keywords = ["ai-agents", "mcp", "a2a", "orchestration", "orcha", "emerge"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pyyaml>=6.0",
|
|
12
|
+
]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
"Topic :: System :: Distributed Computing",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/solvent-labs-org/metaorcha"
|
|
27
|
+
Repository = "https://github.com/solvent-labs-org/metaorcha"
|
|
28
|
+
Issues = "https://github.com/solvent-labs-org/metaorcha/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
# `uvx <name>` resolves the *distribution* name, so the primary console script
|
|
32
|
+
# must match it for `uvx orcha-sdk init` to work. `emerge` stays as an alias.
|
|
33
|
+
orcha-sdk = "emerge.cli:main"
|
|
34
|
+
emerge = "emerge.cli:main"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/emerge"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Orcha agent SDK — register an agent in three lines.
|
|
2
|
+
|
|
3
|
+
import emerge
|
|
4
|
+
|
|
5
|
+
@emerge.agent(name="My Agent", description="What I do")
|
|
6
|
+
def handle(task: str) -> str:
|
|
7
|
+
return f"handled: {task}"
|
|
8
|
+
|
|
9
|
+
if __name__ == "__main__":
|
|
10
|
+
emerge.run()
|
|
11
|
+
|
|
12
|
+
Or use the CLI: ``emerge run`` / ``emerge publish``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from .manifest import build_manifest, manifest_yaml
|
|
20
|
+
from .sdk import AgentSpec, Skill, agent, registered_agents
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.1"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"agent",
|
|
26
|
+
"Skill",
|
|
27
|
+
"AgentSpec",
|
|
28
|
+
"registered_agents",
|
|
29
|
+
"build_manifest",
|
|
30
|
+
"manifest_yaml",
|
|
31
|
+
"run",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run() -> int:
|
|
37
|
+
"""Serve every decorated agent in this process (convenience for ``__main__``).
|
|
38
|
+
|
|
39
|
+
Agents are already registered via import, so this just serves them — it does
|
|
40
|
+
not register against a registry. Use the ``emerge run`` CLI for registration.
|
|
41
|
+
"""
|
|
42
|
+
from .server import serve_agent
|
|
43
|
+
|
|
44
|
+
agents = registered_agents()
|
|
45
|
+
if not agents:
|
|
46
|
+
print(
|
|
47
|
+
"emerge.run(): no @emerge.agent registered in this module.", file=sys.stderr
|
|
48
|
+
)
|
|
49
|
+
return 1
|
|
50
|
+
for spec in agents[:-1]:
|
|
51
|
+
serve_agent(spec, block=False)
|
|
52
|
+
print(f"✓ Serving {spec.name} on http://localhost:{spec.port} ({spec.did})")
|
|
53
|
+
last = agents[-1]
|
|
54
|
+
print(f"✓ Serving {last.name} on http://localhost:{last.port} ({last.did})")
|
|
55
|
+
print("Press Ctrl+C to stop.")
|
|
56
|
+
try:
|
|
57
|
+
serve_agent(last, block=True)
|
|
58
|
+
except KeyboardInterrupt:
|
|
59
|
+
print("\nStopped.")
|
|
60
|
+
return 0
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Orcha agent CLI — the launch-scope developer surface.
|
|
2
|
+
|
|
3
|
+
Installed as ``orcha-sdk`` (and ``emerge``, kept as an alias). Four commands
|
|
4
|
+
only (resist scope creep — test/deploy/login are post-launch):
|
|
5
|
+
|
|
6
|
+
- ``orcha-sdk init [name]`` scaffold a new agent from the bundled template
|
|
7
|
+
- ``orcha-sdk run [module]`` serve decorated agents locally + register them
|
|
8
|
+
- ``orcha-sdk publish [module]`` register decorated agents against a remote registry
|
|
9
|
+
- ``orcha-sdk validate`` validator demo (``--once`` synthetic attestation)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
from .client import DEFAULT_REGISTRY_URL, RegistryError, register
|
|
26
|
+
from .manifest import manifest_yaml
|
|
27
|
+
from .sdk import AgentSpec, clear_registry, registered_agents
|
|
28
|
+
from .server import serve_agent
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("emerge")
|
|
31
|
+
|
|
32
|
+
_TEMPLATE_DIR = Path(__file__).parent / "templates" / "your-first-agent"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _dan_experimental_enabled() -> bool:
|
|
36
|
+
"""True when ORCHA_DAN_EXPERIMENTAL=1 (experimental network opt-in)."""
|
|
37
|
+
return os.getenv("ORCHA_DAN_EXPERIMENTAL", "").lower() in ("1", "true", "yes")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _require_dan_experimental(feature: str) -> bool:
|
|
41
|
+
if _dan_experimental_enabled():
|
|
42
|
+
return True
|
|
43
|
+
print(
|
|
44
|
+
f"emerge: {feature} requires experimental network mode.\n"
|
|
45
|
+
" Set ORCHA_DAN_EXPERIMENTAL=1 or network.experimental: true in emerge.yaml.",
|
|
46
|
+
file=sys.stderr,
|
|
47
|
+
)
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_module(module_path: str) -> None:
|
|
52
|
+
"""Import a Python file by path so its @emerge.agent decorators register."""
|
|
53
|
+
path = Path(module_path).resolve()
|
|
54
|
+
if not path.exists():
|
|
55
|
+
sys.exit(f"emerge: no such file: {module_path}")
|
|
56
|
+
sys.path.insert(0, str(path.parent))
|
|
57
|
+
spec = importlib.util.spec_from_file_location(path.stem, path)
|
|
58
|
+
if spec is None or spec.loader is None:
|
|
59
|
+
sys.exit(f"emerge: cannot import {module_path}")
|
|
60
|
+
module = importlib.util.module_from_spec(spec)
|
|
61
|
+
sys.modules[path.stem] = module
|
|
62
|
+
spec.loader.exec_module(module)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _discover(module_path: str) -> list[AgentSpec]:
|
|
66
|
+
clear_registry()
|
|
67
|
+
_load_module(module_path)
|
|
68
|
+
agents = registered_agents()
|
|
69
|
+
if not agents:
|
|
70
|
+
sys.exit(
|
|
71
|
+
f"emerge: no @emerge.agent found in {module_path}. "
|
|
72
|
+
"Did you decorate a handler?"
|
|
73
|
+
)
|
|
74
|
+
return agents
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _default_module() -> str:
|
|
78
|
+
for candidate in ("agent.py", "main.py"):
|
|
79
|
+
if Path(candidate).exists():
|
|
80
|
+
return candidate
|
|
81
|
+
sys.exit(
|
|
82
|
+
"emerge: no agent.py/main.py here. Pass a module path or run `emerge init`."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
87
|
+
name = args.name
|
|
88
|
+
slug = name.lower().replace(" ", "-")
|
|
89
|
+
dest = Path(args.dir or slug)
|
|
90
|
+
if dest.exists() and any(dest.iterdir()):
|
|
91
|
+
sys.exit(f"emerge: {dest} already exists and is not empty.")
|
|
92
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
for src in _TEMPLATE_DIR.rglob("*"):
|
|
94
|
+
rel = src.relative_to(_TEMPLATE_DIR)
|
|
95
|
+
target = dest / rel
|
|
96
|
+
if src.is_dir():
|
|
97
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
continue
|
|
99
|
+
text = src.read_text(encoding="utf-8")
|
|
100
|
+
text = text.replace("{{AGENT_NAME}}", name).replace("{{AGENT_SLUG}}", slug)
|
|
101
|
+
target.write_text(text, encoding="utf-8")
|
|
102
|
+
print(f"✓ Scaffolded '{name}' in {dest}/")
|
|
103
|
+
print(" Next:")
|
|
104
|
+
print(f" cd {dest}")
|
|
105
|
+
print(" orcha-sdk run # serve locally; registers if a registry is up")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
110
|
+
module = args.module or _default_module()
|
|
111
|
+
agents = _discover(module)
|
|
112
|
+
registry_url = args.registry or os.getenv(
|
|
113
|
+
"ORCHA_REGISTRY_URL", DEFAULT_REGISTRY_URL
|
|
114
|
+
)
|
|
115
|
+
token = os.getenv("ORCHA_PAT")
|
|
116
|
+
|
|
117
|
+
# Every agent serves in a background thread first: the registry harvests
|
|
118
|
+
# capabilities from the live endpoint, so it has to be reachable before
|
|
119
|
+
# register() is called. The main thread parks afterwards — serving a port
|
|
120
|
+
# that is already bound raises OSError(EADDRINUSE).
|
|
121
|
+
for spec in agents:
|
|
122
|
+
serve_agent(spec, block=False)
|
|
123
|
+
print(f"✓ Serving {spec.name} on http://localhost:{spec.port} ({spec.did})")
|
|
124
|
+
if args.register:
|
|
125
|
+
try:
|
|
126
|
+
resp = register(
|
|
127
|
+
manifest_yaml(spec), registry_url=registry_url, token=token
|
|
128
|
+
)
|
|
129
|
+
print(
|
|
130
|
+
f" ✓ Registered with {registry_url}"
|
|
131
|
+
+ (
|
|
132
|
+
f" (agent_id={resp.get('agent_id')})"
|
|
133
|
+
if resp.get("agent_id")
|
|
134
|
+
else ""
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
except RegistryError as exc:
|
|
138
|
+
print(f" ⚠ Registration skipped: {exc}", file=sys.stderr)
|
|
139
|
+
print(
|
|
140
|
+
" The agent is serving locally regardless. To register it, "
|
|
141
|
+
"start the runtime (./scripts/run-all.sh) or pass --no-register "
|
|
142
|
+
"to silence this.",
|
|
143
|
+
file=sys.stderr,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
print("\nPress Ctrl+C to stop.")
|
|
147
|
+
try:
|
|
148
|
+
threading.Event().wait()
|
|
149
|
+
except KeyboardInterrupt:
|
|
150
|
+
print("\nStopped.")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_publish(args: argparse.Namespace) -> int:
|
|
155
|
+
if getattr(args, "network", None) and not _require_dan_experimental(
|
|
156
|
+
"emerge publish --network"
|
|
157
|
+
):
|
|
158
|
+
return 1
|
|
159
|
+
module = args.module or _default_module()
|
|
160
|
+
agents = _discover(module)
|
|
161
|
+
registry_url = args.registry or os.getenv("ORCHA_REGISTRY_URL")
|
|
162
|
+
if not registry_url:
|
|
163
|
+
sys.exit("emerge publish: pass --registry <url> or set ORCHA_REGISTRY_URL.")
|
|
164
|
+
token = args.token or os.getenv("ORCHA_PAT")
|
|
165
|
+
host = args.host
|
|
166
|
+
failures = 0
|
|
167
|
+
for spec in agents:
|
|
168
|
+
try:
|
|
169
|
+
resp = register(
|
|
170
|
+
manifest_yaml(spec, host=host), registry_url=registry_url, token=token
|
|
171
|
+
)
|
|
172
|
+
print(
|
|
173
|
+
f"✓ Published {spec.name} → {registry_url}"
|
|
174
|
+
+ (
|
|
175
|
+
f" (agent_id={resp.get('agent_id')})"
|
|
176
|
+
if resp.get("agent_id")
|
|
177
|
+
else ""
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
except RegistryError as exc:
|
|
181
|
+
failures += 1
|
|
182
|
+
print(f"✗ {spec.name}: {exc}", file=sys.stderr)
|
|
183
|
+
return 1 if failures else 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _synthetic_attestation(*, validator_did: str) -> dict:
|
|
187
|
+
"""Build a demo attestation for `emerge validate --once`."""
|
|
188
|
+
success = True
|
|
189
|
+
content = "ok"
|
|
190
|
+
score = 0.85 if success and content and not content.startswith("Error:") else 0.2
|
|
191
|
+
return {
|
|
192
|
+
"schema_version": "1.0",
|
|
193
|
+
"call_id": "call-validate-demo",
|
|
194
|
+
"agent_id": "did:orcha:agent:demo",
|
|
195
|
+
"validator_did": validator_did,
|
|
196
|
+
"success": success,
|
|
197
|
+
"latency_ms": 42,
|
|
198
|
+
"judge_score": score,
|
|
199
|
+
"notes": "spike-heuristic (--once demo)",
|
|
200
|
+
"observed_at": datetime.now(UTC).isoformat(),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
205
|
+
validator_did = args.validator_did or os.getenv(
|
|
206
|
+
"VALIDATOR_DID", "did:orcha:validator:local"
|
|
207
|
+
)
|
|
208
|
+
if args.once:
|
|
209
|
+
attestation = _synthetic_attestation(validator_did=validator_did)
|
|
210
|
+
print(json.dumps(attestation, indent=2))
|
|
211
|
+
print(
|
|
212
|
+
f"\n✓ Demo attestation for validator {validator_did} "
|
|
213
|
+
"(subscribe to execution.step_complete for live traffic)"
|
|
214
|
+
)
|
|
215
|
+
return 0
|
|
216
|
+
if not _require_dan_experimental("emerge validate (live mode)"):
|
|
217
|
+
return 1
|
|
218
|
+
if args.kafka:
|
|
219
|
+
print(
|
|
220
|
+
"emerge validate: Kafka consumer is not wired yet.\n"
|
|
221
|
+
" Use --once for a local demo, or run a validator process against "
|
|
222
|
+
"execution.step_complete when KAFKA_ENABLED=true.",
|
|
223
|
+
file=sys.stderr,
|
|
224
|
+
)
|
|
225
|
+
return 1
|
|
226
|
+
print(
|
|
227
|
+
"emerge validate: pass --once for a demo attestation, or --kafka <brokers> "
|
|
228
|
+
"(consumer TBD).",
|
|
229
|
+
file=sys.stderr,
|
|
230
|
+
)
|
|
231
|
+
return 1
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
235
|
+
p = argparse.ArgumentParser(description="Orcha agent developer CLI")
|
|
236
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
237
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
238
|
+
|
|
239
|
+
pi = sub.add_parser("init", help="scaffold a new agent from a template")
|
|
240
|
+
pi.add_argument("name", help="agent name, e.g. 'My Agent'")
|
|
241
|
+
pi.add_argument("--dir", help="target directory (default: slug of name)")
|
|
242
|
+
pi.set_defaults(func=cmd_init)
|
|
243
|
+
|
|
244
|
+
pr = sub.add_parser(
|
|
245
|
+
"run", help="serve agents locally + register against local registry"
|
|
246
|
+
)
|
|
247
|
+
pr.add_argument(
|
|
248
|
+
"module", nargs="?", help="path to agent module (default: agent.py/main.py)"
|
|
249
|
+
)
|
|
250
|
+
pr.add_argument(
|
|
251
|
+
"--registry", help=f"registry URL (default: {DEFAULT_REGISTRY_URL})"
|
|
252
|
+
)
|
|
253
|
+
pr.add_argument(
|
|
254
|
+
"--no-register",
|
|
255
|
+
dest="register",
|
|
256
|
+
action="store_false",
|
|
257
|
+
help="serve only; do not register",
|
|
258
|
+
)
|
|
259
|
+
pr.set_defaults(func=cmd_run, register=True)
|
|
260
|
+
|
|
261
|
+
pp = sub.add_parser("publish", help="register agents against a remote registry")
|
|
262
|
+
pp.add_argument(
|
|
263
|
+
"module", nargs="?", help="path to agent module (default: agent.py/main.py)"
|
|
264
|
+
)
|
|
265
|
+
pp.add_argument("--registry", help="remote registry URL (or ORCHA_REGISTRY_URL)")
|
|
266
|
+
pp.add_argument("--token", help="PAT token (or ORCHA_PAT)")
|
|
267
|
+
pp.add_argument(
|
|
268
|
+
"--host",
|
|
269
|
+
default="localhost",
|
|
270
|
+
help="host advertised in the manifest endpoint (default: localhost)",
|
|
271
|
+
)
|
|
272
|
+
pp.add_argument(
|
|
273
|
+
"--network",
|
|
274
|
+
help="network bootstrap peer for publish (experimental)",
|
|
275
|
+
)
|
|
276
|
+
pp.set_defaults(func=cmd_publish)
|
|
277
|
+
|
|
278
|
+
pv = sub.add_parser(
|
|
279
|
+
"validate",
|
|
280
|
+
help="run a validator observer node (experimental — --once demo)",
|
|
281
|
+
)
|
|
282
|
+
pv.add_argument(
|
|
283
|
+
"--validator-did",
|
|
284
|
+
help="validator DID (default: did:orcha:validator:local or VALIDATOR_DID)",
|
|
285
|
+
)
|
|
286
|
+
pv.add_argument(
|
|
287
|
+
"--bootstrap",
|
|
288
|
+
help="network bootstrap peer (reserved)",
|
|
289
|
+
)
|
|
290
|
+
pv.add_argument(
|
|
291
|
+
"--kafka",
|
|
292
|
+
help="Kafka bootstrap servers for execution.step_complete (consumer TBD)",
|
|
293
|
+
)
|
|
294
|
+
pv.add_argument(
|
|
295
|
+
"--once",
|
|
296
|
+
action="store_true",
|
|
297
|
+
help="emit one synthetic attestation and exit (local demo)",
|
|
298
|
+
)
|
|
299
|
+
pv.set_defaults(func=cmd_validate)
|
|
300
|
+
|
|
301
|
+
return p
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def main(argv: list[str] | None = None) -> int:
|
|
305
|
+
logging.basicConfig(level=os.getenv("EMERGE_LOG_LEVEL", "INFO"))
|
|
306
|
+
args = build_parser().parse_args(argv)
|
|
307
|
+
return args.func(args)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Minimal registry client — register an agent's emerge.yaml.
|
|
2
|
+
|
|
3
|
+
Uses only the standard library (urllib) so the SDK stays dependency-light.
|
|
4
|
+
Registration mirrors ``POST /api/v1/agents/register`` (multipart upload of the
|
|
5
|
+
``emerge.yaml`` file, optional ``Authorization: Bearer <PAT>``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("emerge.client")
|
|
17
|
+
|
|
18
|
+
DEFAULT_REGISTRY_URL = "http://localhost:8000"
|
|
19
|
+
_REGISTER_PATH = "/api/v1/agents/register"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RegistryError(RuntimeError):
|
|
23
|
+
"""Raised when registration fails."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _multipart_body(yaml_text: str, *, field: str = "emerge_yaml") -> tuple[bytes, str]:
|
|
27
|
+
boundary = f"----emerge{uuid.uuid4().hex}"
|
|
28
|
+
parts = [
|
|
29
|
+
f"--{boundary}",
|
|
30
|
+
f'Content-Disposition: form-data; name="{field}"; filename="emerge.yaml"',
|
|
31
|
+
"Content-Type: application/x-yaml",
|
|
32
|
+
"",
|
|
33
|
+
yaml_text,
|
|
34
|
+
f"--{boundary}--",
|
|
35
|
+
"",
|
|
36
|
+
]
|
|
37
|
+
body = "\r\n".join(parts).encode("utf-8")
|
|
38
|
+
return body, boundary
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def register(
|
|
42
|
+
yaml_text: str,
|
|
43
|
+
*,
|
|
44
|
+
registry_url: str = DEFAULT_REGISTRY_URL,
|
|
45
|
+
token: str | None = None,
|
|
46
|
+
timeout: float = 30.0,
|
|
47
|
+
) -> dict:
|
|
48
|
+
"""Register an agent manifest against a registry. Returns the JSON response.
|
|
49
|
+
|
|
50
|
+
Raises :class:`RegistryError` on non-2xx responses or transport errors.
|
|
51
|
+
"""
|
|
52
|
+
url = registry_url.rstrip("/") + _REGISTER_PATH
|
|
53
|
+
body, boundary = _multipart_body(yaml_text)
|
|
54
|
+
req = urllib.request.Request(url, data=body, method="POST") # noqa: S310 - http(s) only
|
|
55
|
+
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
|
56
|
+
if token:
|
|
57
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
58
|
+
try:
|
|
59
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
|
60
|
+
payload = resp.read().decode("utf-8")
|
|
61
|
+
return json.loads(payload) if payload else {}
|
|
62
|
+
except urllib.error.HTTPError as exc:
|
|
63
|
+
detail = exc.read().decode("utf-8", "replace")
|
|
64
|
+
raise RegistryError(
|
|
65
|
+
f"Registry returned {exc.code} for {url}: {detail}"
|
|
66
|
+
) from exc
|
|
67
|
+
except urllib.error.URLError as exc:
|
|
68
|
+
raise RegistryError(
|
|
69
|
+
f"Could not reach registry at {registry_url} — is the local stack up? "
|
|
70
|
+
f"({exc.reason})"
|
|
71
|
+
) from exc
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Generate an ``emerge.yaml`` manifest from an :class:`AgentSpec`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .sdk import AgentSpec
|
|
8
|
+
|
|
9
|
+
SCHEMA_VERSION = "1.0"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_manifest(spec: AgentSpec, *, host: str = "localhost") -> dict[str, Any]:
|
|
13
|
+
"""Build the manifest dict the registry validates against emerge.yaml schema."""
|
|
14
|
+
endpoint = f"http://{host}:{spec.port}"
|
|
15
|
+
manifest: dict[str, Any] = {
|
|
16
|
+
"schema_version": SCHEMA_VERSION,
|
|
17
|
+
"identity": {
|
|
18
|
+
"id": spec.did,
|
|
19
|
+
"name": spec.name,
|
|
20
|
+
"version": spec.version,
|
|
21
|
+
"description": spec.description,
|
|
22
|
+
"tags": spec.tags,
|
|
23
|
+
},
|
|
24
|
+
"protocol": {
|
|
25
|
+
"type": "a2a",
|
|
26
|
+
"version": "1.0",
|
|
27
|
+
"transport": {"type": "http", "endpoint": endpoint},
|
|
28
|
+
},
|
|
29
|
+
"health_endpoint": f"{endpoint}/health",
|
|
30
|
+
"security": {
|
|
31
|
+
"transport_layer": {"type": "none"},
|
|
32
|
+
"auth_strategies": [],
|
|
33
|
+
},
|
|
34
|
+
"skills": [
|
|
35
|
+
{
|
|
36
|
+
"id": s.id,
|
|
37
|
+
"name": s.name,
|
|
38
|
+
"description": s.description,
|
|
39
|
+
"tags": s.tags,
|
|
40
|
+
"examples": s.examples,
|
|
41
|
+
}
|
|
42
|
+
for s in spec.skills
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
if spec.base_fee is not None:
|
|
46
|
+
manifest["payment"] = {"enabled": True, "base_fee": spec.base_fee}
|
|
47
|
+
return manifest
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def manifest_yaml(spec: AgentSpec, *, host: str = "localhost") -> str:
|
|
51
|
+
"""Serialize the manifest to YAML text."""
|
|
52
|
+
import yaml
|
|
53
|
+
|
|
54
|
+
return yaml.safe_dump(
|
|
55
|
+
build_manifest(spec, host=host), sort_keys=False, default_flow_style=False
|
|
56
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The ``@emerge.agent`` decorator and the in-process agent registry.
|
|
2
|
+
|
|
3
|
+
Register an agent in three lines::
|
|
4
|
+
|
|
5
|
+
import emerge
|
|
6
|
+
|
|
7
|
+
@emerge.agent(name="My Agent", description="What I do")
|
|
8
|
+
def handle(task: str) -> str:
|
|
9
|
+
return f"handled: {task}"
|
|
10
|
+
|
|
11
|
+
Then ``emerge run`` (CLI) — or ``emerge.run()`` in ``__main__`` — serves it over
|
|
12
|
+
an A2A-compatible HTTP endpoint and registers it against a local registry.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import inspect
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
Handler = Callable[[str], Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _slugify(name: str) -> str:
|
|
27
|
+
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
|
28
|
+
return slug or "agent"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Skill:
|
|
33
|
+
"""A declared capability, surfaced on the agent card and in the manifest."""
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
name: str = ""
|
|
37
|
+
description: str = ""
|
|
38
|
+
tags: list[str] = field(default_factory=list)
|
|
39
|
+
examples: list[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if not self.name:
|
|
43
|
+
self.name = self.id.replace("-", " ").replace("_", " ").title()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class AgentSpec:
|
|
48
|
+
"""Everything the runtime needs to serve, describe, and register an agent."""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
description: str
|
|
52
|
+
handler: Handler
|
|
53
|
+
did: str
|
|
54
|
+
version: str = "0.1.0"
|
|
55
|
+
port: int = 8900
|
|
56
|
+
tags: list[str] = field(default_factory=list)
|
|
57
|
+
skills: list[Skill] = field(default_factory=list)
|
|
58
|
+
base_fee: str | None = None
|
|
59
|
+
|
|
60
|
+
async def invoke(self, task: str) -> str:
|
|
61
|
+
"""Call the handler, awaiting it if it is a coroutine. Returns text."""
|
|
62
|
+
result = self.handler(task)
|
|
63
|
+
if inspect.isawaitable(result):
|
|
64
|
+
result = await result
|
|
65
|
+
return result if isinstance(result, str) else str(result)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Process-wide registry of decorated agents, in declaration order.
|
|
69
|
+
_AGENTS: list[AgentSpec] = []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def agent(
|
|
73
|
+
_fn: Handler | None = None,
|
|
74
|
+
*,
|
|
75
|
+
name: str,
|
|
76
|
+
description: str,
|
|
77
|
+
did: str | None = None,
|
|
78
|
+
version: str = "0.1.0",
|
|
79
|
+
port: int = 8900,
|
|
80
|
+
tags: list[str] | None = None,
|
|
81
|
+
skills: list[Skill | dict[str, Any]] | None = None,
|
|
82
|
+
base_fee: str | None = None,
|
|
83
|
+
) -> Callable[[Handler], Handler] | Handler:
|
|
84
|
+
"""Register ``fn`` as an Orcha agent. Returns ``fn`` unchanged.
|
|
85
|
+
|
|
86
|
+
``did`` defaults to ``did:orcha:agent:<slug-of-name>``. If no ``skills`` are
|
|
87
|
+
given, a single skill is derived from the agent name so the agent card is
|
|
88
|
+
never empty (the registry harvests skills from the card at registration).
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def decorate(fn: Handler) -> Handler:
|
|
92
|
+
slug = _slugify(name)
|
|
93
|
+
resolved_did = did or f"did:orcha:agent:{slug}"
|
|
94
|
+
resolved_skills: list[Skill] = []
|
|
95
|
+
for s in skills or []:
|
|
96
|
+
resolved_skills.append(s if isinstance(s, Skill) else Skill(**s))
|
|
97
|
+
if not resolved_skills:
|
|
98
|
+
resolved_skills = [
|
|
99
|
+
Skill(id=slug, name=name, description=description, tags=tags or [])
|
|
100
|
+
]
|
|
101
|
+
_AGENTS.append(
|
|
102
|
+
AgentSpec(
|
|
103
|
+
name=name,
|
|
104
|
+
description=description,
|
|
105
|
+
handler=fn,
|
|
106
|
+
did=resolved_did,
|
|
107
|
+
version=version,
|
|
108
|
+
port=port,
|
|
109
|
+
tags=tags or [],
|
|
110
|
+
skills=resolved_skills,
|
|
111
|
+
base_fee=base_fee,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return fn
|
|
115
|
+
|
|
116
|
+
# Support both @agent(...) and bare @agent usage.
|
|
117
|
+
if _fn is not None:
|
|
118
|
+
return decorate(_fn)
|
|
119
|
+
return decorate
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def registered_agents() -> list[AgentSpec]:
|
|
123
|
+
"""Return all agents registered via the decorator, in declaration order."""
|
|
124
|
+
return list(_AGENTS)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def clear_registry() -> None:
|
|
128
|
+
"""Test helper — drop all registered agents."""
|
|
129
|
+
_AGENTS.clear()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""A dependency-light A2A-compatible HTTP server for SDK agents.
|
|
2
|
+
|
|
3
|
+
Implements the same wire contract the Orcha SuperAgent speaks to A2A agents,
|
|
4
|
+
using only the standard library so ``emerge run`` has no heavy dependencies:
|
|
5
|
+
|
|
6
|
+
- ``GET /health`` → liveness probe
|
|
7
|
+
- ``GET /.well-known/agent.json`` → A2A agent card (registry harvests skills)
|
|
8
|
+
- ``POST /`` → JSON-RPC 2.0: ``message/send`` + ``tasks/get``
|
|
9
|
+
|
|
10
|
+
Tasks complete synchronously, so ``message/send`` returns a completed task and
|
|
11
|
+
``tasks/get`` reads it back from a small in-memory store.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
import uuid
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .sdk import AgentSpec
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("emerge.server")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def agent_card(spec: AgentSpec, host: str = "localhost") -> dict[str, Any]:
|
|
31
|
+
"""Build the A2A agent card served at /.well-known/agent.json."""
|
|
32
|
+
return {
|
|
33
|
+
"schemaVersion": "1.0",
|
|
34
|
+
"name": spec.name,
|
|
35
|
+
"description": spec.description,
|
|
36
|
+
"url": f"http://{host}:{spec.port}",
|
|
37
|
+
"version": spec.version,
|
|
38
|
+
"capabilities": {"streaming": False, "pushNotifications": False},
|
|
39
|
+
"skills": [
|
|
40
|
+
{
|
|
41
|
+
"id": s.id,
|
|
42
|
+
"name": s.name,
|
|
43
|
+
"description": s.description,
|
|
44
|
+
"tags": s.tags,
|
|
45
|
+
"examples": s.examples,
|
|
46
|
+
}
|
|
47
|
+
for s in spec.skills
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _extract_text(message: dict[str, Any]) -> str:
|
|
53
|
+
parts = message.get("parts", []) or []
|
|
54
|
+
texts = [
|
|
55
|
+
p.get("text", "")
|
|
56
|
+
for p in parts
|
|
57
|
+
if p.get("kind") == "text" or p.get("type") == "text"
|
|
58
|
+
]
|
|
59
|
+
return " ".join(texts).strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _completed_task(task_id: str, answer: str) -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"id": task_id,
|
|
65
|
+
"status": {"state": "completed"},
|
|
66
|
+
"artifacts": [{"parts": [{"kind": "text", "text": answer}]}],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _failed_task(task_id: str, error: str) -> dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"id": task_id,
|
|
73
|
+
"status": {
|
|
74
|
+
"state": "failed",
|
|
75
|
+
"message": {
|
|
76
|
+
"role": "agent",
|
|
77
|
+
"parts": [{"kind": "text", "text": f"Error: {error}"}],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _make_handler(spec: AgentSpec, host: str):
|
|
84
|
+
card = agent_card(spec, host=host)
|
|
85
|
+
task_store: dict[str, dict[str, Any]] = {}
|
|
86
|
+
|
|
87
|
+
class Handler(BaseHTTPRequestHandler):
|
|
88
|
+
protocol_version = "HTTP/1.1"
|
|
89
|
+
|
|
90
|
+
def log_message(self, fmt: str, *args: Any) -> None: # quieter logs
|
|
91
|
+
logger.debug("%s - %s", self.address_string(), fmt % args)
|
|
92
|
+
|
|
93
|
+
def _send_json(self, payload: dict[str, Any], status: int = 200) -> None:
|
|
94
|
+
body = json.dumps(payload).encode("utf-8")
|
|
95
|
+
self.send_response(status)
|
|
96
|
+
self.send_header("Content-Type", "application/json")
|
|
97
|
+
self.send_header("Content-Length", str(len(body)))
|
|
98
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
99
|
+
self.end_headers()
|
|
100
|
+
self.wfile.write(body)
|
|
101
|
+
|
|
102
|
+
def do_GET(self) -> None: # noqa: N802
|
|
103
|
+
if self.path == "/health":
|
|
104
|
+
self._send_json(
|
|
105
|
+
{"status": "healthy", "agent": spec.name, "version": spec.version}
|
|
106
|
+
)
|
|
107
|
+
elif self.path == "/.well-known/agent.json":
|
|
108
|
+
self._send_json(card)
|
|
109
|
+
else:
|
|
110
|
+
self._send_json({"error": "not found"}, status=404)
|
|
111
|
+
|
|
112
|
+
def do_POST(self) -> None: # noqa: N802
|
|
113
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
114
|
+
try:
|
|
115
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
116
|
+
except json.JSONDecodeError:
|
|
117
|
+
self._send_json(
|
|
118
|
+
{
|
|
119
|
+
"jsonrpc": "2.0",
|
|
120
|
+
"id": "",
|
|
121
|
+
"error": {"code": -32700, "message": "Parse error"},
|
|
122
|
+
},
|
|
123
|
+
status=400,
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
rpc_id = body.get("id", "")
|
|
128
|
+
method = body.get("method", "")
|
|
129
|
+
params = body.get("params", {}) or {}
|
|
130
|
+
|
|
131
|
+
if method == "message/send":
|
|
132
|
+
message = params.get("message", {}) or {}
|
|
133
|
+
task_id = (
|
|
134
|
+
params.get("taskId") or message.get("taskId") or str(uuid.uuid4())
|
|
135
|
+
)
|
|
136
|
+
query = _extract_text(message)
|
|
137
|
+
try:
|
|
138
|
+
answer = asyncio.run(spec.invoke(query))
|
|
139
|
+
task = _completed_task(task_id, answer)
|
|
140
|
+
except Exception as exc: # noqa: BLE001 - surface as failed task
|
|
141
|
+
logger.exception("Handler raised for task %s", task_id)
|
|
142
|
+
task = _failed_task(task_id, str(exc))
|
|
143
|
+
task_store[task_id] = task
|
|
144
|
+
self._send_json({"jsonrpc": "2.0", "id": rpc_id, "result": task})
|
|
145
|
+
elif method == "tasks/get":
|
|
146
|
+
task_id = params.get("id", "")
|
|
147
|
+
task = task_store.get(task_id)
|
|
148
|
+
if task is None:
|
|
149
|
+
self._send_json(
|
|
150
|
+
{
|
|
151
|
+
"jsonrpc": "2.0",
|
|
152
|
+
"id": rpc_id,
|
|
153
|
+
"error": {
|
|
154
|
+
"code": -32602,
|
|
155
|
+
"message": f"Task {task_id!r} not found",
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
self._send_json({"jsonrpc": "2.0", "id": rpc_id, "result": task})
|
|
161
|
+
else:
|
|
162
|
+
self._send_json(
|
|
163
|
+
{
|
|
164
|
+
"jsonrpc": "2.0",
|
|
165
|
+
"id": rpc_id,
|
|
166
|
+
"error": {
|
|
167
|
+
"code": -32601,
|
|
168
|
+
"message": f"Method not found: {method!r}",
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return Handler
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def serve_agent(
|
|
177
|
+
spec: AgentSpec, host: str = "0.0.0.0", *, block: bool = True
|
|
178
|
+
) -> ThreadingHTTPServer:
|
|
179
|
+
"""Start an HTTP server for a single agent. Returns the server.
|
|
180
|
+
|
|
181
|
+
When ``block`` is False the server runs in a daemon thread (useful for tests
|
|
182
|
+
and multi-agent serving); otherwise this call blocks via ``serve_forever``.
|
|
183
|
+
"""
|
|
184
|
+
card_host = os.getenv("EMERGE_ADVERTISE_HOST", "localhost")
|
|
185
|
+
httpd = ThreadingHTTPServer((host, spec.port), _make_handler(spec, card_host))
|
|
186
|
+
if block:
|
|
187
|
+
httpd.serve_forever()
|
|
188
|
+
else:
|
|
189
|
+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
190
|
+
return httpd
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# {{AGENT_NAME}}
|
|
2
|
+
|
|
3
|
+
Your first Orcha agent, scaffolded with `orcha-sdk init`.
|
|
4
|
+
|
|
5
|
+
## Run it
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install orcha-sdk # if you haven't already
|
|
9
|
+
orcha-sdk run # serve + register against http://localhost:8000
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or serve without registering:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
orcha-sdk run --no-register
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## What's here
|
|
19
|
+
|
|
20
|
+
- `agent.py` — the agent. A decorated `handle(task)` function is the whole thing.
|
|
21
|
+
- `requirements.txt` — just `orcha-sdk`.
|
|
22
|
+
|
|
23
|
+
## Next steps
|
|
24
|
+
|
|
25
|
+
1. Edit `handle()` in `agent.py` with your real logic.
|
|
26
|
+
2. Update the `description` and `skills` — the planner uses them to route to you.
|
|
27
|
+
3. `orcha-sdk publish --registry <url>` to register against a remote registry.
|
|
28
|
+
|
|
29
|
+
DID: `did:orcha:agent:{{AGENT_SLUG}}` · Manifest: generated from the decorator.
|
|
30
|
+
See the [bridges guide](https://metaorcha.ai/docs)
|
|
31
|
+
to connect a whole other protocol.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""{{AGENT_NAME}} — your first Orcha agent.
|
|
2
|
+
|
|
3
|
+
An agent is just a function that takes a natural-language `task` string and
|
|
4
|
+
returns a result string. The `@emerge.agent` decorator does the rest:
|
|
5
|
+
|
|
6
|
+
- gives the agent a DID (did:orcha:agent:{{AGENT_SLUG}})
|
|
7
|
+
- generates the emerge.yaml manifest the registry validates
|
|
8
|
+
- serves it over an A2A-compatible HTTP endpoint when you run it
|
|
9
|
+
|
|
10
|
+
Try it:
|
|
11
|
+
|
|
12
|
+
orcha-sdk run # serve + register against the local registry
|
|
13
|
+
orcha-sdk run --no-register # serve only
|
|
14
|
+
|
|
15
|
+
Then ask the orchestrator to do something this agent can handle, or call it
|
|
16
|
+
directly:
|
|
17
|
+
|
|
18
|
+
curl -s localhost:8900/.well-known/agent.json
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import emerge
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@emerge.agent(
|
|
27
|
+
name="{{AGENT_NAME}}",
|
|
28
|
+
description="Describe in one sentence what this agent does — the planner reads this to decide when to route to you.",
|
|
29
|
+
version="0.1.0",
|
|
30
|
+
port=8900,
|
|
31
|
+
tags=["example"],
|
|
32
|
+
# Declare the things your agent can do. Skills are surfaced on the agent
|
|
33
|
+
# card and harvested by the registry. Good descriptions + examples make the
|
|
34
|
+
# planner route to you correctly.
|
|
35
|
+
skills=[
|
|
36
|
+
{
|
|
37
|
+
"id": "echo",
|
|
38
|
+
"name": "Echo",
|
|
39
|
+
"description": "Echo the task back. Replace this with your real capability.",
|
|
40
|
+
"examples": ["say hello", "repeat after me: orcha"],
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
# Uncomment to charge per invocation (mock mode by default — no wallet needed):
|
|
44
|
+
# base_fee="0.05",
|
|
45
|
+
)
|
|
46
|
+
def handle(task: str) -> str:
|
|
47
|
+
"""Handle one task. Replace the body with your real logic.
|
|
48
|
+
|
|
49
|
+
The handler may be sync or async (`async def handle(...)`).
|
|
50
|
+
"""
|
|
51
|
+
return f"{{AGENT_NAME}} received: {task}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
emerge.run()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
orcha-sdk>=0.1.0
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""End-to-end-ish tests for the emerge SDK: decorator → manifest → A2A server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import emerge
|
|
11
|
+
from emerge.manifest import build_manifest
|
|
12
|
+
from emerge.sdk import clear_registry, registered_agents
|
|
13
|
+
from emerge.server import agent_card, serve_agent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def _clean_registry():
|
|
18
|
+
clear_registry()
|
|
19
|
+
yield
|
|
20
|
+
clear_registry()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_decorator_registers_agent_with_default_did():
|
|
24
|
+
@emerge.agent(name="My Agent", description="does things")
|
|
25
|
+
def handle(task: str) -> str:
|
|
26
|
+
return task
|
|
27
|
+
|
|
28
|
+
agents = registered_agents()
|
|
29
|
+
assert len(agents) == 1
|
|
30
|
+
spec = agents[0]
|
|
31
|
+
assert spec.did == "did:orcha:agent:my-agent"
|
|
32
|
+
assert spec.name == "My Agent"
|
|
33
|
+
# No explicit skills → one derived skill so the agent card is never empty.
|
|
34
|
+
assert spec.skills and spec.skills[0].id == "my-agent"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_decorator_returns_callable_unchanged():
|
|
38
|
+
@emerge.agent(name="X", description="d")
|
|
39
|
+
def handle(task: str) -> str:
|
|
40
|
+
return "ok:" + task
|
|
41
|
+
|
|
42
|
+
assert handle("hi") == "ok:hi"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_manifest_shape_matches_registry_schema():
|
|
46
|
+
@emerge.agent(name="Priced", description="d", base_fee="0.10", port=8901)
|
|
47
|
+
def handle(task: str) -> str:
|
|
48
|
+
return task
|
|
49
|
+
|
|
50
|
+
m = build_manifest(registered_agents()[0])
|
|
51
|
+
assert m["schema_version"] == "1.0"
|
|
52
|
+
assert m["identity"]["id"] == "did:orcha:agent:priced"
|
|
53
|
+
assert m["protocol"]["type"] == "a2a"
|
|
54
|
+
assert m["protocol"]["transport"]["endpoint"] == "http://localhost:8901"
|
|
55
|
+
assert m["health_endpoint"] == "http://localhost:8901/health"
|
|
56
|
+
assert m["payment"] == {"enabled": True, "base_fee": "0.10"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_agent_card_is_valid():
|
|
60
|
+
@emerge.agent(name="Carded", description="d", port=8902)
|
|
61
|
+
def handle(task: str) -> str:
|
|
62
|
+
return task
|
|
63
|
+
|
|
64
|
+
card = agent_card(registered_agents()[0])
|
|
65
|
+
assert card["name"] == "Carded"
|
|
66
|
+
assert card["skills"]
|
|
67
|
+
assert card["url"] == "http://localhost:8902"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _post_jsonrpc(port: int, method: str, params: dict) -> dict:
|
|
71
|
+
body = json.dumps({"jsonrpc": "2.0", "id": "1", "method": method, "params": params}).encode()
|
|
72
|
+
req = urllib.request.Request(
|
|
73
|
+
f"http://127.0.0.1:{port}/", data=body,
|
|
74
|
+
headers={"Content-Type": "application/json"}, method="POST",
|
|
75
|
+
)
|
|
76
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
77
|
+
return json.loads(resp.read())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get(port: int, path: str) -> dict:
|
|
81
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=5) as resp:
|
|
82
|
+
return json.loads(resp.read())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_server_serves_health_card_and_executes_task():
|
|
86
|
+
port = 8943
|
|
87
|
+
|
|
88
|
+
@emerge.agent(name="Live Agent", description="echoes", port=port)
|
|
89
|
+
def handle(task: str) -> str:
|
|
90
|
+
return f"echo: {task}"
|
|
91
|
+
|
|
92
|
+
httpd = serve_agent(registered_agents()[0], host="127.0.0.1", block=False)
|
|
93
|
+
try:
|
|
94
|
+
assert _get(port, "/health")["status"] == "healthy"
|
|
95
|
+
assert _get(port, "/.well-known/agent.json")["name"] == "Live Agent"
|
|
96
|
+
|
|
97
|
+
result = _post_jsonrpc(port, "message/send", {
|
|
98
|
+
"message": {"parts": [{"kind": "text", "text": "hello"}]},
|
|
99
|
+
})["result"]
|
|
100
|
+
assert result["status"]["state"] == "completed"
|
|
101
|
+
answer = result["artifacts"][0]["parts"][0]["text"]
|
|
102
|
+
assert answer == "echo: hello"
|
|
103
|
+
|
|
104
|
+
# tasks/get reads the same task back.
|
|
105
|
+
fetched = _post_jsonrpc(port, "tasks/get", {"id": result["id"]})["result"]
|
|
106
|
+
assert fetched["id"] == result["id"]
|
|
107
|
+
finally:
|
|
108
|
+
httpd.shutdown()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_async_handler_supported():
|
|
112
|
+
port = 8944
|
|
113
|
+
|
|
114
|
+
@emerge.agent(name="Async Agent", description="async", port=port)
|
|
115
|
+
async def handle(task: str) -> str:
|
|
116
|
+
return f"async: {task}"
|
|
117
|
+
|
|
118
|
+
httpd = serve_agent(registered_agents()[0], host="127.0.0.1", block=False)
|
|
119
|
+
try:
|
|
120
|
+
result = _post_jsonrpc(port, "message/send", {
|
|
121
|
+
"message": {"parts": [{"type": "text", "text": "x"}]},
|
|
122
|
+
})["result"]
|
|
123
|
+
assert result["artifacts"][0]["parts"][0]["text"] == "async: x"
|
|
124
|
+
finally:
|
|
125
|
+
httpd.shutdown()
|