vectros-sdk 2.0.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.
vectros/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Vectros SDK: build AI agents that run on the AIOS kernel.
2
+
3
+ from vectros import Agent, tool
4
+
5
+ @tool
6
+ def weather(city: str) -> str:
7
+ '''Current weather for a city.'''
8
+ return "31 C, clear"
9
+
10
+ agent = Agent("helper", tools=[weather])
11
+ print(agent.run("weather in Pune?"))
12
+ """
13
+
14
+ from .agent import Agent, Event, Storage, ask_terminal
15
+ from .errors import (KernelError, KernelUnavailable, StepLimitReached, ToolDenied,
16
+ VectrosError)
17
+ from .tools import KernelTool, Tool, tool
18
+
19
+ __version__ = "2.0.0"
20
+
21
+ __all__ = [
22
+ "Agent", "Event", "KernelError", "KernelTool", "KernelUnavailable", "StepLimitReached",
23
+ "Storage", "Tool", "ToolDenied", "VectrosError", "ask_terminal", "tool",
24
+ ]
vectros/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
vectros/_kernel.py ADDED
@@ -0,0 +1,441 @@
1
+ """ctypes binding to libaios.so, the userspace half of the AIOS kernel ABI.
2
+
3
+ Everything here talks to /dev/aios. The higher layers (Agent, tool) only use
4
+ the methods of :class:`Kernel`, so tests can swap in a fake with the same
5
+ surface.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ctypes
11
+ import ctypes.util
12
+ import errno
13
+ import fcntl
14
+ import json
15
+ import os
16
+ import struct
17
+ import time
18
+ import warnings
19
+ from pathlib import Path
20
+ from typing import Callable, Generator
21
+
22
+ from ._tracing import AIOSTraceContextC, TraceSpan
23
+ from .errors import KernelError, KernelUnavailable, ToolDenied, VectrosError
24
+
25
+ ABI_VERSION = 4
26
+
27
+ SYSCALL_LLM, SYSCALL_MEMORY, SYSCALL_STORAGE, SYSCALL_TOOL = 0, 1, 2, 3
28
+
29
+ STATE_CREATED, STATE_QUEUED, STATE_PROCESSING, STATE_DONE = 0, 1, 2, 3
30
+ STATE_FAILED, STATE_CANCELLED = 4, 5
31
+ STATE_HELD_APPROVAL = 9
32
+ TERMINAL_STATES = (STATE_DONE, STATE_FAILED, STATE_CANCELLED)
33
+
34
+ STO_WRITE, STO_RETRIEVE = 3, 4
35
+
36
+ OWNER_APPROVE_TOOL, OWNER_DENY_TOOL = 3, 4
37
+ _IOCTL_OWNER_ACTION = 0x40A04173 # _IOW('A', 0x73, struct aios_owner_action)
38
+ _IOCTL_GET_ABI_INFO = 0x800841FD # _IOR('A', 0xFD, struct aios_abi_info)
39
+
40
+ # ABI 4.3
41
+ AGENT_REGISTER_STABLE = 1
42
+ TOOL_CALL_CLIENT, TOOL_CALL_APPROVAL = 1, 2
43
+ CLIENT_RESULT_MAX = 64 * 1024
44
+ _IOCTL_LIST_TOOLS_NR = 0x54 # _IOWR('A', 0x54, ...), size computed below
45
+
46
+ _TOOL_HEADER = struct.Struct("@HHIQ64sII")
47
+ _STORAGE_HEADER = struct.Struct("@I256sQI4xQQ")
48
+ _TOOL_ENTRY = struct.Struct("@HHI64s32s256sIII")
49
+ _TOOL_PAGE_HEADER = struct.Struct("@HHIIIII")
50
+
51
+ RESULT_MAX = 256 * 1024
52
+
53
+ _LIB_CANDIDATES = (
54
+ "/usr/lib/libaios.so",
55
+ "/usr/lib64/libaios.so",
56
+ "/usr/local/lib/libaios.so",
57
+ )
58
+
59
+
60
+ def find_library() -> str:
61
+ """Locate libaios.so: $VECTROS_LIBAIOS, system paths, then the linker."""
62
+ explicit = os.environ.get("VECTROS_LIBAIOS") or os.environ.get("AIOS_LIB")
63
+ if explicit:
64
+ return explicit
65
+ for candidate in _LIB_CANDIDATES:
66
+ if Path(candidate).exists():
67
+ return candidate
68
+ found = ctypes.util.find_library("aios")
69
+ if found:
70
+ return found
71
+ raise KernelUnavailable(
72
+ "libaios.so not found; install the aios package or set VECTROS_LIBAIOS")
73
+
74
+
75
+ def _tool_payload(name: str, args: dict, flags: int) -> bytes:
76
+ raw_name = name.encode()
77
+ if not raw_name or len(raw_name) >= 64 or b"\0" in raw_name:
78
+ raise ValueError("tool name must be 1-63 UTF-8 bytes without NUL")
79
+ raw_args = json.dumps(args, separators=(",", ":")).encode()
80
+ return _TOOL_HEADER.pack(ABI_VERSION, _TOOL_HEADER.size, flags, 0,
81
+ raw_name.ljust(64, b"\0"), len(raw_args), 0) + raw_args
82
+
83
+
84
+ def _errno_error(what: str) -> KernelError:
85
+ code = ctypes.get_errno()
86
+ return KernelError(code, f"{what}: {os.strerror(code)}")
87
+
88
+
89
+ class Kernel:
90
+ """One open handle on /dev/aios."""
91
+
92
+ def __init__(self, lib_path: str | None = None):
93
+ path = lib_path or find_library()
94
+ try:
95
+ self.lib = ctypes.CDLL(path, use_errno=True)
96
+ except OSError as exc:
97
+ raise KernelUnavailable(f"cannot load {path}: {exc}") from exc
98
+ self._bind()
99
+ self.h = self.lib.aios_open()
100
+ if not self.h:
101
+ code = ctypes.get_errno()
102
+ hints = {
103
+ errno.ENOENT: "aios.ko is not loaded",
104
+ errno.EACCES: "add your user to group aios and log in again",
105
+ }
106
+ detail = hints.get(code, os.strerror(code) if code else "unknown error")
107
+ raise KernelUnavailable(f"cannot open /dev/aios: {detail}")
108
+ # Per syscall: buffers the kernel reads after submit, and the trace
109
+ # span to finish when the syscall is waited for.
110
+ self._inflight: dict[int, tuple[object, TraceSpan | None]] = {}
111
+ self.abi_minor = self._abi_minor()
112
+ missing = [name for name in ("aios_register_agent_ex", "aios_client_tool_complete")
113
+ if not hasattr(self.lib, name)]
114
+ if self.abi_minor >= 3 and missing:
115
+ warnings.warn(
116
+ f"{path} predates the loaded kernel (ABI 4.{self.abi_minor}): missing "
117
+ f"{', '.join(missing)}. Agent IDs are random and @tool calls bypass the "
118
+ "kernel. Install the current libaios.so or set VECTROS_LIBAIOS.",
119
+ RuntimeWarning, stacklevel=3)
120
+
121
+ def _abi_minor(self) -> int:
122
+ info = bytearray(8)
123
+ try:
124
+ fcntl.ioctl(self.fd, _IOCTL_GET_ABI_INFO, info, True)
125
+ except OSError:
126
+ return 0
127
+ major, minor, _ = struct.unpack("@HHI", info)
128
+ return minor if major == ABI_VERSION else 0
129
+
130
+ def _bind(self) -> None:
131
+ void_p, u64, u32 = ctypes.c_void_p, ctypes.c_uint64, ctypes.c_uint32
132
+ int_p = ctypes.POINTER(ctypes.c_int)
133
+ signatures = {
134
+ "aios_register_agent": [void_p, ctypes.c_char_p, ctypes.POINTER(u64)],
135
+ "aios_register_agent_ex": [void_p, ctypes.c_char_p, u32, ctypes.POINTER(u64)],
136
+ "aios_client_tool_complete": [void_p, u64, void_p, u32, ctypes.c_int],
137
+ "aios_unregister_agent": [void_p, u64],
138
+ "aios_setup_core": [void_p, u64, u32, u32, u32, ctypes.c_char_p],
139
+ "aios_submit": [void_p, u64, u64, u32, u32, u32, void_p, u32, u64,
140
+ ctypes.POINTER(u64)],
141
+ "aios_submit_trace": [void_p, u64, u64, u32, u32, u32, void_p, u32, u64,
142
+ ctypes.POINTER(AIOSTraceContextC), ctypes.POINTER(u64)],
143
+ "aios_wait": [void_p, u64, void_p, ctypes.POINTER(u32), int_p, int_p],
144
+ "aios_query_status": [void_p, u64, int_p, int_p],
145
+ "aios_read_stream": [void_p, u64, void_p, ctypes.POINTER(u32), ctypes.c_int,
146
+ int_p, int_p],
147
+ "aios_cancel": [void_p, u64],
148
+ "aios_trace_get_enabled": [void_p, u64, int_p],
149
+ }
150
+ self.lib.aios_open.restype = void_p
151
+ self.lib.aios_close.argtypes = [void_p]
152
+ self.lib.aios_close.restype = None
153
+ for name, argtypes in signatures.items():
154
+ function = getattr(self.lib, name, None)
155
+ if function is not None:
156
+ function.argtypes = argtypes
157
+ function.restype = ctypes.c_int
158
+
159
+ @property
160
+ def fd(self) -> int:
161
+ # struct aios_handle is opaque; its first member is the /dev/aios fd.
162
+ return ctypes.cast(self.h, ctypes.POINTER(ctypes.c_int))[0]
163
+
164
+ def close(self) -> None:
165
+ if self.h:
166
+ self.lib.aios_close(self.h)
167
+ self.h = None
168
+
169
+ # --- agents ---
170
+
171
+ @property
172
+ def supports_client_tools(self) -> bool:
173
+ return self.abi_minor >= 3 and hasattr(self.lib, "aios_client_tool_complete")
174
+
175
+ def register(self, name: str) -> int:
176
+ """Register this handle's agent. On ABI 4.3 kernels the ID is derived
177
+ from (UID, name), so it is the same after a restart."""
178
+ agent_id = ctypes.c_uint64(0)
179
+ if self.abi_minor >= 3 and hasattr(self.lib, "aios_register_agent_ex"):
180
+ if self.lib.aios_register_agent_ex(self.h, name.encode(), AGENT_REGISTER_STABLE,
181
+ ctypes.byref(agent_id)) < 0:
182
+ if ctypes.get_errno() == errno.EEXIST:
183
+ raise VectrosError(f"agent {name!r} is already running for this user; "
184
+ "stop it or use another name")
185
+ raise _errno_error("register agent")
186
+ elif self.lib.aios_register_agent(self.h, name.encode(), ctypes.byref(agent_id)) < 0:
187
+ raise _errno_error("register agent")
188
+ return agent_id.value
189
+
190
+ def unregister(self, agent_id: int) -> None:
191
+ if self.lib.aios_unregister_agent(self.h, agent_id) < 0:
192
+ raise _errno_error("unregister agent")
193
+
194
+ def setup_core(self, agent_id: int, core: int, pstr: str = "") -> None:
195
+ if self.lib.aios_setup_core(self.h, agent_id, core, 0, 0, pstr.encode()) < 0:
196
+ raise _errno_error(f"setup core {core}")
197
+
198
+ def trace_enabled(self, agent_id: int) -> bool:
199
+ function = getattr(self.lib, "aios_trace_get_enabled", None)
200
+ enabled = ctypes.c_int(0)
201
+ if function is None or function(self.h, agent_id, ctypes.byref(enabled)) < 0:
202
+ return False
203
+ return bool(enabled.value)
204
+
205
+ # --- raw syscalls ---
206
+
207
+ def submit(self, agent_id: int, type_id: int, payload: bytes, timeout_ms: int,
208
+ span: TraceSpan | None = None, keepalive: object = None) -> int:
209
+ buf = ctypes.create_string_buffer(payload, len(payload))
210
+ syscall_id = ctypes.c_uint64(0)
211
+ if span is not None and hasattr(self.lib, "aios_submit_trace"):
212
+ context = span.context()
213
+ query_id = int.from_bytes(span.trace_id[:8], "big") or 1
214
+ ret = self.lib.aios_submit_trace(
215
+ self.h, agent_id, query_id, type_id, 0, 1, ctypes.cast(buf, ctypes.c_void_p),
216
+ len(payload), timeout_ms, ctypes.byref(context), ctypes.byref(syscall_id))
217
+ else:
218
+ ret = self.lib.aios_submit(
219
+ self.h, agent_id, 0, type_id, 0, 1, ctypes.cast(buf, ctypes.c_void_p),
220
+ len(payload), timeout_ms, ctypes.byref(syscall_id))
221
+ if ret < 0:
222
+ error = _errno_error("submit syscall")
223
+ if span is not None:
224
+ span.finish(error=str(error))
225
+ raise error
226
+ self._inflight[syscall_id.value] = ((buf, keepalive), span)
227
+ return syscall_id.value
228
+
229
+ def wait(self, syscall_id: int) -> bytes:
230
+ result = ctypes.create_string_buffer(RESULT_MAX)
231
+ length = ctypes.c_uint32(RESULT_MAX)
232
+ state, error = ctypes.c_int(0), ctypes.c_int(0)
233
+ ret = self.lib.aios_wait(self.h, syscall_id, result, ctypes.byref(length),
234
+ ctypes.byref(state), ctypes.byref(error))
235
+ _, span = self._inflight.pop(syscall_id, (None, None))
236
+ if ret < 0:
237
+ failure = _errno_error("wait syscall")
238
+ if span is not None:
239
+ span.finish(error=str(failure))
240
+ raise failure
241
+ data = result.raw[:length.value]
242
+ extra = {"aios.completion.state": state.value}
243
+ if error.value:
244
+ message = data.split(b"\0", 1)[0].decode("utf-8", "replace")
245
+ failure = KernelError(abs(error.value), message or os.strerror(abs(error.value)))
246
+ if span is not None:
247
+ span.finish(error=str(failure), extra={**extra,
248
+ "aios.completion.error_code": error.value})
249
+ raise failure
250
+ if span is not None:
251
+ span.finish(output=data.decode("utf-8", "replace"), extra=extra)
252
+ return data
253
+
254
+ def status(self, syscall_id: int) -> tuple[int, int]:
255
+ state, error = ctypes.c_int(0), ctypes.c_int(0)
256
+ if self.lib.aios_query_status(self.h, syscall_id, ctypes.byref(state),
257
+ ctypes.byref(error)) < 0:
258
+ raise _errno_error("query status")
259
+ return state.value, error.value
260
+
261
+ def read_stream(self, syscall_id: int, size: int = 4096) -> tuple[bytes, int]:
262
+ buf = ctypes.create_string_buffer(size)
263
+ length = ctypes.c_uint32(size)
264
+ state, error = ctypes.c_int(0), ctypes.c_int(0)
265
+ if self.lib.aios_read_stream(self.h, syscall_id, buf, ctypes.byref(length), 1,
266
+ ctypes.byref(state), ctypes.byref(error)) < 0:
267
+ raise _errno_error("read stream")
268
+ return buf.raw[:length.value], state.value
269
+
270
+ def cancel(self, syscall_id: int) -> None:
271
+ if self.lib.aios_cancel(self.h, syscall_id) < 0:
272
+ raise _errno_error("cancel syscall")
273
+
274
+ def owner_action(self, agent_id: int, action: int, syscall_id: int,
275
+ reason: str = "") -> None:
276
+ request = struct.pack("@HHIIQQ128s", ABI_VERSION, 160, action, 0, agent_id,
277
+ syscall_id, reason.encode()[:127])
278
+ fcntl.ioctl(self.fd, _IOCTL_OWNER_ACTION, request)
279
+
280
+ # --- services used by Agent ---
281
+
282
+ def llm_stream(self, agent_id: int, messages: list[dict], model: str | None = None,
283
+ json_mode: bool = False, timeout_ms: int = 120_000,
284
+ span: TraceSpan | None = None,
285
+ tools: list[dict] | None = None) -> Generator[str, None, str]:
286
+ """LLM syscall. Yields text chunks as the worker streams them and
287
+ returns the complete result. With tools, the worker returns a JSON
288
+ envelope {"aios_llm_result": 1, "content", "tool_calls"}."""
289
+ request = {
290
+ "messages": messages, "tools": tools or None,
291
+ "format": "json" if json_mode else None, "model": model,
292
+ }
293
+ if span is not None:
294
+ span.set_input(request)
295
+ payload = json.dumps(request).encode()
296
+ syscall_id = self.submit(agent_id, SYSCALL_LLM, payload, timeout_ms, span)
297
+ try:
298
+ while True:
299
+ chunk, state = self.read_stream(syscall_id)
300
+ if chunk:
301
+ yield chunk.decode("utf-8", "replace")
302
+ if state in TERMINAL_STATES and not chunk:
303
+ break
304
+ except GeneratorExit: # Caller stopped reading: free the kernel slot.
305
+ try:
306
+ self.cancel(syscall_id)
307
+ self.wait(syscall_id) # Reap the record; it fails with ECANCELED.
308
+ except KernelError:
309
+ pass
310
+ raise
311
+ return self.wait(syscall_id).decode("utf-8", "replace").rstrip("\0")
312
+
313
+ def tool(self, agent_id: int, name: str, args: dict, timeout_ms: int = 60_000,
314
+ approve: Callable[[str, dict], bool] | None = None,
315
+ span: TraceSpan | None = None) -> str:
316
+ """Run a kernel-registered tool. Held side-effect tools go to approve();
317
+ with no approver they wait for the owner to decide in AIOS Manager."""
318
+ if span is not None:
319
+ span.set_input({"tool.name": name, "tool.arguments": args})
320
+ syscall_id = self.submit(agent_id, SYSCALL_TOOL, _tool_payload(name, args, 0),
321
+ timeout_ms, span)
322
+ if approve is not None:
323
+ while True:
324
+ state, _ = self.status(syscall_id)
325
+ if state == STATE_HELD_APPROVAL:
326
+ allowed = approve(name, args)
327
+ action = OWNER_APPROVE_TOOL if allowed else OWNER_DENY_TOOL
328
+ self.owner_action(agent_id, action, syscall_id,
329
+ "approved by agent owner" if allowed else "denied by agent owner")
330
+ break
331
+ if state not in (STATE_CREATED, STATE_QUEUED):
332
+ break
333
+ time.sleep(0.05)
334
+ try:
335
+ return self.wait(syscall_id).decode("utf-8", "replace").rstrip("\0")
336
+ except KernelError as exc:
337
+ # EACCES: no permission; ECANCELED: the owner (or approve()) denied it.
338
+ if exc.errno == errno.EACCES:
339
+ raise ToolDenied(name, str(exc)) from exc
340
+ if exc.errno == errno.ECANCELED:
341
+ raise ToolDenied(name, "denied or cancelled by the owner") from exc
342
+ raise
343
+
344
+ def client_tool(self, agent_id: int, name: str, args: dict, run: Callable[[], str],
345
+ approval: bool = False, approve: Callable[[str, dict], bool] | None = None,
346
+ timeout_ms: int = 60_000, span: TraceSpan | None = None) -> str:
347
+ """Run a tool in this process under kernel control (ABI 4.3).
348
+
349
+ The kernel checks TOOL core permission, applies the deadline, records
350
+ the call in telemetry and, with approval, holds it until the owner
351
+ decides (through approve(), or in AIOS Manager when approve is None).
352
+ run() executes only once the kernel hands the call over.
353
+ """
354
+ if span is not None:
355
+ span.set_input({"tool.name": name, "tool.arguments": args})
356
+ flags = TOOL_CALL_CLIENT | (TOOL_CALL_APPROVAL if approval else 0)
357
+ syscall_id = self.submit(agent_id, SYSCALL_TOOL, _tool_payload(name, args, flags),
358
+ timeout_ms, span)
359
+ asked = False
360
+ while True:
361
+ state, _ = self.status(syscall_id)
362
+ if state == STATE_HELD_APPROVAL and approve is not None and not asked:
363
+ asked = True
364
+ allowed = approve(name, args)
365
+ self.owner_action(agent_id, OWNER_APPROVE_TOOL if allowed else OWNER_DENY_TOOL,
366
+ syscall_id, "approved by agent owner" if allowed
367
+ else "denied by agent owner")
368
+ continue
369
+ if state == STATE_PROCESSING or state in TERMINAL_STATES:
370
+ break
371
+ time.sleep(0.05)
372
+ if state == STATE_PROCESSING:
373
+ try:
374
+ result = run()
375
+ except Exception as exc:
376
+ self._client_complete(syscall_id, f"{type(exc).__name__}: {exc}".encode()[:1024],
377
+ -errno.EIO)
378
+ try:
379
+ self.wait(syscall_id)
380
+ except KernelError:
381
+ pass
382
+ raise
383
+ self._client_complete(syscall_id, result.encode()[:CLIENT_RESULT_MAX], 0)
384
+ try:
385
+ return self.wait(syscall_id).decode("utf-8", "replace")
386
+ except KernelError as exc:
387
+ if exc.errno in (errno.ECANCELED, errno.EACCES):
388
+ raise ToolDenied(name, "denied or cancelled by the owner") from exc
389
+ raise
390
+
391
+ def _client_complete(self, syscall_id: int, data: bytes, error: int) -> None:
392
+ buf = ctypes.create_string_buffer(data, max(1, len(data)))
393
+ if self.lib.aios_client_tool_complete(self.h, syscall_id, buf, len(data), error) < 0:
394
+ # ECANCELED: the deadline passed or the owner cancelled meanwhile;
395
+ # wait() reports which.
396
+ if ctypes.get_errno() != errno.ECANCELED:
397
+ raise _errno_error("complete client tool")
398
+
399
+ def list_tools(self) -> list[dict]:
400
+ """Kernel tool registry, all pages."""
401
+ size = _TOOL_PAGE_HEADER.size + 8 * _TOOL_ENTRY.size
402
+ request = 0xC0000000 | (size << 16) | (ord("A") << 8) | _IOCTL_LIST_TOOLS_NR
403
+ tools, offset = [], 0
404
+ while True:
405
+ buf = bytearray(_TOOL_PAGE_HEADER.pack(ABI_VERSION, size, 0, offset, 0, 0, 0)
406
+ + bytes(8 * _TOOL_ENTRY.size))
407
+ fcntl.ioctl(self.fd, request, buf, True)
408
+ _, _, _, _, total, count, _ = _TOOL_PAGE_HEADER.unpack_from(buf)
409
+ for index in range(count):
410
+ fields = _TOOL_ENTRY.unpack_from(buf, _TOOL_PAGE_HEADER.size + index * _TOOL_ENTRY.size)
411
+ schema_text = fields[5].split(b"\0", 1)[0].decode("utf-8", "replace")
412
+ try:
413
+ schema = json.loads(schema_text) if schema_text else {}
414
+ except json.JSONDecodeError:
415
+ schema = {}
416
+ tools.append({
417
+ "name": fields[3].split(b"\0", 1)[0].decode(),
418
+ "schema": schema if isinstance(schema, dict) else {},
419
+ "approval_required": bool(fields[2] & 1),
420
+ })
421
+ offset += count
422
+ if not count or offset >= total:
423
+ return tools
424
+
425
+ def storage_write(self, agent_id: int, path: str, data: bytes,
426
+ timeout_ms: int = 10_000) -> None:
427
+ self._storage(agent_id, STO_WRITE, path, data, timeout_ms)
428
+
429
+ def storage_read(self, agent_id: int, path: str, timeout_ms: int = 10_000) -> bytes:
430
+ return self._storage(agent_id, STO_RETRIEVE, path, b"", timeout_ms)
431
+
432
+ def _storage(self, agent_id: int, op: int, path: str, data: bytes,
433
+ timeout_ms: int) -> bytes:
434
+ raw_path = path.encode()
435
+ if not raw_path or len(raw_path) >= 256 or b"\0" in raw_path:
436
+ raise ValueError("storage path must be 1-255 UTF-8 bytes without NUL")
437
+ source = ctypes.create_string_buffer(data, len(data)) if data else None
438
+ pointer = ctypes.addressof(source) if source is not None else 0
439
+ payload = _STORAGE_HEADER.pack(op, raw_path, pointer, len(data), 0, 0)
440
+ return self.wait(self.submit(agent_id, SYSCALL_STORAGE, payload, timeout_ms,
441
+ keepalive=source))