@elizaos/interop 2.0.0-alpha

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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +436 -0
  3. package/dist/packages/interop/tsconfig.tsbuildinfo +1 -0
  4. package/dist/tsconfig.tsbuildinfo +1 -0
  5. package/dist/typescript/index.d.ts +33 -0
  6. package/dist/typescript/index.d.ts.map +1 -0
  7. package/dist/typescript/index.js +121 -0
  8. package/dist/typescript/python-bridge.d.ts +80 -0
  9. package/dist/typescript/python-bridge.d.ts.map +1 -0
  10. package/dist/typescript/python-bridge.js +334 -0
  11. package/dist/typescript/types.d.ts +301 -0
  12. package/dist/typescript/types.d.ts.map +1 -0
  13. package/dist/typescript/types.js +10 -0
  14. package/dist/typescript/wasm-loader.d.ts +32 -0
  15. package/dist/typescript/wasm-loader.d.ts.map +1 -0
  16. package/dist/typescript/wasm-loader.js +269 -0
  17. package/package.json +43 -0
  18. package/python/__init__.py +50 -0
  19. package/python/__pycache__/__init__.cpython-313.pyc +0 -0
  20. package/python/__pycache__/rust_ffi.cpython-313.pyc +0 -0
  21. package/python/__pycache__/ts_bridge.cpython-313.pyc +0 -0
  22. package/python/__pycache__/wasm_loader.cpython-313.pyc +0 -0
  23. package/python/bridge_server.py +505 -0
  24. package/python/rust_ffi.py +418 -0
  25. package/python/tests/__init__.py +1 -0
  26. package/python/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/python/tests/__pycache__/test_bridge_server.cpython-313-pytest-9.0.2.pyc +0 -0
  28. package/python/tests/__pycache__/test_interop.cpython-313-pytest-9.0.2.pyc +0 -0
  29. package/python/tests/__pycache__/test_rust_ffi.cpython-313-pytest-9.0.2.pyc +0 -0
  30. package/python/tests/__pycache__/test_rust_ffi_loader.cpython-313-pytest-9.0.2.pyc +0 -0
  31. package/python/tests/test_bridge_server.py +525 -0
  32. package/python/tests/test_interop.py +319 -0
  33. package/python/tests/test_rust_ffi.py +352 -0
  34. package/python/tests/test_rust_ffi_loader.py +71 -0
  35. package/python/ts_bridge.py +452 -0
  36. package/python/ts_bridge_runner.mjs +284 -0
  37. package/python/wasm_loader.py +517 -0
  38. package/rust/ffi_exports.rs +362 -0
  39. package/rust/mod.rs +21 -0
  40. package/rust/py_loader.rs +412 -0
  41. package/rust/ts_loader.rs +467 -0
  42. package/rust/wasm_plugin.rs +320 -0
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from .. import rust_ffi
6
+ from elizaos.types.components import ActionResult, ProviderResult
7
+
8
+
9
+ class _FakeRustPluginFFI:
10
+ def __init__(self, lib_path: str) -> None:
11
+ self._lib_path = lib_path
12
+
13
+ def get_manifest(self) -> dict[str, object]:
14
+ return {
15
+ "name": "fake-rust",
16
+ "description": "fake",
17
+ "actions": [{"name": "DO", "description": "do it"}],
18
+ "providers": [{"name": "PROV", "description": "p"}],
19
+ "evaluators": [{"name": "EVAL", "description": "e"}],
20
+ }
21
+
22
+ def init(self, config: dict[str, str]) -> None:
23
+ return None
24
+
25
+ def validate_action(self, name: str, memory: object, state: object) -> bool:
26
+ return name == "DO"
27
+
28
+ def invoke_action(self, name: str, memory: object, state: object, options: object) -> object:
29
+ return ActionResult(success=True, text=f"ok:{name}")
30
+
31
+ def get_provider(self, name: str, memory: object, state: object) -> object:
32
+ return ProviderResult(text=f"prov:{name}")
33
+
34
+ def validate_evaluator(self, name: str, memory: object, state: object) -> bool:
35
+ return name == "EVAL"
36
+
37
+ def invoke_evaluator(self, name: str, memory: object, state: object) -> object:
38
+ return ActionResult(success=True, text=f"eval:{name}")
39
+
40
+
41
+ @pytest.mark.asyncio
42
+ async def test_load_rust_plugin_produces_awaitable_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
43
+ monkeypatch.setattr(rust_ffi, "RustPluginFFI", _FakeRustPluginFFI)
44
+
45
+ plugin = rust_ffi.load_rust_plugin("fake.so")
46
+ assert plugin.actions is not None
47
+ assert plugin.providers is not None
48
+ assert plugin.evaluators is not None
49
+
50
+ action = plugin.actions[0]
51
+ provider = plugin.providers[0]
52
+ evaluator = plugin.evaluators[0]
53
+
54
+ valid = await action.validate_fn(None, {"content": {}}, None) # type: ignore[arg-type]
55
+ assert valid is True
56
+
57
+ result = await action.handler(None, {"content": {}}, None, None, None, None) # type: ignore[arg-type]
58
+ assert result is not None
59
+ assert result.success is True
60
+ assert result.text == "ok:DO"
61
+
62
+ prov_result = await provider.get(None, {"content": {}}, {"values": {}, "data": {}, "text": ""}) # type: ignore[arg-type]
63
+ assert prov_result.text == "prov:PROV"
64
+
65
+ eval_valid = await evaluator.validate_fn(None, {"content": {}}, None) # type: ignore[arg-type]
66
+ assert eval_valid is True
67
+
68
+ eval_result = await evaluator.handler(None, {"content": {}}, None, None, None, None) # type: ignore[arg-type]
69
+ assert eval_result is not None
70
+ assert eval_result.text == "eval:EVAL"
71
+
@@ -0,0 +1,452 @@
1
+ """
2
+ TypeScript Plugin Bridge for elizaOS
3
+
4
+ This module provides utilities for loading TypeScript plugins into the Python runtime
5
+ via subprocess IPC communication.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import os
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import Callable, Awaitable
16
+
17
+ from elizaos.types.plugin import Plugin
18
+ from elizaos.types.memory import Memory
19
+ from elizaos.types.state import State
20
+ from elizaos.types.components import (
21
+ Action,
22
+ ActionResult,
23
+ Provider,
24
+ ProviderResult,
25
+ Evaluator,
26
+ HandlerOptions,
27
+ )
28
+
29
+
30
+ class TypeScriptPluginBridge:
31
+ """
32
+ IPC bridge for loading TypeScript plugins in Python.
33
+
34
+ Spawns a Node.js subprocess that loads the TypeScript plugin and
35
+ communicates via JSON-RPC over stdin/stdout.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ plugin_path: str | Path,
41
+ *,
42
+ node_path: str = "node",
43
+ cwd: str | Path | None = None,
44
+ env: dict[str, str] | None = None,
45
+ timeout: float = 30.0,
46
+ inherit_env: bool = True,
47
+ env_denylist: list[str] | None = None,
48
+ max_pending_requests: int = 1000,
49
+ max_message_bytes: int = 1_000_000,
50
+ max_buffer_bytes: int = 2_000_000,
51
+ ) -> None:
52
+ """
53
+ Initialize the TypeScript plugin bridge.
54
+
55
+ Args:
56
+ plugin_path: Path to the TypeScript plugin (directory or entry file).
57
+ node_path: Path to Node.js executable (defaults to 'node').
58
+ cwd: Working directory for the subprocess.
59
+ env: Additional environment variables.
60
+ timeout: Request timeout in seconds.
61
+ """
62
+ self.plugin_path = Path(plugin_path)
63
+ self.node_path = node_path
64
+ self.cwd = Path(cwd) if cwd else self.plugin_path.parent
65
+ base_env: dict[str, str] = dict(os.environ) if inherit_env else {}
66
+ if env_denylist:
67
+ for key in env_denylist:
68
+ base_env.pop(key, None)
69
+ if env:
70
+ base_env.update(env)
71
+ # Also pass sizing limits to the runner so it can fail-closed.
72
+ base_env.setdefault("ELIZA_INTEROP_MAX_MESSAGE_BYTES", str(max_message_bytes))
73
+ base_env.setdefault("ELIZA_INTEROP_MAX_BUFFER_BYTES", str(max_buffer_bytes))
74
+ self.env = base_env
75
+ self.timeout = timeout
76
+ self.max_pending_requests = max_pending_requests
77
+ self.max_message_bytes = max_message_bytes
78
+ self.max_buffer_bytes = max_buffer_bytes
79
+
80
+ self.process: subprocess.Popen[bytes] | None = None
81
+ self.manifest: dict[str, object] | None = None
82
+ self._request_counter = 0
83
+ self._pending_requests: dict[str, asyncio.Future[dict[str, object]]] = {}
84
+ self._reader_task: asyncio.Task[None] | None = None
85
+ self._stderr_task: asyncio.Task[None] | None = None
86
+ self._buffer = ""
87
+
88
+ async def start(self) -> None:
89
+ """Start the TypeScript bridge subprocess."""
90
+ bridge_script = self._get_bridge_script()
91
+
92
+ self.process = subprocess.Popen(
93
+ [self.node_path, bridge_script, str(self.plugin_path)],
94
+ stdin=subprocess.PIPE,
95
+ stdout=subprocess.PIPE,
96
+ stderr=subprocess.PIPE,
97
+ cwd=str(self.cwd),
98
+ env=self.env,
99
+ )
100
+
101
+ # Start the reader task
102
+ self._reader_task = asyncio.create_task(self._read_responses())
103
+ self._stderr_task = asyncio.create_task(self._drain_stderr())
104
+
105
+ # Wait for ready message with manifest
106
+ await self._wait_for_ready()
107
+
108
+ def _get_bridge_script(self) -> str:
109
+ """Get the path to the TypeScript bridge script."""
110
+ script_dir = Path(__file__).parent
111
+ bridge_path = script_dir / "ts_bridge_runner.mjs"
112
+ if not bridge_path.exists():
113
+ raise FileNotFoundError(f"Missing bridge runner: {bridge_path}")
114
+ return str(bridge_path)
115
+
116
+ async def _read_responses(self) -> None:
117
+ """Read responses from the subprocess stdout."""
118
+ if not self.process or not self.process.stdout:
119
+ return
120
+
121
+ loop = asyncio.get_event_loop()
122
+
123
+ while True:
124
+ try:
125
+ line = await loop.run_in_executor(
126
+ None, self.process.stdout.readline
127
+ )
128
+ if not line:
129
+ break
130
+
131
+ line_str = line.decode("utf-8").strip()
132
+ if not line_str:
133
+ continue
134
+ if len(line) > self.max_message_bytes:
135
+ raise RuntimeError("Subprocess output exceeded maximum message size")
136
+
137
+ message = json.loads(line_str)
138
+ self._handle_message(message)
139
+ except json.JSONDecodeError as e:
140
+ # Protocol violation: fail closed.
141
+ await self.stop()
142
+ raise RuntimeError("Invalid JSON received from subprocess") from e
143
+
144
+ async def _drain_stderr(self) -> None:
145
+ if not self.process or not self.process.stderr:
146
+ return
147
+ loop = asyncio.get_event_loop()
148
+ while True:
149
+ line = await loop.run_in_executor(None, self.process.stderr.readline)
150
+ if not line:
151
+ break
152
+ # Drain to avoid deadlock; stderr content may be sensitive.
153
+ # In production, route to a controlled logger sink if desired.
154
+ _ = line
155
+
156
+ def _handle_message(self, message: dict[str, object]) -> None:
157
+ """Handle an incoming message from the subprocess."""
158
+ msg_id = message.get("id")
159
+
160
+ if msg_id and msg_id in self._pending_requests:
161
+ future = self._pending_requests.pop(msg_id)
162
+ if not future.done():
163
+ if message.get("type") == "error":
164
+ future.set_exception(Exception(message.get("error", "Unknown error")))
165
+ else:
166
+ future.set_result(message)
167
+
168
+ async def _wait_for_ready(self) -> None:
169
+ """Wait for the ready message from the subprocess."""
170
+ if not self.process or not self.process.stdout:
171
+ raise RuntimeError("Process not started")
172
+
173
+ loop = asyncio.get_event_loop()
174
+
175
+ try:
176
+ line = await asyncio.wait_for(
177
+ loop.run_in_executor(None, self.process.stdout.readline),
178
+ timeout=self.timeout,
179
+ )
180
+ if not line:
181
+ raise RuntimeError("Process exited before sending ready message")
182
+
183
+ message = json.loads(line.decode("utf-8"))
184
+ if message.get("type") != "ready":
185
+ raise RuntimeError(f"Unexpected first message: {message.get('type')}")
186
+
187
+ self.manifest = message.get("manifest")
188
+ except asyncio.TimeoutError:
189
+ raise RuntimeError(f"Plugin startup timeout after {self.timeout}s")
190
+
191
+ async def send_request(self, request: dict[str, object]) -> dict[str, object]:
192
+ """Send a request and wait for the response."""
193
+ if not self.process or not self.process.stdin:
194
+ raise RuntimeError("Bridge not started")
195
+
196
+ if len(self._pending_requests) >= self.max_pending_requests:
197
+ raise RuntimeError("Too many pending requests")
198
+
199
+ self._request_counter += 1
200
+ request_id = f"req_{self._request_counter}"
201
+ request["id"] = request_id
202
+
203
+ future: asyncio.Future[dict[str, object]] = asyncio.get_event_loop().create_future()
204
+ self._pending_requests[request_id] = future
205
+
206
+ json_line = json.dumps(request) + "\n"
207
+ self.process.stdin.write(json_line.encode("utf-8"))
208
+ self.process.stdin.flush()
209
+
210
+ try:
211
+ return await asyncio.wait_for(future, timeout=self.timeout)
212
+ except asyncio.TimeoutError:
213
+ self._pending_requests.pop(request_id, None)
214
+ raise RuntimeError(f"Request timeout for {request.get('type')}")
215
+
216
+ async def stop(self) -> None:
217
+ """Stop the TypeScript bridge subprocess."""
218
+ if self._reader_task:
219
+ self._reader_task.cancel()
220
+ try:
221
+ await self._reader_task
222
+ except asyncio.CancelledError:
223
+ pass
224
+
225
+ if self._stderr_task:
226
+ self._stderr_task.cancel()
227
+ try:
228
+ await self._stderr_task
229
+ except asyncio.CancelledError:
230
+ pass
231
+
232
+ if self.process:
233
+ self.process.terminate()
234
+ try:
235
+ self.process.wait(timeout=5)
236
+ except subprocess.TimeoutExpired:
237
+ self.process.kill()
238
+
239
+ for fut in self._pending_requests.values():
240
+ if not fut.done():
241
+ fut.set_exception(RuntimeError("Bridge stopped"))
242
+ self._pending_requests.clear()
243
+
244
+ def get_manifest(self) -> dict[str, Any] | None:
245
+ """Get the plugin manifest."""
246
+ return self.manifest
247
+
248
+
249
+ async def load_typescript_plugin(
250
+ plugin_path: str | Path,
251
+ *,
252
+ node_path: str = "node",
253
+ cwd: str | Path | None = None,
254
+ timeout: float = 30.0,
255
+ ) -> Plugin:
256
+ """
257
+ Load a TypeScript plugin and return an elizaOS Plugin interface.
258
+
259
+ Args:
260
+ plugin_path: Path to the TypeScript plugin.
261
+ node_path: Path to Node.js executable.
262
+ cwd: Working directory for the subprocess.
263
+ timeout: Request timeout in seconds.
264
+
265
+ Returns:
266
+ elizaOS Plugin instance.
267
+ """
268
+ bridge = TypeScriptPluginBridge(
269
+ plugin_path,
270
+ node_path=node_path,
271
+ cwd=cwd,
272
+ timeout=timeout,
273
+ )
274
+ await bridge.start()
275
+
276
+ manifest = bridge.get_manifest()
277
+ if not manifest:
278
+ raise RuntimeError("Failed to get plugin manifest")
279
+
280
+ # Create action wrappers
281
+ actions: list[Action] = []
282
+ for action_def in manifest.get("actions", []):
283
+ action_name = action_def["name"]
284
+
285
+ def make_validate(name: str, b: TypeScriptPluginBridge) -> Callable[..., Awaitable[bool]]:
286
+ async def validate(runtime: Any, message: Memory, state: State | None) -> bool:
287
+ response = await b.send_request({
288
+ "type": "action.validate",
289
+ "action": name,
290
+ "memory": message.model_dump() if hasattr(message, "model_dump") else message,
291
+ "state": state.model_dump() if state and hasattr(state, "model_dump") else state,
292
+ })
293
+ return response.get("valid", False)
294
+ return validate
295
+
296
+ def make_handler(
297
+ name: str, b: TypeScriptPluginBridge
298
+ ) -> Callable[..., Awaitable[ActionResult | None]]:
299
+ async def handler(
300
+ runtime: Any,
301
+ message: Memory,
302
+ state: State | None,
303
+ options: HandlerOptions | None,
304
+ callback: Any,
305
+ responses: Any,
306
+ ) -> ActionResult | None:
307
+ response = await b.send_request({
308
+ "type": "action.invoke",
309
+ "action": name,
310
+ "memory": message.model_dump() if hasattr(message, "model_dump") else message,
311
+ "state": state.model_dump() if state and hasattr(state, "model_dump") else state,
312
+ "options": options.model_dump() if options else None,
313
+ })
314
+ result = response.get("result")
315
+ if not result:
316
+ return None
317
+ return ActionResult(**result)
318
+ return handler
319
+
320
+ validate_fn = make_validate(action_name, bridge)
321
+ handler_fn = make_handler(action_name, bridge)
322
+
323
+ actions.append(
324
+ Action(
325
+ name=action_name,
326
+ description=action_def.get("description", ""),
327
+ similes=action_def.get("similes"),
328
+ validate=validate_fn, # type: ignore
329
+ handler=handler_fn, # type: ignore
330
+ )
331
+ )
332
+
333
+ # Create provider wrappers
334
+ providers: list[Provider] = []
335
+ for provider_def in manifest.get("providers", []):
336
+ provider_name = provider_def["name"]
337
+
338
+ def make_get(name: str, b: TypeScriptPluginBridge) -> Callable[..., Awaitable[ProviderResult]]:
339
+ async def get(runtime: Any, message: Memory, state: State) -> ProviderResult:
340
+ response = await b.send_request({
341
+ "type": "provider.get",
342
+ "provider": name,
343
+ "memory": message.model_dump() if hasattr(message, "model_dump") else message,
344
+ "state": state.model_dump() if hasattr(state, "model_dump") else state,
345
+ })
346
+ result = response.get("result", {})
347
+ return ProviderResult(**result)
348
+ return get
349
+
350
+ get_fn = make_get(provider_name, bridge)
351
+
352
+ providers.append(
353
+ Provider(
354
+ name=provider_name,
355
+ description=provider_def.get("description"),
356
+ dynamic=provider_def.get("dynamic"),
357
+ position=provider_def.get("position"),
358
+ private=provider_def.get("private"),
359
+ get=get_fn, # type: ignore
360
+ )
361
+ )
362
+
363
+ # Create evaluator wrappers
364
+ evaluators: list[Evaluator] = []
365
+ for eval_def in manifest.get("evaluators", []):
366
+ eval_name = eval_def["name"]
367
+
368
+ def make_eval_validate(name: str, b: TypeScriptPluginBridge) -> Callable[..., Awaitable[bool]]:
369
+ async def validate(runtime: Any, message: Memory, state: State | None) -> bool:
370
+ response = await b.send_request({
371
+ "type": "action.validate",
372
+ "action": name,
373
+ "memory": message.model_dump() if hasattr(message, "model_dump") else message,
374
+ "state": state.model_dump() if state and hasattr(state, "model_dump") else state,
375
+ })
376
+ return response.get("valid", False)
377
+ return validate
378
+
379
+ def make_eval_handler(
380
+ name: str, b: TypeScriptPluginBridge
381
+ ) -> Callable[..., Awaitable[ActionResult | None]]:
382
+ async def handler(
383
+ runtime: Any,
384
+ message: Memory,
385
+ state: State | None,
386
+ options: HandlerOptions | None,
387
+ callback: Any,
388
+ responses: Any,
389
+ ) -> ActionResult | None:
390
+ response = await b.send_request({
391
+ "type": "evaluator.invoke",
392
+ "evaluator": name,
393
+ "memory": message.model_dump() if hasattr(message, "model_dump") else message,
394
+ "state": state.model_dump() if state and hasattr(state, "model_dump") else state,
395
+ })
396
+ result = response.get("result")
397
+ if not result:
398
+ return None
399
+ return ActionResult(**result)
400
+ return handler
401
+
402
+ validate_fn = make_eval_validate(eval_name, bridge)
403
+ handler_fn = make_eval_handler(eval_name, bridge)
404
+
405
+ evaluators.append(
406
+ Evaluator(
407
+ name=eval_name,
408
+ description=eval_def.get("description", ""),
409
+ always_run=eval_def.get("alwaysRun"),
410
+ similes=eval_def.get("similes"),
411
+ examples=[],
412
+ validate=validate_fn, # type: ignore
413
+ handler=handler_fn, # type: ignore
414
+ )
415
+ )
416
+
417
+ # Create init function
418
+ async def init(config: dict[str, str], runtime: Any) -> None:
419
+ await bridge.send_request({
420
+ "type": "plugin.init",
421
+ "config": config,
422
+ })
423
+
424
+ # Store bridge reference for cleanup
425
+ plugin = Plugin(
426
+ name=manifest["name"],
427
+ description=manifest["description"],
428
+ init=init,
429
+ config=manifest.get("config"),
430
+ dependencies=manifest.get("dependencies"),
431
+ actions=actions if actions else None,
432
+ providers=providers if providers else None,
433
+ evaluators=evaluators if evaluators else None,
434
+ )
435
+
436
+ # Attach bridge for cleanup
437
+ setattr(plugin, "_bridge", bridge)
438
+
439
+ return plugin
440
+
441
+
442
+ async def stop_typescript_plugin(plugin: Plugin) -> None:
443
+ """Stop a TypeScript plugin bridge."""
444
+ bridge = getattr(plugin, "_bridge", None)
445
+ if bridge:
446
+ await bridge.stop()
447
+
448
+
449
+
450
+
451
+
452
+