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 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."""
@@ -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
@@ -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)