runagents 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runagents/__init__.py +8 -0
- runagents/agent.py +143 -0
- runagents/cli/__init__.py +1 -0
- runagents/cli/binary.py +121 -0
- runagents/cli/dev_cmd.py +308 -0
- runagents/cli/init_cmd.py +57 -0
- runagents/cli/main.py +71 -0
- runagents/cli/templates/agent.py.tmpl +21 -0
- runagents/cli/templates/agents_md.tmpl +22 -0
- runagents/cli/templates/claude_md.tmpl +37 -0
- runagents/cli/templates/cursorrules.tmpl +14 -0
- runagents/cli/templates/gitignore.tmpl +8 -0
- runagents/cli/templates/mcp_json.tmpl +9 -0
- runagents/cli/templates/requirements.txt.tmpl +1 -0
- runagents/cli/templates/runagents.yaml.tmpl +7 -0
- runagents/client.py +253 -0
- runagents/config.py +70 -0
- runagents/mcp/__init__.py +5 -0
- runagents/mcp/__main__.py +5 -0
- runagents/mcp/server.py +254 -0
- runagents/runtime.py +1163 -0
- runagents/types.py +166 -0
- runagents-0.2.0.dist-info/METADATA +150 -0
- runagents-0.2.0.dist-info/RECORD +28 -0
- runagents-0.2.0.dist-info/WHEEL +5 -0
- runagents-0.2.0.dist-info/entry_points.txt +3 -0
- runagents-0.2.0.dist-info/top_level.txt +2 -0
- runagents_runtime.py +13 -0
runagents/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""RunAgents — Python SDK for the RunAgents AI agent platform."""
|
|
2
|
+
|
|
3
|
+
from runagents.client import Client
|
|
4
|
+
from runagents.agent import Agent, tool
|
|
5
|
+
from runagents.runtime import RunContext
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
__all__ = ["Client", "Agent", "tool", "RunContext"]
|
runagents/agent.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Agent SDK for writing RunAgents agents.
|
|
2
|
+
|
|
3
|
+
Provides a high-level Agent class that reads operator-injected env vars
|
|
4
|
+
and exposes ``call_tool()`` and ``chat()`` helpers. Stdlib only.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from runagents import Agent
|
|
9
|
+
|
|
10
|
+
agent = Agent()
|
|
11
|
+
result = agent.chat("What is 2+2?", tools=[...])
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import functools
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Agent:
|
|
23
|
+
"""High-level agent helper that reads operator-injected env vars.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
system_prompt: The agent's system prompt.
|
|
27
|
+
model: LLM model name (e.g. ``gpt-4o-mini``).
|
|
28
|
+
llm_gateway_url: URL for the LLM gateway chat completions endpoint.
|
|
29
|
+
tool_urls: Mapping of tool name → base URL.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self.system_prompt = os.environ.get("SYSTEM_PROMPT", "You are a helpful assistant.")
|
|
34
|
+
self.model = os.environ.get("LLM_MODEL", "gpt-4o-mini")
|
|
35
|
+
self.llm_gateway_url = os.environ.get(
|
|
36
|
+
"LLM_GATEWAY_URL",
|
|
37
|
+
"http://llm-gateway.agent-system.svc:8080/v1/chat/completions",
|
|
38
|
+
)
|
|
39
|
+
self.tool_urls: dict[str, str] = {}
|
|
40
|
+
for key, val in os.environ.items():
|
|
41
|
+
if key.startswith("TOOL_URL_"):
|
|
42
|
+
name = key[len("TOOL_URL_"):].lower().replace("_", "-")
|
|
43
|
+
self.tool_urls[name] = val
|
|
44
|
+
|
|
45
|
+
def call_tool(
|
|
46
|
+
self,
|
|
47
|
+
name: str,
|
|
48
|
+
path: str = "/",
|
|
49
|
+
payload: dict | None = None,
|
|
50
|
+
method: str = "POST",
|
|
51
|
+
) -> dict:
|
|
52
|
+
"""Call a platform tool by name.
|
|
53
|
+
|
|
54
|
+
The Istio mesh handles auth and policy — this just makes the HTTP call.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
name: Tool name (must be in ``self.tool_urls``).
|
|
58
|
+
path: HTTP path on the tool (default ``/``).
|
|
59
|
+
payload: JSON body (for POST/PUT/PATCH).
|
|
60
|
+
method: HTTP method.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Parsed JSON response.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
KeyError: If tool name is not found in env vars.
|
|
67
|
+
"""
|
|
68
|
+
from runagents.runtime import execute_tool_call
|
|
69
|
+
|
|
70
|
+
base = self.tool_urls.get(name)
|
|
71
|
+
if base is None:
|
|
72
|
+
env_key = "TOOL_URL_" + name.upper().replace("-", "_")
|
|
73
|
+
raise KeyError(f"Tool {name!r} not found. Set {env_key} or check requiredTools.")
|
|
74
|
+
|
|
75
|
+
url = base.rstrip("/") + path
|
|
76
|
+
body = json.dumps(payload) if payload else None
|
|
77
|
+
result_str = execute_tool_call(method, url, body=body)
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(result_str)
|
|
80
|
+
except (json.JSONDecodeError, TypeError):
|
|
81
|
+
return {"raw": result_str}
|
|
82
|
+
|
|
83
|
+
def chat(
|
|
84
|
+
self,
|
|
85
|
+
message: str,
|
|
86
|
+
tools: list[dict] | None = None,
|
|
87
|
+
history: list[dict] | None = None,
|
|
88
|
+
) -> dict:
|
|
89
|
+
"""Send a chat completion request through the LLM gateway.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
message: User message.
|
|
93
|
+
tools: OpenAI-format tool definitions (optional).
|
|
94
|
+
history: Prior conversation messages (optional).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Full OpenAI-format response dict.
|
|
98
|
+
"""
|
|
99
|
+
from runagents.runtime import call_llm
|
|
100
|
+
|
|
101
|
+
messages = list(history) if history else []
|
|
102
|
+
messages.append({"role": "user", "content": message})
|
|
103
|
+
|
|
104
|
+
return call_llm(
|
|
105
|
+
messages,
|
|
106
|
+
gateway_url=self.llm_gateway_url,
|
|
107
|
+
model=self.model,
|
|
108
|
+
system_prompt=self.system_prompt if not history else None,
|
|
109
|
+
tools=tools,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def tool(fn: Callable | None = None, *, name: str | None = None, description: str = "") -> Any:
|
|
114
|
+
"""Decorator to mark a function as a tool handler.
|
|
115
|
+
|
|
116
|
+
The decorated function gains ``.tool_name`` and ``.tool_description``
|
|
117
|
+
attributes for discovery by frameworks.
|
|
118
|
+
|
|
119
|
+
Usage::
|
|
120
|
+
|
|
121
|
+
@tool
|
|
122
|
+
def calculator(expression: str) -> str:
|
|
123
|
+
return str(eval(expression))
|
|
124
|
+
|
|
125
|
+
@tool(name="weather-lookup", description="Get current weather")
|
|
126
|
+
def weather(city: str) -> dict:
|
|
127
|
+
...
|
|
128
|
+
"""
|
|
129
|
+
def decorator(f: Callable) -> Callable:
|
|
130
|
+
f.tool_name = name or f.__name__.replace("_", "-") # type: ignore[attr-defined]
|
|
131
|
+
f.tool_description = description or (f.__doc__ or "").strip().split("\n")[0] # type: ignore[attr-defined]
|
|
132
|
+
|
|
133
|
+
@functools.wraps(f)
|
|
134
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
135
|
+
return f(*args, **kwargs)
|
|
136
|
+
|
|
137
|
+
wrapper.tool_name = f.tool_name # type: ignore[attr-defined]
|
|
138
|
+
wrapper.tool_description = f.tool_description # type: ignore[attr-defined]
|
|
139
|
+
return wrapper
|
|
140
|
+
|
|
141
|
+
if fn is not None:
|
|
142
|
+
return decorator(fn)
|
|
143
|
+
return decorator
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""RunAgents CLI — Python entry point that delegates to Go binary."""
|
runagents/cli/binary.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Go CLI binary downloader — mirrors cli/npm/install.js logic.
|
|
2
|
+
|
|
3
|
+
Downloads from S3, verifies SHA256, caches at ~/.runagents/bin/.
|
|
4
|
+
Stdlib only: urllib.request, tarfile, hashlib, platform.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
CLI_VERSION = "1.0.6"
|
|
20
|
+
S3_BASE = "https://runagents-releases.s3.amazonaws.com/cli"
|
|
21
|
+
|
|
22
|
+
PLATFORM_MAP = {"Darwin": "darwin", "Linux": "linux", "Windows": "windows"}
|
|
23
|
+
ARCH_MAP = {"x86_64": "amd64", "AMD64": "amd64", "arm64": "arm64", "aarch64": "arm64"}
|
|
24
|
+
|
|
25
|
+
_BIN_DIR = Path.home() / ".runagents" / "bin"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_binary(version: str = CLI_VERSION) -> Path | None:
|
|
29
|
+
"""Return path to Go binary, downloading if needed. Returns None on failure."""
|
|
30
|
+
# 1. Check cached binary
|
|
31
|
+
cached = _BIN_DIR / f"runagents-{version}"
|
|
32
|
+
if cached.exists() and os.access(cached, os.X_OK):
|
|
33
|
+
return cached
|
|
34
|
+
|
|
35
|
+
# 2. Check PATH
|
|
36
|
+
on_path = shutil.which("runagents")
|
|
37
|
+
if on_path:
|
|
38
|
+
return Path(on_path)
|
|
39
|
+
|
|
40
|
+
# 3. Download
|
|
41
|
+
try:
|
|
42
|
+
return _download(version)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"Warning: could not download CLI binary: {e}", file=sys.stderr)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _download(version: str) -> Path:
|
|
49
|
+
plat = PLATFORM_MAP.get(platform.system())
|
|
50
|
+
arch = ARCH_MAP.get(platform.machine())
|
|
51
|
+
if not plat or not arch:
|
|
52
|
+
raise RuntimeError(f"Unsupported platform: {platform.system()}/{platform.machine()}")
|
|
53
|
+
|
|
54
|
+
ext = ".zip" if plat == "windows" else ".tar.gz"
|
|
55
|
+
asset = f"runagents_{plat}_{arch}{ext}"
|
|
56
|
+
url = f"{S3_BASE}/v{version}/{asset}"
|
|
57
|
+
checksums_url = f"{S3_BASE}/v{version}/checksums.txt"
|
|
58
|
+
|
|
59
|
+
_BIN_DIR.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
62
|
+
archive_path = Path(tmpdir) / asset
|
|
63
|
+
|
|
64
|
+
# Download archive
|
|
65
|
+
print(f"Downloading runagents v{version} for {plat}/{arch}...")
|
|
66
|
+
urllib.request.urlretrieve(url, archive_path)
|
|
67
|
+
|
|
68
|
+
# Verify checksum
|
|
69
|
+
try:
|
|
70
|
+
with urllib.request.urlopen(checksums_url, timeout=10) as resp:
|
|
71
|
+
checksums_text = resp.read().decode()
|
|
72
|
+
expected_hash = _find_hash(checksums_text, asset)
|
|
73
|
+
if expected_hash:
|
|
74
|
+
actual_hash = _sha256(archive_path)
|
|
75
|
+
if actual_hash != expected_hash:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
f"SHA256 mismatch for {asset}: expected {expected_hash}, got {actual_hash}"
|
|
78
|
+
)
|
|
79
|
+
except urllib.error.URLError:
|
|
80
|
+
pass # Skip verification if checksums unavailable
|
|
81
|
+
|
|
82
|
+
# Extract
|
|
83
|
+
bin_name = "runagents.exe" if plat == "windows" else "runagents"
|
|
84
|
+
if ext == ".tar.gz":
|
|
85
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
86
|
+
# Find the binary in the archive
|
|
87
|
+
for member in tar.getmembers():
|
|
88
|
+
if member.name.endswith(bin_name):
|
|
89
|
+
member.name = bin_name
|
|
90
|
+
tar.extract(member, tmpdir)
|
|
91
|
+
break
|
|
92
|
+
else:
|
|
93
|
+
import zipfile
|
|
94
|
+
with zipfile.ZipFile(archive_path) as zf:
|
|
95
|
+
for name in zf.namelist():
|
|
96
|
+
if name.endswith(bin_name):
|
|
97
|
+
zf.extract(name, tmpdir)
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
src = Path(tmpdir) / bin_name
|
|
101
|
+
dst = _BIN_DIR / f"runagents-{version}"
|
|
102
|
+
shutil.move(str(src), str(dst))
|
|
103
|
+
dst.chmod(dst.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
104
|
+
print(f"Installed runagents v{version} to {dst}")
|
|
105
|
+
return dst
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _sha256(path: Path) -> str:
|
|
109
|
+
h = hashlib.sha256()
|
|
110
|
+
with open(path, "rb") as f:
|
|
111
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
112
|
+
h.update(chunk)
|
|
113
|
+
return h.hexdigest()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _find_hash(checksums_text: str, asset_name: str) -> str | None:
|
|
117
|
+
for line in checksums_text.strip().splitlines():
|
|
118
|
+
parts = line.split()
|
|
119
|
+
if len(parts) == 2 and parts[1] == asset_name:
|
|
120
|
+
return parts[0]
|
|
121
|
+
return None
|
runagents/cli/dev_cmd.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""``runagents dev`` — local dev server with mock tools and hot reload."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_dev(args: list[str]) -> None:
|
|
14
|
+
if args and args[0] in ("-h", "--help"):
|
|
15
|
+
print(
|
|
16
|
+
"Usage: runagents dev [options]\n\n"
|
|
17
|
+
"Start local dev server.\n\n"
|
|
18
|
+
"Options:\n"
|
|
19
|
+
" --port PORT Agent port (default 8080)\n"
|
|
20
|
+
" --mock-port PORT Mock tool server port (default 9090)\n"
|
|
21
|
+
" --no-mock Skip mock tool server\n"
|
|
22
|
+
" --watch Hot-reload on .py changes (requires watchdog)\n"
|
|
23
|
+
)
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
# Parse args
|
|
27
|
+
port = 8080
|
|
28
|
+
mock_port = 9090
|
|
29
|
+
use_mock = True
|
|
30
|
+
watch = False
|
|
31
|
+
i = 0
|
|
32
|
+
while i < len(args):
|
|
33
|
+
if args[i] == "--port" and i + 1 < len(args):
|
|
34
|
+
port = int(args[i + 1])
|
|
35
|
+
i += 2
|
|
36
|
+
elif args[i] == "--mock-port" and i + 1 < len(args):
|
|
37
|
+
mock_port = int(args[i + 1])
|
|
38
|
+
i += 2
|
|
39
|
+
elif args[i] == "--no-mock":
|
|
40
|
+
use_mock = False
|
|
41
|
+
i += 1
|
|
42
|
+
elif args[i] == "--watch":
|
|
43
|
+
watch = True
|
|
44
|
+
i += 1
|
|
45
|
+
else:
|
|
46
|
+
i += 1
|
|
47
|
+
|
|
48
|
+
config = _load_runagents_yaml()
|
|
49
|
+
if config is None:
|
|
50
|
+
print("Error: runagents.yaml not found. Run 'runagents init' first.", file=sys.stderr)
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
# Set env vars matching operator injection
|
|
54
|
+
_setup_env(config, mock_port, use_mock)
|
|
55
|
+
|
|
56
|
+
print(f"RunAgents Dev Server")
|
|
57
|
+
print(f" Agent: :{port}")
|
|
58
|
+
if use_mock:
|
|
59
|
+
print(f" Mock tools: :{mock_port}")
|
|
60
|
+
print(f" Model: {os.environ.get('LLM_MODEL', 'gpt-4o-mini')}")
|
|
61
|
+
print(f" Entry point: {config.get('entry_point', 'agent.py')}")
|
|
62
|
+
print()
|
|
63
|
+
|
|
64
|
+
# Start mock tool server
|
|
65
|
+
mock_server = None
|
|
66
|
+
if use_mock:
|
|
67
|
+
mock_server = _start_mock_server(mock_port, config.get("tools", []))
|
|
68
|
+
|
|
69
|
+
# Start agent runtime
|
|
70
|
+
try:
|
|
71
|
+
if watch:
|
|
72
|
+
_run_with_watch(port, config)
|
|
73
|
+
else:
|
|
74
|
+
_run_agent(port)
|
|
75
|
+
except KeyboardInterrupt:
|
|
76
|
+
print("\nShutting down...")
|
|
77
|
+
finally:
|
|
78
|
+
if mock_server:
|
|
79
|
+
mock_server.shutdown()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_runagents_yaml() -> dict | None:
|
|
83
|
+
"""Load runagents.yaml from current directory."""
|
|
84
|
+
for name in ("runagents.yaml", "runagents.yml"):
|
|
85
|
+
p = Path.cwd() / name
|
|
86
|
+
if p.exists():
|
|
87
|
+
# Use a simple YAML subset parser (stdlib only)
|
|
88
|
+
return _parse_simple_yaml(p.read_text())
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_simple_yaml(text: str) -> dict:
|
|
93
|
+
"""Parse a simple single-level YAML file (no nested objects beyond one level)."""
|
|
94
|
+
result: dict = {}
|
|
95
|
+
current_key = None
|
|
96
|
+
current_list: list | None = None
|
|
97
|
+
|
|
98
|
+
for line in text.splitlines():
|
|
99
|
+
stripped = line.strip()
|
|
100
|
+
if not stripped or stripped.startswith("#"):
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# List item
|
|
104
|
+
if stripped.startswith("- "):
|
|
105
|
+
if current_list is not None:
|
|
106
|
+
current_list.append(stripped[2:].strip().strip('"').strip("'"))
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# Key: value
|
|
110
|
+
if ":" in stripped:
|
|
111
|
+
key, _, val = stripped.partition(":")
|
|
112
|
+
key = key.strip()
|
|
113
|
+
val = val.strip().strip('"').strip("'")
|
|
114
|
+
|
|
115
|
+
# Handle nested keys (one level deep via indentation)
|
|
116
|
+
indent = len(line) - len(line.lstrip())
|
|
117
|
+
if indent > 0 and current_key and isinstance(result.get(current_key), dict):
|
|
118
|
+
result[current_key][key] = val
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
if val == "":
|
|
122
|
+
# Could be a list or nested dict
|
|
123
|
+
# Peek: if next non-empty line starts with "- ", it's a list
|
|
124
|
+
result[key] = {}
|
|
125
|
+
current_key = key
|
|
126
|
+
current_list = None
|
|
127
|
+
continue
|
|
128
|
+
elif val == "[]":
|
|
129
|
+
result[key] = []
|
|
130
|
+
current_key = key
|
|
131
|
+
current_list = result[key]
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
result[key] = val
|
|
135
|
+
current_key = key
|
|
136
|
+
current_list = None
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _setup_env(config: dict, mock_port: int, use_mock: bool) -> None:
|
|
142
|
+
"""Set environment variables matching what the operator would inject."""
|
|
143
|
+
os.environ.setdefault("SYSTEM_PROMPT", config.get("system_prompt", "You are a helpful assistant."))
|
|
144
|
+
os.environ.setdefault("AGENT_NAME", config.get("name", "dev-agent"))
|
|
145
|
+
|
|
146
|
+
# LLM config
|
|
147
|
+
model_cfg = config.get("model", {})
|
|
148
|
+
if isinstance(model_cfg, dict):
|
|
149
|
+
model = model_cfg.get("model", "gpt-4o-mini")
|
|
150
|
+
else:
|
|
151
|
+
model = "gpt-4o-mini"
|
|
152
|
+
os.environ.setdefault("LLM_MODEL", model)
|
|
153
|
+
|
|
154
|
+
# LLM gateway: if platform configured, use it; else direct OpenAI
|
|
155
|
+
if os.environ.get("RUNAGENTS_ENDPOINT"):
|
|
156
|
+
from runagents.config import load_config
|
|
157
|
+
cfg = load_config()
|
|
158
|
+
os.environ.setdefault("LLM_GATEWAY_URL", f"{cfg.endpoint}/v1/chat/completions")
|
|
159
|
+
elif os.environ.get("OPENAI_API_KEY"):
|
|
160
|
+
os.environ.setdefault("LLM_GATEWAY_URL", "https://api.openai.com/v1/chat/completions")
|
|
161
|
+
else:
|
|
162
|
+
os.environ.setdefault("LLM_GATEWAY_URL", "http://localhost:8092/v1/chat/completions")
|
|
163
|
+
|
|
164
|
+
# Tools
|
|
165
|
+
tools = config.get("tools", [])
|
|
166
|
+
if isinstance(tools, list) and use_mock:
|
|
167
|
+
for t in tools:
|
|
168
|
+
if isinstance(t, str):
|
|
169
|
+
env_key = "TOOL_URL_" + t.upper().replace("-", "_")
|
|
170
|
+
os.environ.setdefault(env_key, f"http://localhost:{mock_port}")
|
|
171
|
+
|
|
172
|
+
# Tool definitions + routes for runtime
|
|
173
|
+
os.environ.setdefault("TOOL_DEFINITIONS_JSON", "[]")
|
|
174
|
+
os.environ.setdefault("TOOL_ROUTES_JSON", "{}")
|
|
175
|
+
|
|
176
|
+
# User entry point
|
|
177
|
+
entry = config.get("entry_point", "agent.py")
|
|
178
|
+
if entry:
|
|
179
|
+
module = entry.removesuffix(".py")
|
|
180
|
+
os.environ.setdefault("USER_ENTRY_POINT", module)
|
|
181
|
+
|
|
182
|
+
os.environ.setdefault("PORT", "8080")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _start_mock_server(port: int, tools: list) -> HTTPServer:
|
|
186
|
+
"""Start a mock tool server that returns echo responses."""
|
|
187
|
+
|
|
188
|
+
class MockHandler(BaseHTTPRequestHandler):
|
|
189
|
+
def do_GET(self):
|
|
190
|
+
self._respond({"method": "GET", "path": self.path, "mock": True})
|
|
191
|
+
|
|
192
|
+
def do_POST(self):
|
|
193
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
194
|
+
body = self.rfile.read(length).decode() if length else "{}"
|
|
195
|
+
try:
|
|
196
|
+
payload = json.loads(body)
|
|
197
|
+
except json.JSONDecodeError:
|
|
198
|
+
payload = {"raw": body}
|
|
199
|
+
|
|
200
|
+
self._respond({
|
|
201
|
+
"method": "POST",
|
|
202
|
+
"path": self.path,
|
|
203
|
+
"received": payload,
|
|
204
|
+
"mock": True,
|
|
205
|
+
"result": f"Mock response for {self.path}",
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
def do_PUT(self):
|
|
209
|
+
self.do_POST()
|
|
210
|
+
|
|
211
|
+
def do_PATCH(self):
|
|
212
|
+
self.do_POST()
|
|
213
|
+
|
|
214
|
+
def do_DELETE(self):
|
|
215
|
+
self._respond({"method": "DELETE", "path": self.path, "mock": True, "deleted": True})
|
|
216
|
+
|
|
217
|
+
def _respond(self, data: dict) -> None:
|
|
218
|
+
body = json.dumps(data).encode()
|
|
219
|
+
self.send_response(200)
|
|
220
|
+
self.send_header("Content-Type", "application/json")
|
|
221
|
+
self.send_header("Content-Length", str(len(body)))
|
|
222
|
+
self.end_headers()
|
|
223
|
+
self.wfile.write(body)
|
|
224
|
+
|
|
225
|
+
def log_message(self, format, *args):
|
|
226
|
+
print(f" [mock] {args[0]}")
|
|
227
|
+
|
|
228
|
+
server = HTTPServer(("0.0.0.0", port), MockHandler)
|
|
229
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
230
|
+
thread.start()
|
|
231
|
+
return server
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _run_agent(port: int) -> None:
|
|
235
|
+
"""Start the agent runtime server."""
|
|
236
|
+
os.environ["PORT"] = str(port)
|
|
237
|
+
|
|
238
|
+
# Add cwd to path so user's agent.py is importable
|
|
239
|
+
if str(Path.cwd()) not in sys.path:
|
|
240
|
+
sys.path.insert(0, str(Path.cwd()))
|
|
241
|
+
|
|
242
|
+
from runagents.runtime import main as runtime_main
|
|
243
|
+
runtime_main()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _run_with_watch(port: int, config: dict) -> None:
|
|
247
|
+
"""Run with file watching and auto-restart."""
|
|
248
|
+
try:
|
|
249
|
+
from watchdog.observers import Observer
|
|
250
|
+
from watchdog.events import FileSystemEventHandler
|
|
251
|
+
except ImportError:
|
|
252
|
+
print("Warning: watchdog not installed. Running without hot-reload.")
|
|
253
|
+
print("Install with: pip install runagents[dev]")
|
|
254
|
+
_run_agent(port)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
import subprocess
|
|
258
|
+
|
|
259
|
+
process = None
|
|
260
|
+
lock = threading.Lock()
|
|
261
|
+
|
|
262
|
+
def start():
|
|
263
|
+
nonlocal process
|
|
264
|
+
env = os.environ.copy()
|
|
265
|
+
env["PORT"] = str(port)
|
|
266
|
+
process = subprocess.Popen(
|
|
267
|
+
[sys.executable, "-c", "from runagents.runtime import main; main()"],
|
|
268
|
+
env=env,
|
|
269
|
+
cwd=str(Path.cwd()),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def restart():
|
|
273
|
+
nonlocal process
|
|
274
|
+
with lock:
|
|
275
|
+
if process:
|
|
276
|
+
process.terminate()
|
|
277
|
+
process.wait(timeout=5)
|
|
278
|
+
print(" [dev] Restarting agent...")
|
|
279
|
+
start()
|
|
280
|
+
|
|
281
|
+
class ReloadHandler(FileSystemEventHandler):
|
|
282
|
+
def __init__(self):
|
|
283
|
+
self._last = 0
|
|
284
|
+
|
|
285
|
+
def on_modified(self, event):
|
|
286
|
+
if not event.src_path.endswith(".py"):
|
|
287
|
+
return
|
|
288
|
+
now = time.time()
|
|
289
|
+
if now - self._last < 1:
|
|
290
|
+
return
|
|
291
|
+
self._last = now
|
|
292
|
+
restart()
|
|
293
|
+
|
|
294
|
+
observer = Observer()
|
|
295
|
+
observer.schedule(ReloadHandler(), str(Path.cwd()), recursive=True)
|
|
296
|
+
observer.start()
|
|
297
|
+
|
|
298
|
+
start()
|
|
299
|
+
try:
|
|
300
|
+
while True:
|
|
301
|
+
if process and process.poll() is not None:
|
|
302
|
+
break
|
|
303
|
+
time.sleep(0.5)
|
|
304
|
+
finally:
|
|
305
|
+
observer.stop()
|
|
306
|
+
observer.join()
|
|
307
|
+
if process:
|
|
308
|
+
process.terminate()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""``runagents init [name]`` — scaffold a new agent project."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_TEMPLATE_DIR = Path(__file__).parent / "templates"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_init(args: list[str]) -> None:
|
|
11
|
+
if args and args[0] in ("-h", "--help"):
|
|
12
|
+
print("Usage: runagents init [name]\n\nScaffold a new agent project.")
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
name = args[0] if args else "my-agent"
|
|
16
|
+
target = Path.cwd() / name
|
|
17
|
+
|
|
18
|
+
if target.exists():
|
|
19
|
+
print(f"Error: directory '{name}' already exists.", file=sys.stderr)
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
target.mkdir(parents=True)
|
|
23
|
+
_render_templates(target, name)
|
|
24
|
+
|
|
25
|
+
print(f"Created project '{name}/' with:")
|
|
26
|
+
for f in sorted(target.rglob("*")):
|
|
27
|
+
if f.is_file():
|
|
28
|
+
print(f" {f.relative_to(target)}")
|
|
29
|
+
print(f"\nNext steps:\n cd {name}\n pip install runagents\n runagents dev")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _render_templates(target: Path, name: str) -> None:
|
|
33
|
+
"""Read .tmpl files and write rendered output."""
|
|
34
|
+
replacements = {
|
|
35
|
+
"{{name}}": name,
|
|
36
|
+
"{{name_underscore}}": name.replace("-", "_"),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
file_map = {
|
|
40
|
+
"agent.py.tmpl": "agent.py",
|
|
41
|
+
"runagents.yaml.tmpl": "runagents.yaml",
|
|
42
|
+
"requirements.txt.tmpl": "requirements.txt",
|
|
43
|
+
"claude_md.tmpl": "CLAUDE.md",
|
|
44
|
+
"cursorrules.tmpl": ".cursorrules",
|
|
45
|
+
"agents_md.tmpl": "AGENTS.md",
|
|
46
|
+
"mcp_json.tmpl": ".mcp.json",
|
|
47
|
+
"gitignore.tmpl": ".gitignore",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for tmpl_name, out_name in file_map.items():
|
|
51
|
+
tmpl_path = _TEMPLATE_DIR / tmpl_name
|
|
52
|
+
if not tmpl_path.exists():
|
|
53
|
+
continue
|
|
54
|
+
content = tmpl_path.read_text()
|
|
55
|
+
for old, new in replacements.items():
|
|
56
|
+
content = content.replace(old, new)
|
|
57
|
+
(target / out_name).write_text(content)
|