ramure 0.0.1__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.
ramure/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ from ramure.agent import Agent
2
+ from ramure.log import Log, LogEntry
3
+ from ramure.machines import Image, LocalImage, LocalMachine, Machine
4
+ from ramure.process import (
5
+ ProcessHandle,
6
+ ProcessScope,
7
+ agent,
8
+ agent_process,
9
+ connect,
10
+ current_runtime,
11
+ done,
12
+ emit,
13
+ expose,
14
+ fail,
15
+ machine,
16
+ spawn,
17
+ wait,
18
+ )
19
+ from ramure.runtime import Runtime
20
+ from ramure.stream import Event, Stream
21
+ from ramure.types import ExecResult, ExecutionFailed
22
+
23
+ __all__ = [
24
+ "Agent",
25
+ "Event",
26
+ "ExecResult",
27
+ "ExecutionFailed",
28
+ "Image",
29
+ "LocalImage",
30
+ "LocalMachine",
31
+ "Log",
32
+ "LogEntry",
33
+ "Machine",
34
+ "ProcessHandle",
35
+ "ProcessScope",
36
+ "Runtime",
37
+ "Stream",
38
+ "agent",
39
+ "agent_process",
40
+ "connect",
41
+ "current_runtime",
42
+ "done",
43
+ "emit",
44
+ "expose",
45
+ "fail",
46
+ "machine",
47
+ "spawn",
48
+ "wait",
49
+ ]
ramure/agent.py ADDED
@@ -0,0 +1,68 @@
1
+ """The ``Agent`` type.
2
+
3
+ An Agent owns its own state: a replicated ``Log``, a dict of tool
4
+ handlers, a registration ``Event``, and a handle to the process scope
5
+ that created it. It is the single source of truth for that agent inside
6
+ the runtime; nothing else shadows its fields.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import inspect
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable
15
+
16
+ from ramure.helpers.schema import build_tool_definition
17
+ from ramure.log import Log
18
+ from ramure.machines import Machine
19
+ from ramure.stream import Stream
20
+ from ramure.types import ExecResult
21
+
22
+ if TYPE_CHECKING:
23
+ from ramure.process import ProcessScope
24
+
25
+
26
+ Handler = Callable[..., Awaitable[Any]]
27
+
28
+
29
+ @dataclass
30
+ class Agent:
31
+ name: str
32
+ machine: Machine
33
+ log: Log
34
+ system_prompt: str | None = None
35
+ handlers: dict[str, Handler] = field(default_factory=dict)
36
+ registered: asyncio.Event = field(default_factory=asyncio.Event)
37
+ scope: "ProcessScope | None" = field(default=None, repr=False)
38
+
39
+ @property
40
+ def events(self) -> Stream:
41
+ """Async-iterable stream of this agent's events (no replication metadata)."""
42
+ return self.log.stream
43
+
44
+ def on(self, tool_name: str) -> Callable[[Handler], Handler]:
45
+ """Register an async tool handler for this agent."""
46
+
47
+ def decorator(fn: Handler) -> Handler:
48
+ if not inspect.iscoroutinefunction(fn):
49
+ raise TypeError("Tool handlers must be async")
50
+ self.register_handler(tool_name, fn)
51
+ return fn
52
+
53
+ return decorator
54
+
55
+ def register_handler(self, name: str, fn: Handler) -> None:
56
+ """Install a tool handler. If the agent is already live, announce it."""
57
+ self.handlers[name] = fn
58
+ if self.registered.is_set():
59
+ self.log.emit("tool_registered", build_tool_definition(name, fn))
60
+
61
+ async def send(self, text: str) -> None:
62
+ """Deliver a user-style message to this agent."""
63
+ self.log.emit("message", {"text": text})
64
+
65
+ async def exec(
66
+ self, command: str, *, user: str = "agent", timeout: int | None = None
67
+ ) -> ExecResult:
68
+ return await self.machine.exec(command, user=user, timeout=timeout)
ramure/cli.py ADDED
@@ -0,0 +1,168 @@
1
+ """Ramure CLI.
2
+
3
+ Commands talk to a live runtime over its Unix socket at
4
+ ``~/.ramure/runtimes/{execution_id}.sock``. Finished runs have no
5
+ socket; their logs stay in ``~/.ramure/logs/{execution_id}/``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shlex
13
+ import socket
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import typer
18
+
19
+ from ramure.control import SOCKET_DIR, socket_path
20
+
21
+
22
+ app = typer.Typer(
23
+ name="ramure",
24
+ help="Inspect and interact with ramure executions.",
25
+ no_args_is_help=True,
26
+ add_completion=False,
27
+ )
28
+
29
+ ID_OPT = typer.Option(None, "--id", "-i", help="Execution id or prefix (default: the only live run).")
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Discovery
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ def _live_ids() -> list[str]:
38
+ if not SOCKET_DIR.exists():
39
+ return []
40
+ return sorted(p.stem for p in SOCKET_DIR.glob("*.sock"))
41
+
42
+
43
+ def pick(prefix: str | None) -> str:
44
+ """Resolve a live run id by prefix, or the only live run if omitted."""
45
+ ids = [i for i in _live_ids() if prefix is None or i.startswith(prefix)]
46
+ if not ids:
47
+ die(f"No live run matches '{prefix}'." if prefix else "No live runs.")
48
+ if len(ids) > 1:
49
+ label = "Ambiguous prefix" if prefix else "Multiple live runs; pass --id"
50
+ die(f"{label}:\n" + "\n".join(f" {i[:8]}" for i in ids))
51
+ return ids[0]
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Socket RPC
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def call(execution_id: str, request: dict[str, Any]) -> dict[str, Any]:
60
+ """Send one request to the runtime's control socket, return the reply."""
61
+ try:
62
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
63
+ s.connect(str(socket_path(execution_id)))
64
+ s.sendall((json.dumps(request) + "\n").encode())
65
+ reply = _recv_line(s)
66
+ except (FileNotFoundError, ConnectionRefusedError) as exc:
67
+ die(f"Runtime unreachable: {exc}")
68
+ if not reply:
69
+ die("empty reply from runtime")
70
+ data = json.loads(reply)
71
+ if "error" in data:
72
+ die(data["error"])
73
+ return data
74
+
75
+
76
+ def _recv_line(s: socket.socket) -> str:
77
+ buf = bytearray()
78
+ while True:
79
+ chunk = s.recv(4096)
80
+ if not chunk:
81
+ break
82
+ buf.extend(chunk)
83
+ if b"\n" in chunk:
84
+ break
85
+ return buf.decode().rstrip("\n")
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Commands
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ @app.command("ls")
94
+ def cmd_ls() -> None:
95
+ """List live runs."""
96
+ ids = _live_ids()
97
+ if not ids:
98
+ typer.echo("No live runs.")
99
+ return
100
+ for i in ids:
101
+ reply = call(i, {"cmd": "status"})
102
+ typer.echo(f"{i[:8]} {reply.get('program', '?')}")
103
+
104
+
105
+ @app.command("status")
106
+ def cmd_status(id_: str = ID_OPT) -> None:
107
+ """Show structure of a live run: agents, machines, connections."""
108
+ s = call(pick(id_), {"cmd": "status"})
109
+
110
+ typer.echo(f"Execution: {s['execution_id']}")
111
+ typer.echo(f"Program: {s.get('program', '?')}")
112
+ typer.echo(f"PID: {s.get('pid', '?')}")
113
+ typer.echo(f"Server: {s.get('server_url', '?')}")
114
+
115
+ if s.get("agents"):
116
+ typer.echo("\nAgents:")
117
+ for ag in s["agents"]:
118
+ m = ag["machine"]
119
+ bits = [m.get("kind", "?")]
120
+ if "workdir" in m:
121
+ bits.append(f"workdir={m['workdir']}")
122
+ tmux = f" tmux={ag['tmux_session']}" if ag.get("tmux_session") else ""
123
+ typer.echo(f" {ag['name']} [{' '.join(bits)}]{tmux}")
124
+
125
+ if s.get("connections"):
126
+ typer.echo("\nConnections:")
127
+ for c in s["connections"]:
128
+ typer.echo(f" {c['a']} -> {c['b']}")
129
+
130
+
131
+ @app.command("send")
132
+ def cmd_send(
133
+ agent: str = typer.Argument(..., help="Agent name."),
134
+ message: str = typer.Argument(..., help="Message text."),
135
+ id_: str = ID_OPT,
136
+ ) -> None:
137
+ """Send a message to an agent."""
138
+ call(pick(id_), {"cmd": "send", "agent": agent, "text": message})
139
+ typer.echo(f"Sent to {agent}.")
140
+
141
+
142
+ @app.command("connect")
143
+ def cmd_connect(agent: str = typer.Argument(...), id_: str = ID_OPT) -> None:
144
+ """Attach to an agent's tmux session."""
145
+ eid = pick(id_)
146
+ info = call(eid, {"cmd": "agent", "name": agent})
147
+ session = info.get("tmux_session") or f"ramure-{eid}-{agent}"
148
+ os.execvp("tmux", ["tmux", "attach-session", "-t", session])
149
+
150
+
151
+ @app.command("ssh")
152
+ def cmd_ssh(agent: str = typer.Argument(...), id_: str = ID_OPT) -> None:
153
+ """Open a shell on an agent's machine."""
154
+ info = call(pick(id_), {"cmd": "agent", "name": agent})
155
+ m = info["machine"]
156
+ if m.get("kind") != "LocalMachine":
157
+ die(f"ssh not supported for machine kind '{m.get('kind')}' yet.")
158
+ shell = os.environ.get("SHELL", "/bin/bash")
159
+ os.execvp(shell, [shell, "-c", f"cd {shlex.quote(m.get('workdir', os.getcwd()))} && exec {shell}"])
160
+
161
+
162
+ def die(msg: str) -> None:
163
+ typer.echo(msg, err=True)
164
+ raise typer.Exit(1)
165
+
166
+
167
+ if __name__ == "__main__":
168
+ app()
ramure/context.py ADDED
@@ -0,0 +1,18 @@
1
+ """Context variables shared across the runtime and process modules.
2
+
3
+ Extracted so ``ramure.runtime`` doesn't need a lazy import of
4
+ ``ramure.process`` to reach the active process scope.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextvars import ContextVar
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from ramure.process import ProcessScope
14
+
15
+
16
+ _current_process: ContextVar["ProcessScope | None"] = ContextVar(
17
+ "ramure_current_process", default=None
18
+ )
ramure/control.py ADDED
@@ -0,0 +1,139 @@
1
+ """Unix-socket control server for CLI interaction.
2
+
3
+ The runtime listens on ``~/.ramure/runtimes/{execution_id}.sock``.
4
+ The CLI connects, sends one line of JSON, reads one line back, closes.
5
+
6
+ Commands:
7
+
8
+ - ``{"cmd":"status"}`` -> ``{agents, connections, program, pid, started_at}``
9
+ - ``{"cmd":"agent", "name":<str>}`` -> ``{name, machine, tmux_session}``
10
+ - ``{"cmd":"send", "agent":<str>, "text":<str>}`` -> ``{"ok":true}``
11
+
12
+ Errors come back as ``{"error":<msg>}``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ from ramure.helpers import agent_session_name
25
+
26
+ if TYPE_CHECKING:
27
+ from ramure.runtime import Runtime
28
+
29
+
30
+ SOCKET_DIR = Path.home() / ".ramure" / "runtimes"
31
+
32
+
33
+ def socket_path(execution_id: str) -> Path:
34
+ return SOCKET_DIR / f"{execution_id}.sock"
35
+
36
+
37
+ class ControlServer:
38
+ def __init__(self, runtime: Runtime) -> None:
39
+ self.runtime = runtime
40
+ self._server: asyncio.base_events.Server | None = None
41
+ self._path: Path | None = None
42
+
43
+ async def start(self) -> None:
44
+ assert self.runtime.execution_id
45
+ SOCKET_DIR.mkdir(parents=True, exist_ok=True)
46
+ self._path = socket_path(self.runtime.execution_id)
47
+ # Clean up a stale socket from a crashed previous run.
48
+ try:
49
+ self._path.unlink()
50
+ except FileNotFoundError:
51
+ pass
52
+ self._server = await asyncio.start_unix_server(self._handle, path=str(self._path))
53
+
54
+ async def stop(self) -> None:
55
+ if self._server is not None:
56
+ self._server.close()
57
+ await self._server.wait_closed()
58
+ self._server = None
59
+ if self._path is not None:
60
+ try:
61
+ self._path.unlink()
62
+ except FileNotFoundError:
63
+ pass
64
+ self._path = None
65
+
66
+ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
67
+ try:
68
+ line = await reader.readline()
69
+ if not line:
70
+ return
71
+ try:
72
+ msg = json.loads(line)
73
+ except json.JSONDecodeError:
74
+ reply = {"error": "invalid json"}
75
+ else:
76
+ reply = await self._dispatch(msg)
77
+ writer.write((json.dumps(reply) + "\n").encode())
78
+ await writer.drain()
79
+ finally:
80
+ writer.close()
81
+ try:
82
+ await writer.wait_closed()
83
+ except Exception:
84
+ pass
85
+
86
+ async def _dispatch(self, msg: dict[str, Any]) -> dict[str, Any]:
87
+ cmd = msg.get("cmd")
88
+ if cmd == "status":
89
+ return self._cmd_status()
90
+ if cmd == "agent":
91
+ return self._cmd_agent(msg.get("name", ""))
92
+ if cmd == "send":
93
+ return await self._cmd_send(msg.get("agent", ""), msg.get("text", ""))
94
+ return {"error": f"unknown cmd '{cmd}'"}
95
+
96
+ # -- handlers --
97
+
98
+ def _cmd_status(self) -> dict[str, Any]:
99
+ rt = self.runtime
100
+ return {
101
+ "execution_id": rt.execution_id,
102
+ "pid": os.getpid(),
103
+ "program": _program_name(),
104
+ "started_at": rt.started_at,
105
+ "server_url": rt.server_url,
106
+ "agents": [self._agent_info(ag.name) for ag in rt.agents.values()],
107
+ "connections": [
108
+ {"a": a, "b": b} for (a, b) in sorted(rt.edges)
109
+ ],
110
+ }
111
+
112
+ def _cmd_agent(self, name: str) -> dict[str, Any]:
113
+ if name not in self.runtime.agents:
114
+ return {"error": f"unknown agent '{name}'"}
115
+ return self._agent_info(name)
116
+
117
+ async def _cmd_send(self, name: str, text: str) -> dict[str, Any]:
118
+ ag = self.runtime.agents.get(name)
119
+ if ag is None:
120
+ return {"error": f"unknown agent '{name}'"}
121
+ if not text:
122
+ return {"error": "missing text"}
123
+ await ag.send(text)
124
+ return {"ok": True}
125
+
126
+ def _agent_info(self, name: str) -> dict[str, Any]:
127
+ ag = self.runtime.agents[name]
128
+ return {
129
+ "name": name,
130
+ "machine": ag.machine.describe(),
131
+ "tmux_session": agent_session_name(self.runtime.execution_id or "", name),
132
+ }
133
+
134
+
135
+ def _program_name() -> str:
136
+ argv0 = sys.argv[0] if sys.argv else ""
137
+ if argv0 and Path(argv0).exists():
138
+ return str(Path(argv0).resolve())
139
+ return argv0 or "<unknown>"
ramure/extension.py ADDED
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def extension_source() -> str:
7
+ path = Path(__file__).with_name("extension.ts")
8
+ if not path.exists():
9
+ raise FileNotFoundError(f"Bundled extension not found: {path}")
10
+ return path.read_text()