ara-sdk 0.1.0__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.
- ara_sdk-0.1.0/.github/workflows/ci.yml +28 -0
- ara_sdk-0.1.0/.gitignore +14 -0
- ara_sdk-0.1.0/LICENSE +27 -0
- ara_sdk-0.1.0/PKG-INFO +89 -0
- ara_sdk-0.1.0/README.md +67 -0
- ara_sdk-0.1.0/examples/calcom-booking/README.md +24 -0
- ara_sdk-0.1.0/examples/calcom-booking/app.py +31 -0
- ara_sdk-0.1.0/pyproject.toml +40 -0
- ara_sdk-0.1.0/src/ara_sdk/__init__.py +27 -0
- ara_sdk-0.1.0/src/ara_sdk/__main__.py +40 -0
- ara_sdk-0.1.0/src/ara_sdk/core.py +837 -0
- ara_sdk-0.1.0/tests/test_manifest.py +87 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["**"]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- name: Install package and test dependencies
|
|
20
|
+
run: |
|
|
21
|
+
python -m pip install --upgrade pip
|
|
22
|
+
python -m pip install -e . pytest
|
|
23
|
+
- name: Run tests
|
|
24
|
+
run: pytest -q
|
|
25
|
+
- name: Build package
|
|
26
|
+
run: |
|
|
27
|
+
python -m pip install build
|
|
28
|
+
python -m build
|
ara_sdk-0.1.0/.gitignore
ADDED
ara_sdk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Ara SDK Proprietary License
|
|
2
|
+
Copyright (c) 2026 Ara. All rights reserved.
|
|
3
|
+
|
|
4
|
+
This software and all associated source code, binaries, APIs, documentation,
|
|
5
|
+
and examples (collectively, the "Software") are proprietary and confidential.
|
|
6
|
+
|
|
7
|
+
No rights are granted except by an explicit written commercial agreement
|
|
8
|
+
signed by Ara.
|
|
9
|
+
|
|
10
|
+
Without Ara's prior written permission, you may NOT:
|
|
11
|
+
- copy, modify, or create derivative works of the Software;
|
|
12
|
+
- distribute, sublicense, sell, lease, lend, transfer, or make the Software
|
|
13
|
+
available to any third party;
|
|
14
|
+
- reverse engineer, decompile, disassemble, or otherwise attempt to derive
|
|
15
|
+
source code from any non-source distribution;
|
|
16
|
+
- remove or alter any proprietary notices;
|
|
17
|
+
- use the Software to build, benchmark, or train a competing product.
|
|
18
|
+
|
|
19
|
+
Any unauthorized use is strictly prohibited and may result in immediate
|
|
20
|
+
termination of access, legal action, and/or damages.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
24
|
+
FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, OR TITLE. TO THE MAXIMUM EXTENT
|
|
25
|
+
PERMITTED BY LAW, ARA SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
|
|
26
|
+
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF DATA, PROFITS,
|
|
27
|
+
REVENUE, OR BUSINESS INTERRUPTION, ARISING OUT OF OR RELATED TO THE SOFTWARE.
|
ara_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ara-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Public Python SDK for building and running Ara apps.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Aradotso/ara-python-sdk
|
|
6
|
+
Project-URL: Documentation, https://docs.ara.so/sdk/overview
|
|
7
|
+
Project-URL: Issues, https://github.com/Aradotso/ara-python-sdk/issues
|
|
8
|
+
Author-email: Ara <support@ara.so>
|
|
9
|
+
License: Proprietary
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,ai,ara,sdk
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: Other/Proprietary License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Ara Python SDK
|
|
24
|
+
|
|
25
|
+
Public Python SDK for building Ara apps with a decorator-first workflow.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install ara-sdk
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Principles
|
|
34
|
+
|
|
35
|
+
- Public SDK is generic and provider-agnostic.
|
|
36
|
+
- Runtime policy, retries, and safety controls are enforced server-side.
|
|
37
|
+
- Optional integrations (Cal.com, CRM, etc.) live in examples, not in the core package.
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from ara_sdk import App, cron, run_cli, sandbox
|
|
43
|
+
|
|
44
|
+
app = App("Investor Meeting Booker", project_name="investor-meeting-booking")
|
|
45
|
+
|
|
46
|
+
@app.subagent(handoff_to=["calendar-strategist"], sandbox=sandbox())
|
|
47
|
+
def booking_coordinator(event=None):
|
|
48
|
+
"""Coordinate scheduling requests."""
|
|
49
|
+
|
|
50
|
+
@app.hook(id="daily-followups", event="scheduler.followups", schedule=cron("0 13 * * 1-5"))
|
|
51
|
+
def daily_followups():
|
|
52
|
+
"""Send pending followups."""
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
run_cli(app)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
export ARA_API_BASE_URL="https://api.ara.so"
|
|
60
|
+
export ARA_ACCESS_TOKEN="your_user_jwt"
|
|
61
|
+
|
|
62
|
+
python app.py deploy
|
|
63
|
+
python app.py run --workflow booking-coordinator --message "Need 3 slots next week"
|
|
64
|
+
python app.py events --event-type channel.web.inbound --channel web --message "hello"
|
|
65
|
+
python app.py setup
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Environment
|
|
69
|
+
|
|
70
|
+
- `ARA_API_BASE_URL`: Ara API base URL
|
|
71
|
+
- `ARA_ACCESS_TOKEN`: user JWT for control plane
|
|
72
|
+
- `ARA_RUNTIME_KEY`: optional runtime key override for run/events (otherwise `.runtime-key.local` is used)
|
|
73
|
+
|
|
74
|
+
## Examples
|
|
75
|
+
|
|
76
|
+
See `examples/` for optional integrations and demo projects:
|
|
77
|
+
|
|
78
|
+
- `examples/calcom-booking/`
|
|
79
|
+
|
|
80
|
+
## Security
|
|
81
|
+
|
|
82
|
+
- Never commit API keys, runtime keys, or provider secrets.
|
|
83
|
+
- Keep provider-specific credentials in environment variables.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
This repository is source-available under a strict proprietary license.
|
|
88
|
+
Unauthorized copying, redistribution, or derivative works are prohibited.
|
|
89
|
+
See `LICENSE` for full terms.
|
ara_sdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Ara Python SDK
|
|
2
|
+
|
|
3
|
+
Public Python SDK for building Ara apps with a decorator-first workflow.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ara-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Principles
|
|
12
|
+
|
|
13
|
+
- Public SDK is generic and provider-agnostic.
|
|
14
|
+
- Runtime policy, retries, and safety controls are enforced server-side.
|
|
15
|
+
- Optional integrations (Cal.com, CRM, etc.) live in examples, not in the core package.
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from ara_sdk import App, cron, run_cli, sandbox
|
|
21
|
+
|
|
22
|
+
app = App("Investor Meeting Booker", project_name="investor-meeting-booking")
|
|
23
|
+
|
|
24
|
+
@app.subagent(handoff_to=["calendar-strategist"], sandbox=sandbox())
|
|
25
|
+
def booking_coordinator(event=None):
|
|
26
|
+
"""Coordinate scheduling requests."""
|
|
27
|
+
|
|
28
|
+
@app.hook(id="daily-followups", event="scheduler.followups", schedule=cron("0 13 * * 1-5"))
|
|
29
|
+
def daily_followups():
|
|
30
|
+
"""Send pending followups."""
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
run_cli(app)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export ARA_API_BASE_URL="https://api.ara.so"
|
|
38
|
+
export ARA_ACCESS_TOKEN="your_user_jwt"
|
|
39
|
+
|
|
40
|
+
python app.py deploy
|
|
41
|
+
python app.py run --workflow booking-coordinator --message "Need 3 slots next week"
|
|
42
|
+
python app.py events --event-type channel.web.inbound --channel web --message "hello"
|
|
43
|
+
python app.py setup
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Environment
|
|
47
|
+
|
|
48
|
+
- `ARA_API_BASE_URL`: Ara API base URL
|
|
49
|
+
- `ARA_ACCESS_TOKEN`: user JWT for control plane
|
|
50
|
+
- `ARA_RUNTIME_KEY`: optional runtime key override for run/events (otherwise `.runtime-key.local` is used)
|
|
51
|
+
|
|
52
|
+
## Examples
|
|
53
|
+
|
|
54
|
+
See `examples/` for optional integrations and demo projects:
|
|
55
|
+
|
|
56
|
+
- `examples/calcom-booking/`
|
|
57
|
+
|
|
58
|
+
## Security
|
|
59
|
+
|
|
60
|
+
- Never commit API keys, runtime keys, or provider secrets.
|
|
61
|
+
- Keep provider-specific credentials in environment variables.
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
This repository is source-available under a strict proprietary license.
|
|
66
|
+
Unauthorized copying, redistribution, or derivative works are prohibited.
|
|
67
|
+
See `LICENSE` for full terms.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Cal.com Booking Example
|
|
2
|
+
|
|
3
|
+
This folder is an optional integration example built on top of the public `ara-sdk`.
|
|
4
|
+
|
|
5
|
+
It is intentionally outside the core package so the SDK stays provider-agnostic.
|
|
6
|
+
|
|
7
|
+
## What this demonstrates
|
|
8
|
+
|
|
9
|
+
- Turning inbound chat messages into booking intents
|
|
10
|
+
- Looking up next-week availability from Cal.com
|
|
11
|
+
- Optionally creating bookings for selected slots
|
|
12
|
+
- Forwarding enriched context to Ara app event ingress
|
|
13
|
+
|
|
14
|
+
## Required environment variables
|
|
15
|
+
|
|
16
|
+
- `CALCOM_API_KEY`
|
|
17
|
+
- `CALCOM_EVENT_TYPE_ID` or `CALCOM_EVENT_TYPE_SLUG`
|
|
18
|
+
- `ARA_API_BASE_URL`
|
|
19
|
+
- `ARA_ACCESS_TOKEN`
|
|
20
|
+
|
|
21
|
+
## Security
|
|
22
|
+
|
|
23
|
+
- Never hardcode API keys in code.
|
|
24
|
+
- Keep `.env` files out of version control.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from ara_sdk import App, cron, run_cli, sandbox
|
|
2
|
+
|
|
3
|
+
app = App(
|
|
4
|
+
"Meeting Booker",
|
|
5
|
+
project_name="meeting-booker",
|
|
6
|
+
description="Optional Cal.com-backed meeting booking flow.",
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.subagent(
|
|
11
|
+
id="booking-coordinator",
|
|
12
|
+
workflow_id="booking-coordinator",
|
|
13
|
+
handoff_to=["calendar-strategist"],
|
|
14
|
+
sandbox=sandbox(max_concurrency=3),
|
|
15
|
+
)
|
|
16
|
+
def booking_coordinator(event=None):
|
|
17
|
+
"""Coordinate scheduling and booking actions."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.hook(
|
|
21
|
+
id="daily-followups",
|
|
22
|
+
event="scheduler.followups",
|
|
23
|
+
schedule=cron("0 13 * * 1-5"),
|
|
24
|
+
agent="booking-coordinator",
|
|
25
|
+
)
|
|
26
|
+
def daily_followups():
|
|
27
|
+
"""Send reminders for pending confirmations."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
run_cli(app)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ara-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Public Python SDK for building and running Ara apps."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Ara", email = "support@ara.so" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["ara", "ai", "agents", "sdk"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: Other/Proprietary License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules"
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/Aradotso/ara-python-sdk"
|
|
30
|
+
Documentation = "https://docs.ara.so/sdk/overview"
|
|
31
|
+
Issues = "https://github.com/Aradotso/ara-python-sdk/issues"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
ara-sdk = "ara_sdk.__main__:main"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/ara_sdk"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Public Ara Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .core import (
|
|
4
|
+
App,
|
|
5
|
+
AraClient,
|
|
6
|
+
cron,
|
|
7
|
+
entrypoint,
|
|
8
|
+
file,
|
|
9
|
+
local_file,
|
|
10
|
+
runtime,
|
|
11
|
+
run_cli,
|
|
12
|
+
sandbox,
|
|
13
|
+
subagent_hook,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"App",
|
|
18
|
+
"AraClient",
|
|
19
|
+
"cron",
|
|
20
|
+
"entrypoint",
|
|
21
|
+
"file",
|
|
22
|
+
"local_file",
|
|
23
|
+
"runtime",
|
|
24
|
+
"run_cli",
|
|
25
|
+
"sandbox",
|
|
26
|
+
"subagent_hook",
|
|
27
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
|
|
8
|
+
from .core import App, run_cli
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_module(path: pathlib.Path) -> ModuleType:
|
|
12
|
+
spec = importlib.util.spec_from_file_location("ara_user_app", str(path))
|
|
13
|
+
if spec is None or spec.loader is None:
|
|
14
|
+
raise RuntimeError(f"Could not load module from {path}")
|
|
15
|
+
module = importlib.util.module_from_spec(spec)
|
|
16
|
+
spec.loader.exec_module(module)
|
|
17
|
+
return module
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _discover_app(module: ModuleType) -> App:
|
|
21
|
+
for _, value in vars(module).items():
|
|
22
|
+
if isinstance(value, App):
|
|
23
|
+
return value
|
|
24
|
+
raise RuntimeError("No App(...) instance found in script")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> None:
|
|
28
|
+
if len(sys.argv) < 3:
|
|
29
|
+
raise SystemExit("Usage: ara-sdk <command> <app_script.py> [args...]")
|
|
30
|
+
command = sys.argv[1]
|
|
31
|
+
script = pathlib.Path(sys.argv[2]).expanduser().resolve()
|
|
32
|
+
if not script.exists():
|
|
33
|
+
raise SystemExit(f"Script not found: {script}")
|
|
34
|
+
module = _load_module(script)
|
|
35
|
+
app = _discover_app(module)
|
|
36
|
+
run_cli(app, argv=[command, *sys.argv[3:]], default_command=command)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
"""Public Ara Python SDK core (provider-agnostic)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Callable, Optional
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
DEFAULT_SUBAGENT_MAX_CONCURRENCY = 4
|
|
17
|
+
DEFAULT_TIMEOUT_SECONDS = 120
|
|
18
|
+
DEFAULT_MAX_RETRIES = 2
|
|
19
|
+
DEFAULT_RETRY_BACKOFF_SECONDS = 5
|
|
20
|
+
DEBUG_HTTP_ERRORS_ENV = "ARA_SDK_DEBUG_HTTP_ERRORS"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _slugify(value: str) -> str:
|
|
24
|
+
out = []
|
|
25
|
+
prev_dash = False
|
|
26
|
+
for ch in str(value or "").strip().lower():
|
|
27
|
+
if ch.isalnum():
|
|
28
|
+
out.append(ch)
|
|
29
|
+
prev_dash = False
|
|
30
|
+
continue
|
|
31
|
+
if not prev_dash:
|
|
32
|
+
out.append("-")
|
|
33
|
+
prev_dash = True
|
|
34
|
+
slug = "".join(out).strip("-")
|
|
35
|
+
return slug[:120]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _new_run_id() -> str:
|
|
39
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
40
|
+
return f"run-{ts}-{uuid4().hex[:8]}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _env_flag_enabled(key: str) -> bool:
|
|
44
|
+
return str(os.getenv(key, "")).strip().lower() in {"1", "true", "yes", "on"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def file(path: str, content: str, *, executable: bool = False) -> dict[str, Any]:
|
|
48
|
+
path_value = str(path or "").strip()
|
|
49
|
+
if not path_value:
|
|
50
|
+
raise ValueError("file() requires a non-empty path")
|
|
51
|
+
return {"path": path_value, "content": str(content or ""), "executable": bool(executable)}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def local_file(
|
|
55
|
+
source: str | pathlib.Path,
|
|
56
|
+
path: Optional[str] = None,
|
|
57
|
+
*,
|
|
58
|
+
executable: bool = True,
|
|
59
|
+
encoding: str = "utf-8",
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
src = pathlib.Path(source)
|
|
62
|
+
if not src.exists() or not src.is_file():
|
|
63
|
+
raise ValueError(f"local_file() source not found: {src}")
|
|
64
|
+
target = str(path or src.name).strip()
|
|
65
|
+
if not target:
|
|
66
|
+
raise ValueError("local_file() requires a non-empty target path")
|
|
67
|
+
return file(target, src.read_text(encoding=encoding), executable=executable)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def entrypoint(command: str, *, shell: str = "bash", args: Optional[list[str]] = None) -> dict[str, Any]:
|
|
71
|
+
cmd = str(command or "").strip()
|
|
72
|
+
if not cmd:
|
|
73
|
+
raise ValueError("entrypoint() requires a non-empty command")
|
|
74
|
+
return {
|
|
75
|
+
"entrypoint": cmd,
|
|
76
|
+
"shell": str(shell or "bash").strip() or "bash",
|
|
77
|
+
"args": [str(a).strip() for a in (args or []) if str(a).strip()],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def runtime(
|
|
82
|
+
*,
|
|
83
|
+
files: Optional[list[dict[str, Any]]] = None,
|
|
84
|
+
startup: Optional[dict[str, Any]] = None,
|
|
85
|
+
image: Optional[str] = None,
|
|
86
|
+
memory_mb: Optional[int] = None,
|
|
87
|
+
volume_size_mb: Optional[int] = None,
|
|
88
|
+
python_packages: Optional[list[str]] = None,
|
|
89
|
+
node_packages: Optional[list[str]] = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
profile: dict[str, Any] = {}
|
|
92
|
+
if files:
|
|
93
|
+
profile["files"] = [dict(item) for item in files]
|
|
94
|
+
if startup:
|
|
95
|
+
profile["startup"] = dict(startup)
|
|
96
|
+
if image:
|
|
97
|
+
profile["image"] = str(image).strip()
|
|
98
|
+
if memory_mb is not None:
|
|
99
|
+
profile["memory_mb"] = int(memory_mb)
|
|
100
|
+
if volume_size_mb is not None:
|
|
101
|
+
profile["volume_size_mb"] = int(volume_size_mb)
|
|
102
|
+
if python_packages:
|
|
103
|
+
profile["python_packages"] = [str(pkg).strip() for pkg in python_packages if str(pkg).strip()]
|
|
104
|
+
if node_packages:
|
|
105
|
+
profile["node_packages"] = [str(pkg).strip() for pkg in node_packages if str(pkg).strip()]
|
|
106
|
+
return profile
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cron(expression: str, *, timezone: str = "UTC") -> dict[str, Any]:
|
|
110
|
+
expr = str(expression or "").strip()
|
|
111
|
+
if not expr:
|
|
112
|
+
raise ValueError("cron() requires a non-empty expression")
|
|
113
|
+
return {"type": "cron", "cron": expr, "schedule": expr, "timezone": str(timezone or "UTC")}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def sandbox(
|
|
117
|
+
*,
|
|
118
|
+
policy: str = "shared",
|
|
119
|
+
max_concurrency: Optional[int] = None,
|
|
120
|
+
idle_ttl_minutes: Optional[int] = None,
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
normalized_policy = str(policy or "shared").strip().lower()
|
|
123
|
+
if normalized_policy != "shared":
|
|
124
|
+
raise ValueError("Public SDK currently supports only sandbox(policy='shared').")
|
|
125
|
+
out: dict[str, Any] = {"policy": "shared"}
|
|
126
|
+
out["max_concurrency"] = max(1, int(max_concurrency or DEFAULT_SUBAGENT_MAX_CONCURRENCY))
|
|
127
|
+
if idle_ttl_minutes is not None:
|
|
128
|
+
out["idle_ttl_minutes"] = max(1, int(idle_ttl_minutes))
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def subagent_hook(
|
|
133
|
+
*,
|
|
134
|
+
event: str,
|
|
135
|
+
id: Optional[str] = None,
|
|
136
|
+
task: Optional[str] = None,
|
|
137
|
+
command: Optional[str] = None,
|
|
138
|
+
trigger: Optional[dict[str, Any]] = None,
|
|
139
|
+
schedule: Optional[dict[str, Any] | str] = None,
|
|
140
|
+
channel: str = "api",
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
evt = str(event or "").strip()
|
|
143
|
+
if not evt:
|
|
144
|
+
raise ValueError("subagent_hook() requires event")
|
|
145
|
+
if task and command:
|
|
146
|
+
raise ValueError("subagent_hook() accepts either task= or command=, not both")
|
|
147
|
+
hook_id = str(id or "").strip() or f"{_slugify(evt)}-hook"
|
|
148
|
+
out: dict[str, Any] = {"id": hook_id, "event": evt, "channel": str(channel or "api").strip() or "api"}
|
|
149
|
+
if task:
|
|
150
|
+
out["task"] = str(task).strip()
|
|
151
|
+
if command:
|
|
152
|
+
out["command"] = str(command).strip()
|
|
153
|
+
if trigger and isinstance(trigger, dict):
|
|
154
|
+
out["trigger"] = dict(trigger)
|
|
155
|
+
if schedule is not None:
|
|
156
|
+
out["schedule"] = schedule
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _normalize_trigger(
|
|
161
|
+
trigger: Optional[dict[str, Any]],
|
|
162
|
+
schedule: Optional[dict[str, Any] | str],
|
|
163
|
+
) -> tuple[dict[str, Any], str]:
|
|
164
|
+
trigger_cfg = dict(trigger) if isinstance(trigger, dict) else {}
|
|
165
|
+
schedule_expr = ""
|
|
166
|
+
if isinstance(schedule, dict):
|
|
167
|
+
schedule_expr = str(schedule.get("cron") or schedule.get("schedule") or "").strip()
|
|
168
|
+
trigger_cfg.setdefault("type", str(schedule.get("type") or "cron").strip() or "cron")
|
|
169
|
+
if schedule_expr:
|
|
170
|
+
trigger_cfg.setdefault("cron", schedule_expr)
|
|
171
|
+
trigger_cfg.setdefault("schedule", schedule_expr)
|
|
172
|
+
if schedule.get("timezone"):
|
|
173
|
+
trigger_cfg.setdefault("timezone", str(schedule.get("timezone")))
|
|
174
|
+
elif isinstance(schedule, str):
|
|
175
|
+
schedule_expr = schedule.strip()
|
|
176
|
+
if schedule_expr:
|
|
177
|
+
trigger_cfg.setdefault("type", "cron")
|
|
178
|
+
trigger_cfg.setdefault("cron", schedule_expr)
|
|
179
|
+
trigger_cfg.setdefault("schedule", schedule_expr)
|
|
180
|
+
else:
|
|
181
|
+
schedule_expr = str(trigger_cfg.get("cron") or trigger_cfg.get("schedule") or "").strip()
|
|
182
|
+
if not trigger_cfg:
|
|
183
|
+
trigger_cfg = {"type": "api"}
|
|
184
|
+
if "type" not in trigger_cfg:
|
|
185
|
+
trigger_cfg["type"] = "api"
|
|
186
|
+
return trigger_cfg, schedule_expr
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class App:
|
|
190
|
+
"""Public app declaration object."""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
name: str,
|
|
195
|
+
*,
|
|
196
|
+
slug: Optional[str] = None,
|
|
197
|
+
project_name: Optional[str] = None,
|
|
198
|
+
description: str = "",
|
|
199
|
+
interfaces: Optional[dict[str, Any]] = None,
|
|
200
|
+
runtime_profile: Optional[dict[str, Any]] = None,
|
|
201
|
+
agent: Optional[dict[str, Any]] = None,
|
|
202
|
+
):
|
|
203
|
+
self.name = str(name or "").strip()
|
|
204
|
+
self.project_name = str(project_name or "").strip()
|
|
205
|
+
source = self.project_name or slug or self.name
|
|
206
|
+
self.slug = _slugify(source)
|
|
207
|
+
if not self.name:
|
|
208
|
+
raise ValueError("App(name=...) requires a non-empty name")
|
|
209
|
+
if not self.slug:
|
|
210
|
+
raise ValueError("App(...) could not derive a slug")
|
|
211
|
+
self.description = str(description or "").strip()
|
|
212
|
+
self._agent = dict(agent or {})
|
|
213
|
+
self._interfaces = dict(interfaces or {})
|
|
214
|
+
self._runtime_profile = dict(runtime_profile or {})
|
|
215
|
+
self._workflows: list[dict[str, Any]] = []
|
|
216
|
+
self._profiles: list[dict[str, Any]] = []
|
|
217
|
+
self._subagents: list[dict[str, Any]] = []
|
|
218
|
+
self._local_entrypoint: Optional[Callable[..., Any]] = None
|
|
219
|
+
|
|
220
|
+
def _upsert(self, rows: list[dict[str, Any]], item: dict[str, Any], *, key: str = "id") -> None:
|
|
221
|
+
item_key = str(item.get(key) or "")
|
|
222
|
+
if not item_key:
|
|
223
|
+
return
|
|
224
|
+
for idx, existing in enumerate(rows):
|
|
225
|
+
if str(existing.get(key) or "") == item_key:
|
|
226
|
+
rows[idx] = item
|
|
227
|
+
return
|
|
228
|
+
rows.append(item)
|
|
229
|
+
|
|
230
|
+
def agent(
|
|
231
|
+
self,
|
|
232
|
+
id: Optional[str] = None,
|
|
233
|
+
*,
|
|
234
|
+
instructions: str = "",
|
|
235
|
+
handoff_to: Optional[list[str]] = None,
|
|
236
|
+
always_on: bool = True,
|
|
237
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
238
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
239
|
+
profile_id = str(id or _slugify(fn.__name__.replace("_", "-"))).strip()
|
|
240
|
+
if not profile_id:
|
|
241
|
+
raise ValueError("@app.agent requires a non-empty id")
|
|
242
|
+
text = str(instructions or fn.__doc__ or "").strip()
|
|
243
|
+
profile = {
|
|
244
|
+
"id": profile_id,
|
|
245
|
+
"instructions": text,
|
|
246
|
+
"persona": text,
|
|
247
|
+
"handoff_to": [str(x).strip() for x in (handoff_to or []) if str(x).strip()],
|
|
248
|
+
"always_on": bool(always_on),
|
|
249
|
+
}
|
|
250
|
+
self._upsert(self._profiles, profile)
|
|
251
|
+
setattr(fn, "__ara_agent_profile__", profile)
|
|
252
|
+
return fn
|
|
253
|
+
|
|
254
|
+
return decorator
|
|
255
|
+
|
|
256
|
+
def task(
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
id: Optional[str] = None,
|
|
260
|
+
agent: Optional[str] = None,
|
|
261
|
+
task: Optional[str] = None,
|
|
262
|
+
trigger: Optional[dict[str, Any]] = None,
|
|
263
|
+
schedule: Optional[dict[str, Any] | str] = None,
|
|
264
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
265
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
266
|
+
workflow_id = str(id or _slugify(fn.__name__.replace("_", "-"))).strip()
|
|
267
|
+
if not workflow_id:
|
|
268
|
+
raise ValueError("@app.task requires a non-empty id")
|
|
269
|
+
trigger_cfg, schedule_expr = _normalize_trigger(trigger, schedule)
|
|
270
|
+
item: dict[str, Any] = {
|
|
271
|
+
"id": workflow_id,
|
|
272
|
+
"mode": "task",
|
|
273
|
+
"task": str(task or fn.__doc__ or "").strip() or f"Execute workflow {workflow_id}",
|
|
274
|
+
"trigger": trigger_cfg,
|
|
275
|
+
"run": {},
|
|
276
|
+
"pipeline": [],
|
|
277
|
+
}
|
|
278
|
+
if agent:
|
|
279
|
+
item["agent_id"] = str(agent).strip()
|
|
280
|
+
if schedule_expr:
|
|
281
|
+
item["schedule"] = schedule_expr
|
|
282
|
+
self._upsert(self._workflows, item)
|
|
283
|
+
setattr(fn, "__ara_workflow__", item)
|
|
284
|
+
return fn
|
|
285
|
+
|
|
286
|
+
return decorator
|
|
287
|
+
|
|
288
|
+
def hook(
|
|
289
|
+
self,
|
|
290
|
+
*,
|
|
291
|
+
id: Optional[str] = None,
|
|
292
|
+
event: str = "hook.tick",
|
|
293
|
+
agent: Optional[str] = None,
|
|
294
|
+
task: Optional[str] = None,
|
|
295
|
+
command: Optional[str] = None,
|
|
296
|
+
trigger: Optional[dict[str, Any]] = None,
|
|
297
|
+
schedule: Optional[dict[str, Any] | str] = None,
|
|
298
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
299
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
300
|
+
workflow_id = str(id or _slugify(fn.__name__.replace("_", "-"))).strip()
|
|
301
|
+
if not workflow_id:
|
|
302
|
+
raise ValueError("@app.hook requires a non-empty id")
|
|
303
|
+
event_name = str(event or "hook.tick").strip() or "hook.tick"
|
|
304
|
+
trigger_cfg = dict(trigger or {})
|
|
305
|
+
trigger_cfg.setdefault("type", "api")
|
|
306
|
+
trigger_cfg.setdefault("event", event_name)
|
|
307
|
+
if command:
|
|
308
|
+
self._upsert(
|
|
309
|
+
self._workflows,
|
|
310
|
+
{
|
|
311
|
+
"id": workflow_id,
|
|
312
|
+
"mode": "run",
|
|
313
|
+
"task": "",
|
|
314
|
+
"run": {"command": str(command).strip()},
|
|
315
|
+
"pipeline": [],
|
|
316
|
+
"trigger": trigger_cfg,
|
|
317
|
+
"schedule": str(schedule or "").strip() if isinstance(schedule, str) else "",
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
self.task(
|
|
322
|
+
id=workflow_id,
|
|
323
|
+
agent=agent,
|
|
324
|
+
task=str(task or fn.__doc__ or "").strip() or f"Handle hook '{event_name}'",
|
|
325
|
+
trigger=trigger_cfg,
|
|
326
|
+
schedule=schedule,
|
|
327
|
+
)(fn)
|
|
328
|
+
setattr(fn, "__ara_hook__", {"id": workflow_id, "event": event_name})
|
|
329
|
+
return fn
|
|
330
|
+
|
|
331
|
+
return decorator
|
|
332
|
+
|
|
333
|
+
def subagent(
|
|
334
|
+
self,
|
|
335
|
+
id: Optional[str] = None,
|
|
336
|
+
*,
|
|
337
|
+
workflow_id: Optional[str] = None,
|
|
338
|
+
instructions: str = "",
|
|
339
|
+
handoff_to: Optional[list[str]] = None,
|
|
340
|
+
always_on: bool = True,
|
|
341
|
+
task: Optional[str] = None,
|
|
342
|
+
trigger: Optional[dict[str, Any]] = None,
|
|
343
|
+
schedule: Optional[dict[str, Any] | str] = None,
|
|
344
|
+
runtime: Optional[dict[str, Any]] = None,
|
|
345
|
+
sandbox: Optional[dict[str, Any]] = None,
|
|
346
|
+
channels: Optional[list[str]] = None,
|
|
347
|
+
hooks: Optional[list[dict[str, Any]]] = None,
|
|
348
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
349
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
350
|
+
profile_id = str(id or _slugify(fn.__name__.replace("_", "-"))).strip()
|
|
351
|
+
wf_id = str(workflow_id or profile_id).strip()
|
|
352
|
+
if not profile_id or not wf_id:
|
|
353
|
+
raise ValueError("@app.subagent requires non-empty id/workflow_id")
|
|
354
|
+
self.agent(
|
|
355
|
+
profile_id,
|
|
356
|
+
instructions=instructions,
|
|
357
|
+
handoff_to=handoff_to,
|
|
358
|
+
always_on=always_on,
|
|
359
|
+
)(fn)
|
|
360
|
+
self.task(
|
|
361
|
+
id=wf_id,
|
|
362
|
+
agent=profile_id,
|
|
363
|
+
task=str(task or fn.__doc__ or "").strip() or f"Execute subagent {profile_id}",
|
|
364
|
+
trigger=trigger,
|
|
365
|
+
schedule=schedule,
|
|
366
|
+
)(fn)
|
|
367
|
+
sub = {
|
|
368
|
+
"id": profile_id,
|
|
369
|
+
"workflow_id": wf_id,
|
|
370
|
+
"channels": sorted({str(c).strip().lower() for c in (channels or []) if str(c).strip()}),
|
|
371
|
+
"runtime": dict(runtime or {}),
|
|
372
|
+
"sandbox": dict(sandbox or {"policy": "shared", "max_concurrency": DEFAULT_SUBAGENT_MAX_CONCURRENCY}),
|
|
373
|
+
"hooks": [dict(h) for h in (hooks or []) if isinstance(h, dict)],
|
|
374
|
+
}
|
|
375
|
+
self._upsert(self._subagents, sub)
|
|
376
|
+
setattr(fn, "__ara_subagent__", sub)
|
|
377
|
+
return fn
|
|
378
|
+
|
|
379
|
+
return decorator
|
|
380
|
+
|
|
381
|
+
def local_entrypoint(self) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
382
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
383
|
+
self._local_entrypoint = fn
|
|
384
|
+
return fn
|
|
385
|
+
|
|
386
|
+
return decorator
|
|
387
|
+
|
|
388
|
+
def call_local_entrypoint(self, input_payload: dict[str, str]) -> Any:
|
|
389
|
+
if self._local_entrypoint is None:
|
|
390
|
+
raise RuntimeError("No @app.local_entrypoint() registered")
|
|
391
|
+
fn = self._local_entrypoint
|
|
392
|
+
params = list(inspect.signature(fn).parameters.values())
|
|
393
|
+
if not params:
|
|
394
|
+
return fn()
|
|
395
|
+
if len(params) == 1:
|
|
396
|
+
return fn(input_payload)
|
|
397
|
+
kwargs = {p.name: input_payload[p.name] for p in params if p.name in input_payload}
|
|
398
|
+
return fn(**kwargs)
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def manifest(self) -> dict[str, Any]:
|
|
402
|
+
agent = dict(self._agent)
|
|
403
|
+
if self._profiles:
|
|
404
|
+
agent["profiles"] = list(self._profiles)
|
|
405
|
+
agent.setdefault("default_profile_id", str(self._profiles[0].get("id") or "default"))
|
|
406
|
+
if self._subagents:
|
|
407
|
+
agent["subagents"] = list(self._subagents)
|
|
408
|
+
return {
|
|
409
|
+
"name": self.name,
|
|
410
|
+
"slug": self.slug,
|
|
411
|
+
"description": self.description,
|
|
412
|
+
"agent": agent,
|
|
413
|
+
"workflows": list(self._workflows),
|
|
414
|
+
"interfaces": dict(self._interfaces),
|
|
415
|
+
"runtime_profile": dict(self._runtime_profile),
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _read_dotenv(path: pathlib.Path) -> None:
|
|
420
|
+
if not path.exists():
|
|
421
|
+
return
|
|
422
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
423
|
+
line = raw.strip()
|
|
424
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
425
|
+
continue
|
|
426
|
+
key, value = line.split("=", 1)
|
|
427
|
+
key = key.strip()
|
|
428
|
+
value = value.strip().strip("'").strip('"')
|
|
429
|
+
if key and not os.getenv(key):
|
|
430
|
+
os.environ[key] = value
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _require_env(*keys: str) -> dict[str, str]:
|
|
434
|
+
out: dict[str, str] = {}
|
|
435
|
+
missing: list[str] = []
|
|
436
|
+
for key in keys:
|
|
437
|
+
value = os.getenv(key, "").strip()
|
|
438
|
+
if not value:
|
|
439
|
+
missing.append(key)
|
|
440
|
+
else:
|
|
441
|
+
out[key] = value
|
|
442
|
+
if missing:
|
|
443
|
+
raise RuntimeError(
|
|
444
|
+
"Missing required env vars: " + ", ".join(missing) + ". "
|
|
445
|
+
"Create .env or export variables before running this command."
|
|
446
|
+
)
|
|
447
|
+
return out
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class _Http:
|
|
451
|
+
def __init__(self, base_url: str, access_token: str):
|
|
452
|
+
self.base_url = base_url.rstrip("/")
|
|
453
|
+
self.access_token = access_token
|
|
454
|
+
|
|
455
|
+
def _request(
|
|
456
|
+
self,
|
|
457
|
+
path: str,
|
|
458
|
+
*,
|
|
459
|
+
method: str = "GET",
|
|
460
|
+
body: Optional[dict[str, Any]] = None,
|
|
461
|
+
headers: Optional[dict[str, str]] = None,
|
|
462
|
+
auth_header: Optional[str] = None,
|
|
463
|
+
) -> Any:
|
|
464
|
+
url = f"{self.base_url}{path}"
|
|
465
|
+
payload = None if body is None else json.dumps(body).encode("utf-8")
|
|
466
|
+
req_headers = {
|
|
467
|
+
"Content-Type": "application/json",
|
|
468
|
+
"Authorization": auth_header or f"Bearer {self.access_token}",
|
|
469
|
+
}
|
|
470
|
+
if headers:
|
|
471
|
+
req_headers.update(headers)
|
|
472
|
+
req = urllib.request.Request(url, method=method, data=payload, headers=req_headers)
|
|
473
|
+
try:
|
|
474
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
475
|
+
if response.status == 204:
|
|
476
|
+
return None
|
|
477
|
+
raw = response.read().decode("utf-8")
|
|
478
|
+
return json.loads(raw) if raw else {}
|
|
479
|
+
except urllib.error.HTTPError as exc:
|
|
480
|
+
details = exc.read().decode("utf-8", errors="replace")
|
|
481
|
+
if _env_flag_enabled(DEBUG_HTTP_ERRORS_ENV):
|
|
482
|
+
raise RuntimeError(f"{method} {path} failed ({exc.code}): {details}") from exc
|
|
483
|
+
raise RuntimeError(
|
|
484
|
+
f"{method} {path} failed ({exc.code}). "
|
|
485
|
+
f"Response body hidden by default; set {DEBUG_HTTP_ERRORS_ENV}=true to include it."
|
|
486
|
+
) from exc
|
|
487
|
+
|
|
488
|
+
def list_apps(self) -> dict[str, Any]:
|
|
489
|
+
return self._request("/apps")
|
|
490
|
+
|
|
491
|
+
def create_app(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
492
|
+
return self._request("/apps", method="POST", body=body)
|
|
493
|
+
|
|
494
|
+
def update_app(self, app_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
495
|
+
return self._request(f"/apps/{app_id}", method="PATCH", body=body)
|
|
496
|
+
|
|
497
|
+
def create_key(self, app_id: str, *, name: str, requests_per_minute: int) -> dict[str, Any]:
|
|
498
|
+
return self._request(
|
|
499
|
+
f"/apps/{app_id}/keys",
|
|
500
|
+
method="POST",
|
|
501
|
+
body={"name": name, "requests_per_minute": int(requests_per_minute)},
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
def run_app(self, app_id: str, *, runtime_key: str, workflow_id: Optional[str], input_payload: dict[str, Any], warmup: bool = False):
|
|
505
|
+
return self._request(
|
|
506
|
+
f"/v1/apps/{app_id}/run",
|
|
507
|
+
method="POST",
|
|
508
|
+
body={"workflow_id": workflow_id, "warmup": bool(warmup), "input": input_payload},
|
|
509
|
+
auth_header=f"Bearer {runtime_key}",
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def send_event(
|
|
513
|
+
self,
|
|
514
|
+
app_id: str,
|
|
515
|
+
*,
|
|
516
|
+
runtime_key: str,
|
|
517
|
+
workflow_id: Optional[str],
|
|
518
|
+
event_type: str,
|
|
519
|
+
channel: str,
|
|
520
|
+
source: str,
|
|
521
|
+
message: str,
|
|
522
|
+
payload: dict[str, Any],
|
|
523
|
+
metadata: dict[str, Any],
|
|
524
|
+
idempotency_key: Optional[str] = None,
|
|
525
|
+
) -> dict[str, Any]:
|
|
526
|
+
headers: dict[str, str] = {}
|
|
527
|
+
if idempotency_key:
|
|
528
|
+
headers["X-Idempotency-Key"] = idempotency_key
|
|
529
|
+
return self._request(
|
|
530
|
+
f"/v1/apps/{app_id}/events",
|
|
531
|
+
method="POST",
|
|
532
|
+
headers=headers,
|
|
533
|
+
body={
|
|
534
|
+
"workflow_id": workflow_id,
|
|
535
|
+
"event_type": event_type,
|
|
536
|
+
"channel": channel,
|
|
537
|
+
"source": source,
|
|
538
|
+
"message": message,
|
|
539
|
+
"payload": payload,
|
|
540
|
+
"metadata": metadata,
|
|
541
|
+
},
|
|
542
|
+
auth_header=f"Bearer {runtime_key}",
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
def setup(self, app_id: str) -> dict[str, Any]:
|
|
546
|
+
return self._request(f"/apps/{app_id}/setup")
|
|
547
|
+
|
|
548
|
+
def invite(self, app_id: str, *, email: str, role: str, expires_in_hours: int) -> dict[str, Any]:
|
|
549
|
+
return self._request(
|
|
550
|
+
f"/apps/{app_id}/invites",
|
|
551
|
+
method="POST",
|
|
552
|
+
body={"email": email, "role": role, "expires_in_hours": int(expires_in_hours)},
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class AraClient:
|
|
557
|
+
"""Runtime client bound to one App manifest."""
|
|
558
|
+
|
|
559
|
+
def __init__(self, *, manifest: dict[str, Any], api_base_url: str, access_token: str, cwd: pathlib.Path):
|
|
560
|
+
self.manifest = dict(manifest)
|
|
561
|
+
self.cwd = cwd
|
|
562
|
+
self.http = _Http(api_base_url, access_token)
|
|
563
|
+
|
|
564
|
+
@classmethod
|
|
565
|
+
def from_env(cls, *, manifest: dict[str, Any], cwd: Optional[str] = None) -> "AraClient":
|
|
566
|
+
base = pathlib.Path(cwd or os.getcwd())
|
|
567
|
+
_read_dotenv(base / ".env")
|
|
568
|
+
env = _require_env("ARA_API_BASE_URL", "ARA_ACCESS_TOKEN")
|
|
569
|
+
return cls(
|
|
570
|
+
manifest=manifest,
|
|
571
|
+
api_base_url=env["ARA_API_BASE_URL"],
|
|
572
|
+
access_token=env["ARA_ACCESS_TOKEN"],
|
|
573
|
+
cwd=base,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def _find_app_by_slug(self) -> Optional[dict[str, Any]]:
|
|
577
|
+
rows = self.http.list_apps().get("apps") or []
|
|
578
|
+
for row in rows:
|
|
579
|
+
if str(row.get("slug") or "") != str(self.manifest.get("slug") or ""):
|
|
580
|
+
continue
|
|
581
|
+
if str(row.get("role") or "") == "owner":
|
|
582
|
+
return row
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
def _resolve_runtime_key(self, explicit: Optional[str] = None) -> str:
|
|
586
|
+
if explicit:
|
|
587
|
+
return explicit
|
|
588
|
+
env_key = os.getenv("ARA_RUNTIME_KEY", "").strip()
|
|
589
|
+
if env_key:
|
|
590
|
+
return env_key
|
|
591
|
+
path = self.cwd / ".runtime-key.local"
|
|
592
|
+
if path.exists():
|
|
593
|
+
return path.read_text(encoding="utf-8").strip()
|
|
594
|
+
return ""
|
|
595
|
+
|
|
596
|
+
def deploy(
|
|
597
|
+
self,
|
|
598
|
+
*,
|
|
599
|
+
activate: bool = True,
|
|
600
|
+
key_name: Optional[str] = None,
|
|
601
|
+
key_rpm: int = 60,
|
|
602
|
+
warm: bool = False,
|
|
603
|
+
warm_workflow_id: Optional[str] = None,
|
|
604
|
+
on_existing: Optional[str] = None,
|
|
605
|
+
) -> dict[str, Any]:
|
|
606
|
+
if on_existing not in (None, "update", "error"):
|
|
607
|
+
raise ValueError("on_existing must be one of: update, error")
|
|
608
|
+
|
|
609
|
+
existing = self._find_app_by_slug()
|
|
610
|
+
app_id = str(existing.get("id")) if existing else ""
|
|
611
|
+
if app_id and on_existing == "error":
|
|
612
|
+
raise RuntimeError(
|
|
613
|
+
f"Project '{self.manifest.get('slug')}' already exists for this account (app_id={app_id})."
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
payload = {
|
|
617
|
+
"name": self.manifest.get("name"),
|
|
618
|
+
"description": self.manifest.get("description") or "",
|
|
619
|
+
"agent": self.manifest.get("agent") or {},
|
|
620
|
+
"workflows": self.manifest.get("workflows") or [],
|
|
621
|
+
"interfaces": self.manifest.get("interfaces") or {},
|
|
622
|
+
"runtime_profile": self.manifest.get("runtime_profile") or {},
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if app_id:
|
|
626
|
+
if activate:
|
|
627
|
+
payload["status"] = "active"
|
|
628
|
+
self.http.update_app(app_id, payload)
|
|
629
|
+
else:
|
|
630
|
+
created = self.http.create_app({**payload, "slug": self.manifest.get("slug")})
|
|
631
|
+
app_id = str((created.get("app") or {}).get("id") or "")
|
|
632
|
+
if not app_id:
|
|
633
|
+
raise RuntimeError("deploy failed: missing app id")
|
|
634
|
+
if activate:
|
|
635
|
+
self.http.update_app(app_id, {"status": "active"})
|
|
636
|
+
|
|
637
|
+
key_out = self.http.create_key(
|
|
638
|
+
app_id,
|
|
639
|
+
name=(key_name or f"{self.manifest.get('slug')}-py-local"),
|
|
640
|
+
requests_per_minute=int(key_rpm),
|
|
641
|
+
)
|
|
642
|
+
runtime_key = str(key_out.get("key") or "").strip()
|
|
643
|
+
if not runtime_key:
|
|
644
|
+
raise RuntimeError("deploy failed: runtime key missing")
|
|
645
|
+
key_path = self.cwd / ".runtime-key.local"
|
|
646
|
+
key_path.write_text(runtime_key + "\n", encoding="utf-8")
|
|
647
|
+
|
|
648
|
+
warmup = None
|
|
649
|
+
if warm:
|
|
650
|
+
warmup = self.http.run_app(
|
|
651
|
+
app_id,
|
|
652
|
+
runtime_key=runtime_key,
|
|
653
|
+
workflow_id=warm_workflow_id,
|
|
654
|
+
input_payload={},
|
|
655
|
+
warmup=True,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
"app_id": app_id,
|
|
660
|
+
"slug": self.manifest.get("slug"),
|
|
661
|
+
"runtime_key_written": True,
|
|
662
|
+
"runtime_key_path": str(key_path),
|
|
663
|
+
"warmup": warmup,
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
def run(self, *, workflow_id: Optional[str], input_payload: Optional[dict[str, Any]] = None, runtime_key: Optional[str] = None):
|
|
667
|
+
app = self._find_app_by_slug()
|
|
668
|
+
if not app:
|
|
669
|
+
raise RuntimeError(f"App '{self.manifest.get('slug')}' not found. Deploy first.")
|
|
670
|
+
key = self._resolve_runtime_key(runtime_key)
|
|
671
|
+
if not key:
|
|
672
|
+
raise RuntimeError("Missing runtime key. Set ARA_RUNTIME_KEY or run deploy first.")
|
|
673
|
+
return self.http.run_app(str(app["id"]), runtime_key=key, workflow_id=workflow_id, input_payload=input_payload or {})
|
|
674
|
+
|
|
675
|
+
def events(
|
|
676
|
+
self,
|
|
677
|
+
*,
|
|
678
|
+
workflow_id: Optional[str],
|
|
679
|
+
event_type: str,
|
|
680
|
+
channel: str,
|
|
681
|
+
source: str,
|
|
682
|
+
message: str,
|
|
683
|
+
payload: Optional[dict[str, Any]] = None,
|
|
684
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
685
|
+
idempotency_key: Optional[str] = None,
|
|
686
|
+
runtime_key: Optional[str] = None,
|
|
687
|
+
) -> dict[str, Any]:
|
|
688
|
+
app = self._find_app_by_slug()
|
|
689
|
+
if not app:
|
|
690
|
+
raise RuntimeError(f"App '{self.manifest.get('slug')}' not found. Deploy first.")
|
|
691
|
+
key = self._resolve_runtime_key(runtime_key)
|
|
692
|
+
if not key:
|
|
693
|
+
raise RuntimeError("Missing runtime key. Set ARA_RUNTIME_KEY or run deploy first.")
|
|
694
|
+
return self.http.send_event(
|
|
695
|
+
str(app["id"]),
|
|
696
|
+
runtime_key=key,
|
|
697
|
+
workflow_id=workflow_id,
|
|
698
|
+
event_type=event_type,
|
|
699
|
+
channel=channel,
|
|
700
|
+
source=source,
|
|
701
|
+
message=message,
|
|
702
|
+
payload=payload or {},
|
|
703
|
+
metadata=metadata or {},
|
|
704
|
+
idempotency_key=idempotency_key,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
def setup(self) -> dict[str, Any]:
|
|
708
|
+
app = self._find_app_by_slug()
|
|
709
|
+
if not app:
|
|
710
|
+
raise RuntimeError(f"App '{self.manifest.get('slug')}' not found. Deploy first.")
|
|
711
|
+
return self.http.setup(str(app["id"]))
|
|
712
|
+
|
|
713
|
+
def invite(self, *, email: str, role: str = "viewer", expires_in_hours: int = 24 * 7) -> dict[str, Any]:
|
|
714
|
+
app = self._find_app_by_slug()
|
|
715
|
+
if not app:
|
|
716
|
+
raise RuntimeError(f"App '{self.manifest.get('slug')}' not found. Deploy first.")
|
|
717
|
+
return self.http.invite(str(app["id"]), email=email, role=role, expires_in_hours=expires_in_hours)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _parse_pairs(items: list[str]) -> dict[str, str]:
|
|
721
|
+
out: dict[str, str] = {}
|
|
722
|
+
for item in items:
|
|
723
|
+
if "=" not in item:
|
|
724
|
+
continue
|
|
725
|
+
key, value = item.split("=", 1)
|
|
726
|
+
key = key.strip()
|
|
727
|
+
if key:
|
|
728
|
+
out[key] = value
|
|
729
|
+
return out
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def run_cli(app: App | dict[str, Any], argv: Optional[list[str]] = None, *, default_command: str = "deploy") -> None:
|
|
733
|
+
app_obj = app if isinstance(app, App) else None
|
|
734
|
+
manifest = app_obj.manifest if app_obj is not None else dict(app)
|
|
735
|
+
|
|
736
|
+
parser = argparse.ArgumentParser(description="Ara Python SDK CLI")
|
|
737
|
+
sub = parser.add_subparsers(dest="command")
|
|
738
|
+
|
|
739
|
+
p_deploy = sub.add_parser("deploy")
|
|
740
|
+
p_deploy.add_argument("--activate", default="true")
|
|
741
|
+
p_deploy.add_argument("--key-name", default="")
|
|
742
|
+
p_deploy.add_argument("--rpm", type=int, default=60)
|
|
743
|
+
p_deploy.add_argument("--warm", default="false")
|
|
744
|
+
p_deploy.add_argument("--warm-workflow", default="")
|
|
745
|
+
p_deploy.add_argument("--on-existing", choices=["update", "error"])
|
|
746
|
+
|
|
747
|
+
p_run = sub.add_parser("run")
|
|
748
|
+
p_run.add_argument("--workflow", default="")
|
|
749
|
+
p_run.add_argument("--message", default="")
|
|
750
|
+
p_run.add_argument("--input", action="append", default=[])
|
|
751
|
+
|
|
752
|
+
p_events = sub.add_parser("events")
|
|
753
|
+
p_events.add_argument("--workflow", default="")
|
|
754
|
+
p_events.add_argument("--event-type", default="webhook.message.received")
|
|
755
|
+
p_events.add_argument("--channel", default="webhook")
|
|
756
|
+
p_events.add_argument("--source", default="webhook")
|
|
757
|
+
p_events.add_argument("--message", default="")
|
|
758
|
+
p_events.add_argument("--input", action="append", default=[])
|
|
759
|
+
p_events.add_argument("--metadata", action="append", default=[])
|
|
760
|
+
p_events.add_argument("--idempotency-key", default="")
|
|
761
|
+
|
|
762
|
+
p_invite = sub.add_parser("invite")
|
|
763
|
+
p_invite.add_argument("--email", default="")
|
|
764
|
+
p_invite.add_argument("--role", default="viewer")
|
|
765
|
+
p_invite.add_argument("--expires-hours", type=int, default=24 * 7)
|
|
766
|
+
|
|
767
|
+
p_local = sub.add_parser("local")
|
|
768
|
+
p_local.add_argument("--input", action="append", default=[])
|
|
769
|
+
|
|
770
|
+
sub.add_parser("setup")
|
|
771
|
+
|
|
772
|
+
args = parser.parse_args(argv)
|
|
773
|
+
command = args.command or default_command
|
|
774
|
+
client = AraClient.from_env(manifest=manifest, cwd=os.getcwd())
|
|
775
|
+
|
|
776
|
+
if command == "deploy":
|
|
777
|
+
deploy_kwargs: dict[str, Any] = {
|
|
778
|
+
"activate": str(args.activate).lower() != "false",
|
|
779
|
+
"key_name": args.key_name or None,
|
|
780
|
+
"key_rpm": int(args.rpm),
|
|
781
|
+
"warm": str(args.warm).lower() == "true",
|
|
782
|
+
"warm_workflow_id": args.warm_workflow or None,
|
|
783
|
+
}
|
|
784
|
+
if args.on_existing:
|
|
785
|
+
deploy_kwargs["on_existing"] = args.on_existing
|
|
786
|
+
print(json.dumps(client.deploy(**deploy_kwargs), indent=2))
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
if command == "run":
|
|
790
|
+
payload = _parse_pairs(args.input)
|
|
791
|
+
if args.message:
|
|
792
|
+
payload["message"] = args.message
|
|
793
|
+
run_id = str(payload.get("run_id") or "").strip() or _new_run_id()
|
|
794
|
+
payload.setdefault("run_id", run_id)
|
|
795
|
+
payload.setdefault("idempotency_key", f"{_slugify(args.workflow or 'default')}-{_slugify(run_id)}")
|
|
796
|
+
print(json.dumps(client.run(workflow_id=args.workflow or None, input_payload=payload), indent=2))
|
|
797
|
+
return
|
|
798
|
+
|
|
799
|
+
if command == "events":
|
|
800
|
+
payload = _parse_pairs(args.input)
|
|
801
|
+
metadata = _parse_pairs(args.metadata)
|
|
802
|
+
idem = str(args.idempotency_key or "").strip() or f"{_slugify(args.event_type)}-{_slugify(_new_run_id())}"
|
|
803
|
+
print(
|
|
804
|
+
json.dumps(
|
|
805
|
+
client.events(
|
|
806
|
+
workflow_id=args.workflow or None,
|
|
807
|
+
event_type=args.event_type,
|
|
808
|
+
channel=args.channel,
|
|
809
|
+
source=args.source,
|
|
810
|
+
message=args.message,
|
|
811
|
+
payload=payload,
|
|
812
|
+
metadata=metadata,
|
|
813
|
+
idempotency_key=idem,
|
|
814
|
+
),
|
|
815
|
+
indent=2,
|
|
816
|
+
)
|
|
817
|
+
)
|
|
818
|
+
return
|
|
819
|
+
|
|
820
|
+
if command == "invite":
|
|
821
|
+
email = str(args.email or "").strip()
|
|
822
|
+
if not email:
|
|
823
|
+
raise RuntimeError("invite requires --email")
|
|
824
|
+
print(json.dumps(client.invite(email=email, role=args.role, expires_in_hours=args.expires_hours), indent=2))
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
if command == "local":
|
|
828
|
+
if app_obj is None:
|
|
829
|
+
raise RuntimeError("local command requires an App(...) instance")
|
|
830
|
+
print(json.dumps({"ok": True, "result": app_obj.call_local_entrypoint(_parse_pairs(args.input))}, indent=2))
|
|
831
|
+
return
|
|
832
|
+
|
|
833
|
+
if command == "setup":
|
|
834
|
+
print(json.dumps(client.setup(), indent=2))
|
|
835
|
+
return
|
|
836
|
+
|
|
837
|
+
parser.print_help()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import urllib.error
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from ara_sdk import App, cron, runtime, sandbox
|
|
7
|
+
from ara_sdk import core
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_app_manifest_project_name_slug_priority():
|
|
11
|
+
app = App(name="Investor Booker", project_name="Team Internal App")
|
|
12
|
+
assert app.slug == "team-internal-app"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_subagent_registers_profile_and_workflow():
|
|
16
|
+
app = App(name="Test App")
|
|
17
|
+
|
|
18
|
+
@app.subagent(
|
|
19
|
+
id="booking-coordinator",
|
|
20
|
+
workflow_id="booking-coordinator",
|
|
21
|
+
instructions="Coordinate booking tasks.",
|
|
22
|
+
schedule=cron("0 10 * * 1-5"),
|
|
23
|
+
runtime=runtime(memory_mb=1024),
|
|
24
|
+
sandbox=sandbox(max_concurrency=3),
|
|
25
|
+
)
|
|
26
|
+
def booking():
|
|
27
|
+
"""Coordinate booking workflows."""
|
|
28
|
+
|
|
29
|
+
manifest = app.manifest
|
|
30
|
+
profiles = manifest["agent"]["profiles"]
|
|
31
|
+
workflows = manifest["workflows"]
|
|
32
|
+
subagents = manifest["agent"]["subagents"]
|
|
33
|
+
|
|
34
|
+
assert profiles[0]["id"] == "booking-coordinator"
|
|
35
|
+
assert workflows[0]["id"] == "booking-coordinator"
|
|
36
|
+
assert workflows[0]["trigger"]["type"] == "cron"
|
|
37
|
+
assert subagents[0]["sandbox"]["max_concurrency"] == 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_http_error_redacts_response_body_by_default(monkeypatch):
|
|
41
|
+
leaked = "internal stack trace: host=prod-worker-17"
|
|
42
|
+
|
|
43
|
+
def _raise_http_error(*args, **kwargs):
|
|
44
|
+
raise urllib.error.HTTPError(
|
|
45
|
+
url="https://api.ara.so/apps",
|
|
46
|
+
code=500,
|
|
47
|
+
msg="Internal Server Error",
|
|
48
|
+
hdrs=None,
|
|
49
|
+
fp=io.BytesIO(leaked.encode("utf-8")),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
monkeypatch.delenv("ARA_SDK_DEBUG_HTTP_ERRORS", raising=False)
|
|
53
|
+
monkeypatch.setattr(core.urllib.request, "urlopen", _raise_http_error)
|
|
54
|
+
|
|
55
|
+
http = core._Http(base_url="https://api.ara.so", access_token="test-token")
|
|
56
|
+
with pytest.raises(RuntimeError) as exc:
|
|
57
|
+
http.list_apps()
|
|
58
|
+
|
|
59
|
+
message = str(exc.value)
|
|
60
|
+
assert "GET /apps failed (500)." in message
|
|
61
|
+
assert "Response body hidden by default" in message
|
|
62
|
+
assert leaked not in message
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_http_error_includes_response_body_in_debug_mode(monkeypatch):
|
|
66
|
+
details = '{"error":"upstream timeout"}'
|
|
67
|
+
|
|
68
|
+
def _raise_http_error(*args, **kwargs):
|
|
69
|
+
raise urllib.error.HTTPError(
|
|
70
|
+
url="https://api.ara.so/apps",
|
|
71
|
+
code=504,
|
|
72
|
+
msg="Gateway Timeout",
|
|
73
|
+
hdrs=None,
|
|
74
|
+
fp=io.BytesIO(details.encode("utf-8")),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
monkeypatch.setenv("ARA_SDK_DEBUG_HTTP_ERRORS", "true")
|
|
78
|
+
monkeypatch.setattr(core.urllib.request, "urlopen", _raise_http_error)
|
|
79
|
+
|
|
80
|
+
http = core._Http(base_url="https://api.ara.so", access_token="test-token")
|
|
81
|
+
with pytest.raises(RuntimeError) as exc:
|
|
82
|
+
http.list_apps()
|
|
83
|
+
|
|
84
|
+
message = str(exc.value)
|
|
85
|
+
assert "GET /apps failed (504):" in message
|
|
86
|
+
assert details in message
|
|
87
|
+
|