jaros 0.1.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.
- jaros/__init__.py +6 -0
- jaros/cli.py +537 -0
- jaros/comms/__init__.py +22 -0
- jaros/comms/fs.py +137 -0
- jaros/comms/queue.py +113 -0
- jaros/core/__init__.py +24 -0
- jaros/core/decision.py +56 -0
- jaros/core/decision_gate.py +108 -0
- jaros/core/json_value.py +69 -0
- jaros/core/reasoning_boundary.py +27 -0
- jaros/daemon.py +527 -0
- jaros/eval/__init__.py +24 -0
- jaros/eval/runner.py +154 -0
- jaros/eval/suite.py +81 -0
- jaros/execution/__init__.py +16 -0
- jaros/execution/determinism.py +52 -0
- jaros/execution/executor.py +115 -0
- jaros/execution/handlers.py +88 -0
- jaros/execution/tools.py +101 -0
- jaros/harness/__init__.py +64 -0
- jaros/harness/capabilities.py +272 -0
- jaros/harness/harness.py +282 -0
- jaros/harness/rules.py +68 -0
- jaros/llm/__init__.py +20 -0
- jaros/llm/adapters/__init__.py +12 -0
- jaros/llm/adapters/default_adapter.py +29 -0
- jaros/llm/adapters/ollama_adapter.py +36 -0
- jaros/llm/adapters/uppercase_adapter.py +29 -0
- jaros/llm/client.py +83 -0
- jaros/llm/config.py +56 -0
- jaros/llm/factory.py +86 -0
- jaros/registry.py +163 -0
- jaros/runtime/__init__.py +23 -0
- jaros/runtime/agent_pool.py +173 -0
- jaros/runtime/agent_thread.py +122 -0
- jaros/runtime/lifecycle.py +51 -0
- jaros/scheduling/__init__.py +22 -0
- jaros/scheduling/cron.py +74 -0
- jaros/scheduling/scheduler.py +195 -0
- jaros/state/__init__.py +100 -0
- jaros/state/coordination.py +123 -0
- jaros/state/decision_log.py +346 -0
- jaros/state/log.py +175 -0
- jaros/state/machine.py +113 -0
- jaros/state/model.py +88 -0
- jaros/state/recover.py +103 -0
- jaros/state/swarm.py +243 -0
- jaros-0.1.0.dist-info/METADATA +358 -0
- jaros-0.1.0.dist-info/RECORD +53 -0
- jaros-0.1.0.dist-info/WHEEL +5 -0
- jaros-0.1.0.dist-info/entry_points.txt +2 -0
- jaros-0.1.0.dist-info/licenses/LICENSE +21 -0
- jaros-0.1.0.dist-info/top_level.txt +1 -0
jaros/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Jaros — a zero-infrastructure runtime that makes agent systems reproducible,
|
|
2
|
+
testable, and capability-safe by construction: a durable, crash-recoverable,
|
|
3
|
+
deterministically replayable state machine that orchestrates AI agents as
|
|
4
|
+
lightweight threads. See .jarify/PRIME-001 for the system intent."""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
jaros/cli.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"""Host Control CLI and shared-FS ingestion (EXT-008).
|
|
2
|
+
|
|
3
|
+
A cross-platform, pure-standard-library command-line client for driving a running
|
|
4
|
+
Jaros OS *from the host*. The entire interface between host and OS is the shared
|
|
5
|
+
data directory (a Docker-mounted volume): every command's effect is a read or a
|
|
6
|
+
write under that directory. No socket is ever opened and no network call is ever
|
|
7
|
+
made — the CLI reaches the daemon exclusively through files.
|
|
8
|
+
|
|
9
|
+
Commands::
|
|
10
|
+
|
|
11
|
+
jaros serve run the daemon (inside the container)
|
|
12
|
+
jaros submit <kind> [--input JSON] -> inbox/<id>.json
|
|
13
|
+
jaros add-agent <file.py> [--name K] -> agents/<name-or-file>.py
|
|
14
|
+
jaros status -> print status.json
|
|
15
|
+
jaros watch [--interval S] -> live status + new outbox results
|
|
16
|
+
jaros logs -> print the daemon log (if present)
|
|
17
|
+
jaros eval -> run the agent eval suite (evals/)
|
|
18
|
+
jaros replay [--json] -> reconstruct + verify a run (byte-identical, no model call)
|
|
19
|
+
|
|
20
|
+
global: --data-dir DIR (else $JAROS_DATA_DIR, else ./.jaros-data)
|
|
21
|
+
|
|
22
|
+
Writes are atomic (temp file + :func:`os.replace`), so the daemon never observes a
|
|
23
|
+
partial job or agent. This module lives directly under ``jaros/`` (not under an
|
|
24
|
+
agent package), so the structural comms / no-server checks correctly treat it as a
|
|
25
|
+
host orchestrator rather than an agent.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import ast
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
import uuid
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Sequence
|
|
39
|
+
|
|
40
|
+
#: Default shared data directory when neither --data-dir nor $JAROS_DATA_DIR is set.
|
|
41
|
+
DEFAULT_DATA_DIR = ".jaros-data"
|
|
42
|
+
|
|
43
|
+
#: Environment variable naming the shared data directory.
|
|
44
|
+
DATA_DIR_ENV = "JAROS_DATA_DIR"
|
|
45
|
+
|
|
46
|
+
#: Candidate daemon log file locations searched (relative to the data dir).
|
|
47
|
+
LOG_CANDIDATES = ("daemon.log", "logs/daemon.log")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# #EXT-008-REQ-1 Start
|
|
51
|
+
def resolve_data_dir(args: argparse.Namespace) -> Path:
|
|
52
|
+
"""Resolve the shared data directory using ``pathlib`` only.
|
|
53
|
+
|
|
54
|
+
Preference order: the ``--data-dir`` flag, then the ``$JAROS_DATA_DIR``
|
|
55
|
+
environment variable, then the ``./.jaros-data`` default. Uses no
|
|
56
|
+
platform-specific separators; the returned path is the same directory the
|
|
57
|
+
daemon uses.
|
|
58
|
+
"""
|
|
59
|
+
chosen = getattr(args, "data_dir", None)
|
|
60
|
+
if not chosen:
|
|
61
|
+
chosen = os.environ.get(DATA_DIR_ENV)
|
|
62
|
+
if not chosen:
|
|
63
|
+
chosen = DEFAULT_DATA_DIR
|
|
64
|
+
return Path(chosen)
|
|
65
|
+
# #EXT-008-REQ-1 End
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _atomic_write(target: Path, data: str) -> None:
|
|
69
|
+
"""Write ``data`` to ``target`` atomically via a temp file + ``os.replace``.
|
|
70
|
+
|
|
71
|
+
The temp file is created in the same directory so the rename is atomic on
|
|
72
|
+
Windows and POSIX alike; the daemon never sees a partial file.
|
|
73
|
+
"""
|
|
74
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
tmp = target.with_name(f".tmp-{uuid.uuid4().hex}-{target.name}")
|
|
76
|
+
tmp.write_text(data, encoding="utf-8")
|
|
77
|
+
os.replace(tmp, target)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# #EXT-008-REQ-2 Start
|
|
81
|
+
def cmd_submit(kind: str, input_json: str | None, data_dir: Path) -> Path:
|
|
82
|
+
"""Write a job descriptor ``{id, kind, input}`` into ``inbox/`` atomically.
|
|
83
|
+
|
|
84
|
+
The ``--input`` string (if given) must parse as JSON; on malformed JSON a
|
|
85
|
+
:class:`ValueError` is raised and *nothing* is written. The job id is a fresh
|
|
86
|
+
``uuid4``; the file is written to ``inbox/.tmp-<id>`` then ``os.replace``-d to
|
|
87
|
+
``inbox/<id>.json`` so the daemon never reads a partial job. Returns the path
|
|
88
|
+
of the created job file.
|
|
89
|
+
"""
|
|
90
|
+
data_dir = Path(data_dir)
|
|
91
|
+
if input_json is None:
|
|
92
|
+
parsed_input: object = None
|
|
93
|
+
else:
|
|
94
|
+
try:
|
|
95
|
+
parsed_input = json.loads(input_json)
|
|
96
|
+
except json.JSONDecodeError as exc:
|
|
97
|
+
raise ValueError(f"--input is not valid JSON: {exc}") from exc
|
|
98
|
+
|
|
99
|
+
job_id = uuid.uuid4().hex
|
|
100
|
+
job = {"id": job_id, "kind": kind, "input": parsed_input}
|
|
101
|
+
target = data_dir / "inbox" / f"{job_id}.json"
|
|
102
|
+
# Validate-then-write: the bad-JSON path above never reaches here, so a
|
|
103
|
+
# rejected submission leaves the inbox untouched.
|
|
104
|
+
tmp = (data_dir / "inbox" / f".tmp-{job_id}")
|
|
105
|
+
tmp.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
tmp.write_text(json.dumps(job, indent=2, sort_keys=True), encoding="utf-8")
|
|
107
|
+
os.replace(tmp, target)
|
|
108
|
+
return target
|
|
109
|
+
# #EXT-008-REQ-2 End
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# #EXT-008-REQ-3 Start
|
|
113
|
+
def _discover_kind(source: str) -> str | None:
|
|
114
|
+
"""Statically read an agent module's top-level ``KIND`` string, if any.
|
|
115
|
+
|
|
116
|
+
Parses the source with :mod:`ast` (no import, so no agent side effects run on
|
|
117
|
+
the host) and returns the literal value of a module-level ``KIND = "..."``
|
|
118
|
+
assignment, or ``None`` when it is absent / not a string literal.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
tree = ast.parse(source)
|
|
122
|
+
except SyntaxError:
|
|
123
|
+
return None
|
|
124
|
+
for node in tree.body:
|
|
125
|
+
if isinstance(node, ast.Assign):
|
|
126
|
+
targets = node.targets
|
|
127
|
+
elif isinstance(node, ast.AnnAssign):
|
|
128
|
+
targets = [node.target] if node.target is not None else []
|
|
129
|
+
else:
|
|
130
|
+
continue
|
|
131
|
+
for target in targets:
|
|
132
|
+
if isinstance(target, ast.Name) and target.id == "KIND":
|
|
133
|
+
value = getattr(node, "value", None)
|
|
134
|
+
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
|
135
|
+
return value.value
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_add_agent(path: str, name: str | None, data_dir: Path) -> tuple[Path, str | None]:
|
|
140
|
+
"""Install an agent module into the watched ``agents/`` folder.
|
|
141
|
+
|
|
142
|
+
Validates the source ``*.py`` exists and is readable, then copies it to
|
|
143
|
+
``agents/.tmp-<file>`` and ``os.replace``-s it to
|
|
144
|
+
``agents/<name-or-filename>.py`` so the daemon never loads a partial module.
|
|
145
|
+
The destination filename defaults to the source filename; ``name`` overrides
|
|
146
|
+
its stem. Returns ``(installed_path, discovered_kind)``.
|
|
147
|
+
"""
|
|
148
|
+
source = Path(path)
|
|
149
|
+
if not source.is_file():
|
|
150
|
+
raise FileNotFoundError(f"source agent not found or not a file: {path}")
|
|
151
|
+
try:
|
|
152
|
+
content = source.read_text(encoding="utf-8")
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
raise OSError(f"source agent is not readable: {path} ({exc})") from exc
|
|
155
|
+
|
|
156
|
+
if name:
|
|
157
|
+
filename = name if name.endswith(".py") else f"{name}.py"
|
|
158
|
+
else:
|
|
159
|
+
filename = source.name
|
|
160
|
+
target = data_dir / "agents" / filename
|
|
161
|
+
_atomic_write(target, content)
|
|
162
|
+
return target, _discover_kind(content)
|
|
163
|
+
# #EXT-008-REQ-3 End
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# #EXT-008-REQ-4 Start
|
|
167
|
+
def _read_status(data_dir: Path) -> dict[str, object] | None:
|
|
168
|
+
"""Return the parsed ``status.json`` or ``None`` when it is absent/unreadable."""
|
|
169
|
+
status_path = data_dir / "status.json"
|
|
170
|
+
if not status_path.is_file():
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
return json.loads(status_path.read_text(encoding="utf-8"))
|
|
174
|
+
except (OSError, json.JSONDecodeError):
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_status(data_dir: Path, stream=None) -> int:
|
|
179
|
+
"""Read and pretty-print ``status.json`` (graceful message when absent).
|
|
180
|
+
|
|
181
|
+
Returns 0 when status was printed and 1 when no status file exists yet.
|
|
182
|
+
"""
|
|
183
|
+
out = stream if stream is not None else sys.stdout
|
|
184
|
+
status_path = data_dir / "status.json"
|
|
185
|
+
if not status_path.is_file():
|
|
186
|
+
print(
|
|
187
|
+
f"no status available yet (no {status_path} — is the daemon running?)",
|
|
188
|
+
file=out,
|
|
189
|
+
)
|
|
190
|
+
return 1
|
|
191
|
+
status = _read_status(data_dir)
|
|
192
|
+
if status is None:
|
|
193
|
+
print(f"status file is present but unreadable: {status_path}", file=out)
|
|
194
|
+
return 1
|
|
195
|
+
print(json.dumps(status, indent=2, sort_keys=True), file=out)
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_watch(data_dir: Path, interval: float, stream=None) -> int:
|
|
200
|
+
"""Loop printing status + newly-appeared ``outbox/*.json`` until interrupted.
|
|
201
|
+
|
|
202
|
+
Reads purely from the shared FS: on each pass it prints the current status and
|
|
203
|
+
any ``outbox/`` result files that have appeared since the previous pass. Exits
|
|
204
|
+
cleanly (returns 0) on ``KeyboardInterrupt``. No socket or network is used.
|
|
205
|
+
"""
|
|
206
|
+
out = stream if stream is not None else sys.stdout
|
|
207
|
+
outbox = data_dir / "outbox"
|
|
208
|
+
seen: set[str] = set()
|
|
209
|
+
try:
|
|
210
|
+
while True:
|
|
211
|
+
cmd_status(data_dir, stream=out)
|
|
212
|
+
if outbox.is_dir():
|
|
213
|
+
for result in sorted(outbox.glob("*.json")):
|
|
214
|
+
if result.name in seen:
|
|
215
|
+
continue
|
|
216
|
+
seen.add(result.name)
|
|
217
|
+
print(f"--- new result: outbox/{result.name} ---", file=out)
|
|
218
|
+
try:
|
|
219
|
+
print(result.read_text(encoding="utf-8"), file=out)
|
|
220
|
+
except OSError:
|
|
221
|
+
pass
|
|
222
|
+
time.sleep(max(interval, 0.0))
|
|
223
|
+
except KeyboardInterrupt:
|
|
224
|
+
print("watch stopped.", file=out)
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_logs(data_dir: Path, stream=None) -> int:
|
|
229
|
+
"""Print the daemon log file under the data dir, if one is present.
|
|
230
|
+
|
|
231
|
+
Searches the conventional log locations (``daemon.log``, ``logs/daemon.log``)
|
|
232
|
+
and prints the first that exists. Returns 0 when a log was printed, 1 when no
|
|
233
|
+
log file was found. Reads only the shared FS — no socket, no network.
|
|
234
|
+
"""
|
|
235
|
+
out = stream if stream is not None else sys.stdout
|
|
236
|
+
for candidate in LOG_CANDIDATES:
|
|
237
|
+
log_path = data_dir / candidate
|
|
238
|
+
if log_path.is_file():
|
|
239
|
+
try:
|
|
240
|
+
print(log_path.read_text(encoding="utf-8"), end="", file=out)
|
|
241
|
+
except OSError as exc:
|
|
242
|
+
print(f"log present but unreadable: {log_path} ({exc})", file=out)
|
|
243
|
+
return 1
|
|
244
|
+
return 0
|
|
245
|
+
searched = ", ".join(str(data_dir / c) for c in LOG_CANDIDATES)
|
|
246
|
+
print(f"no daemon log found (looked for: {searched})", file=out)
|
|
247
|
+
return 1
|
|
248
|
+
# #EXT-008-REQ-4 End
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# #EXT-013-REQ-4 Start
|
|
252
|
+
def cmd_eval(data_dir: Path, stream=None) -> int:
|
|
253
|
+
"""Run the agent eval suite in ``<data>/evals`` and print a pass/fail report.
|
|
254
|
+
|
|
255
|
+
Assembles a deterministic eval environment from the data dir — built-in +
|
|
256
|
+
agents and the read-only/custom tool handlers — then runs every case
|
|
257
|
+
in ``evals/*.json``. Returns 0 iff all cases pass. Reads/loads only the shared
|
|
258
|
+
FS; no network.
|
|
259
|
+
"""
|
|
260
|
+
out = stream if stream is not None else sys.stdout
|
|
261
|
+
from jaros.eval import load_cases, run_suite
|
|
262
|
+
from jaros.execution.tools import load_custom_tools
|
|
263
|
+
from jaros.llm import LlmConfig, create_llm_client
|
|
264
|
+
from jaros.registry import AgentRegistry, load_agents, register_builtins
|
|
265
|
+
|
|
266
|
+
llm = create_llm_client(LlmConfig(provider="default"))
|
|
267
|
+
registry = AgentRegistry()
|
|
268
|
+
register_builtins(registry, llm)
|
|
269
|
+
load_agents(registry, data_dir / "agents", llm)
|
|
270
|
+
load_custom_tools(data_dir / "tools") # register tool handlers for result checks
|
|
271
|
+
|
|
272
|
+
cases = load_cases(data_dir / "evals")
|
|
273
|
+
if not cases:
|
|
274
|
+
print(f"no eval cases found in {data_dir / 'evals'}", file=out)
|
|
275
|
+
return 1
|
|
276
|
+
|
|
277
|
+
report = run_suite(cases, registry)
|
|
278
|
+
for r in report.results:
|
|
279
|
+
print(f"[{'PASS' if r.passed else 'FAIL'}] {r.case}", file=out)
|
|
280
|
+
if not r.passed:
|
|
281
|
+
if r.error:
|
|
282
|
+
print(f" error: {r.error}", file=out)
|
|
283
|
+
for c in r.checks:
|
|
284
|
+
if not c.ok:
|
|
285
|
+
print(f" - {c.name}: {c.detail}", file=out)
|
|
286
|
+
print(f"\n{report.passed}/{report.total} eval cases passed", file=out)
|
|
287
|
+
return 0 if report.ok else 1
|
|
288
|
+
# #EXT-013-REQ-4 End
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# #EXT-008-REQ-6 Start
|
|
292
|
+
def cmd_replay(data_dir: Path, *, as_json: bool = False, verbose: bool = False, stream=None) -> int:
|
|
293
|
+
"""Reconstruct a run from the recorded decision log + verify it, no daemon.
|
|
294
|
+
|
|
295
|
+
Re-applies ``<data>/state/decisions.log`` through the deterministic executor —
|
|
296
|
+
constructing **no** ``LlmClient`` and making zero model calls — into a FRESH
|
|
297
|
+
temp sandbox (its own transition log + ``SharedFileSystem``), so nothing in
|
|
298
|
+
the live data dir is touched and re-running is safe. The same runtime handlers
|
|
299
|
+
are reused over the sandbox, so byte-identity is faithful.
|
|
300
|
+
|
|
301
|
+
Exit codes: ``0`` byte-identical (reproducible), ``1`` divergence detected,
|
|
302
|
+
``2`` nothing to replay.
|
|
303
|
+
"""
|
|
304
|
+
out = stream if stream is not None else sys.stdout
|
|
305
|
+
from jaros.state import replay_swarm
|
|
306
|
+
|
|
307
|
+
data_dir = Path(data_dir)
|
|
308
|
+
res = replay_swarm(data_dir)
|
|
309
|
+
|
|
310
|
+
if res.decisions == 0:
|
|
311
|
+
if as_json:
|
|
312
|
+
print(json.dumps({"decisions": 0, "ok": False, "reason": "empty"}), file=out)
|
|
313
|
+
else:
|
|
314
|
+
print(
|
|
315
|
+
f"nothing to replay: no recorded decisions in "
|
|
316
|
+
f"{data_dir / 'state' / 'decisions.log'} (run `jaros submit ...` first)",
|
|
317
|
+
file=out,
|
|
318
|
+
)
|
|
319
|
+
return 2
|
|
320
|
+
|
|
321
|
+
# #EXT-015-REQ-5 Start
|
|
322
|
+
by_agent = {t.source: t.decisions for t in res.by_agent}
|
|
323
|
+
attribution = None
|
|
324
|
+
if res.attribution is not None:
|
|
325
|
+
a = res.attribution
|
|
326
|
+
attribution = {"kind": a.kind, "index": a.index, "id": a.id, "source": a.source, "reason": a.reason}
|
|
327
|
+
|
|
328
|
+
report = {
|
|
329
|
+
"decisions": res.decisions,
|
|
330
|
+
"byAgent": by_agent,
|
|
331
|
+
"modelCalls": 0,
|
|
332
|
+
"finalState": res.final_state,
|
|
333
|
+
"byteIdentical": res.byte_identical,
|
|
334
|
+
"chainOk": res.chain_ok,
|
|
335
|
+
"attribution": attribution,
|
|
336
|
+
"ok": res.ok,
|
|
337
|
+
}
|
|
338
|
+
if as_json:
|
|
339
|
+
print(json.dumps(report), file=out)
|
|
340
|
+
return 0 if res.ok else 1
|
|
341
|
+
|
|
342
|
+
print(
|
|
343
|
+
f"replayed {res.decisions} recorded decisions across {len(res.by_agent)} "
|
|
344
|
+
f"agent(s) - model calls: 0",
|
|
345
|
+
file=out,
|
|
346
|
+
)
|
|
347
|
+
for t in res.by_agent:
|
|
348
|
+
print(f" {t.source:<18}{t.decisions} decision(s)", file=out)
|
|
349
|
+
print(f" reconstructed state : {res.final_state}", file=out)
|
|
350
|
+
_bi = "yes" if res.byte_identical else ("no" if not res.chain_ok else "NO - divergence detected")
|
|
351
|
+
print(f" byte-identical : {_bi}", file=out)
|
|
352
|
+
print(
|
|
353
|
+
f" tamper-evident chain: {'intact' if res.chain_ok else 'BROKEN - ' + (res.chain_reason or '')}",
|
|
354
|
+
file=out,
|
|
355
|
+
)
|
|
356
|
+
if res.attribution is not None:
|
|
357
|
+
a = res.attribution
|
|
358
|
+
label = "DIVERGENCE" if a.kind == "divergence" else "FAILURE"
|
|
359
|
+
print(f" attribution [{label}] : agent '{a.source}' produced decision #{a.index} ({a.id})", file=out)
|
|
360
|
+
print(f" reason: {a.reason}", file=out)
|
|
361
|
+
if res.ok and res.attribution is None:
|
|
362
|
+
print("reproducible: the whole swarm reconstructs byte-identically, with no model call.", file=out)
|
|
363
|
+
elif res.ok and res.attribution is not None:
|
|
364
|
+
print("reproduced byte-identically; a member's handoff failed - attributed to the exact agent above.", file=out)
|
|
365
|
+
elif not res.chain_ok:
|
|
366
|
+
print(
|
|
367
|
+
"TAMPERED: the decision log's hash chain is broken - the recorded account "
|
|
368
|
+
"was altered, so the run cannot be trusted or replayed. "
|
|
369
|
+
f"{res.chain_reason or ''}",
|
|
370
|
+
file=out,
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
print("DIVERGENCE: replay did not reproduce the run byte-identically (a non-deterministic handler?).", file=out)
|
|
374
|
+
return 0 if res.ok else 1
|
|
375
|
+
# #EXT-015-REQ-5 End
|
|
376
|
+
# #EXT-008-REQ-6 End
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# #EXT-008-REQ-1 Start
|
|
380
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
381
|
+
"""Construct the argparse parser with ``--data-dir`` + subcommands.
|
|
382
|
+
|
|
383
|
+
``--data-dir`` is accepted both before the subcommand (``jaros --data-dir D
|
|
384
|
+
status``) and after it (``jaros status --data-dir D``); the per-subcommand
|
|
385
|
+
occurrence wins when both are given.
|
|
386
|
+
"""
|
|
387
|
+
data_dir_help = (
|
|
388
|
+
"shared data directory the daemon uses "
|
|
389
|
+
f"(else ${DATA_DIR_ENV}, else ./{DEFAULT_DATA_DIR})"
|
|
390
|
+
)
|
|
391
|
+
parser = argparse.ArgumentParser(
|
|
392
|
+
prog="jaros",
|
|
393
|
+
description="Host control CLI for a Jaros OS (shared-filesystem only).",
|
|
394
|
+
)
|
|
395
|
+
parser.add_argument("--data-dir", dest="data_dir", default=None, help=data_dir_help)
|
|
396
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
397
|
+
|
|
398
|
+
def add_command(name: str, help_text: str) -> argparse.ArgumentParser:
|
|
399
|
+
p = sub.add_parser(name, help=help_text)
|
|
400
|
+
# SUPPRESS keeps the global --data-dir value when the flag is not
|
|
401
|
+
# repeated after the subcommand.
|
|
402
|
+
p.add_argument(
|
|
403
|
+
"--data-dir", dest="data_dir", default=argparse.SUPPRESS, help=data_dir_help
|
|
404
|
+
)
|
|
405
|
+
return p
|
|
406
|
+
|
|
407
|
+
add_command("serve", "run the daemon (used inside the container)")
|
|
408
|
+
|
|
409
|
+
p_submit = add_command("submit", "write a job descriptor into inbox/")
|
|
410
|
+
p_submit.add_argument("kind", help="agent kind that should handle the job")
|
|
411
|
+
p_submit.add_argument(
|
|
412
|
+
"--input", dest="input", default=None, help="job input as a JSON string"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
p_add = add_command("add-agent", "install an agent module into agents/")
|
|
416
|
+
p_add.add_argument("path", help="path to the agent module (*.py)")
|
|
417
|
+
p_add.add_argument(
|
|
418
|
+
"--name", dest="name", default=None, help="override the installed kind/filename"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
add_command("status", "read and print status.json")
|
|
422
|
+
|
|
423
|
+
p_watch = add_command("watch", "live status + new outbox results")
|
|
424
|
+
p_watch.add_argument(
|
|
425
|
+
"--interval",
|
|
426
|
+
dest="interval",
|
|
427
|
+
type=float,
|
|
428
|
+
default=1.0,
|
|
429
|
+
help="seconds between refreshes (default: 1.0)",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
add_command("logs", "print the daemon log file if present")
|
|
433
|
+
add_command("eval", "run the agent eval suite in evals/")
|
|
434
|
+
|
|
435
|
+
p_replay = add_command("replay", "reconstruct + verify a run from the decision log")
|
|
436
|
+
p_replay.add_argument("--json", dest="as_json", action="store_true", help="emit a one-line JSON report")
|
|
437
|
+
p_replay.add_argument("--verbose", dest="verbose", action="store_true", help="show extra detail on divergence")
|
|
438
|
+
return parser
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# #EXT-008-REQ-1 Start
|
|
442
|
+
def load_dotenv(env_path: Path | None = None) -> None:
|
|
443
|
+
"""Load keys/values from a .env file into os.environ if it exists."""
|
|
444
|
+
if env_path is None:
|
|
445
|
+
env_path = Path(".env")
|
|
446
|
+
if not env_path.is_file():
|
|
447
|
+
return
|
|
448
|
+
try:
|
|
449
|
+
content = env_path.read_text(encoding="utf-8")
|
|
450
|
+
for line in content.splitlines():
|
|
451
|
+
line = line.strip()
|
|
452
|
+
if not line or line.startswith("#"):
|
|
453
|
+
continue
|
|
454
|
+
if "=" in line:
|
|
455
|
+
key, val = line.split("=", 1)
|
|
456
|
+
key = key.strip()
|
|
457
|
+
val = val.strip()
|
|
458
|
+
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
|
|
459
|
+
val = val[1:-1]
|
|
460
|
+
if key:
|
|
461
|
+
os.environ.setdefault(key, val)
|
|
462
|
+
except Exception:
|
|
463
|
+
pass
|
|
464
|
+
# #EXT-008-REQ-1 End
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
468
|
+
"""CLI entry point: parse ``argv`` and dispatch to the matching command.
|
|
469
|
+
|
|
470
|
+
Returns a process exit code. Never opens a socket or makes a network call;
|
|
471
|
+
every command's effect is a read or write under the shared data directory.
|
|
472
|
+
"""
|
|
473
|
+
load_dotenv()
|
|
474
|
+
parser = _build_parser()
|
|
475
|
+
args = parser.parse_args(argv)
|
|
476
|
+
data_dir = resolve_data_dir(args)
|
|
477
|
+
|
|
478
|
+
if args.command == "serve":
|
|
479
|
+
# Imported lazily so the lightweight host commands don't pull in the whole
|
|
480
|
+
# daemon dependency graph just to submit a job or read status.
|
|
481
|
+
from jaros.daemon import Daemon
|
|
482
|
+
from jaros.llm.config import resolve_llm_config
|
|
483
|
+
|
|
484
|
+
# The default LLM is config-driven (config/llm.json or JAROS_LLM_PROVIDER);
|
|
485
|
+
# every agent reaches it through the one LlmClient interface.
|
|
486
|
+
llm_config = resolve_llm_config(data_dir)
|
|
487
|
+
print(f"[jaros] default LLM provider: {llm_config.provider}", file=sys.stderr)
|
|
488
|
+
return Daemon(data_dir=data_dir, llm_config=llm_config).run()
|
|
489
|
+
|
|
490
|
+
if args.command == "submit":
|
|
491
|
+
try:
|
|
492
|
+
target = cmd_submit(args.kind, args.input, data_dir)
|
|
493
|
+
except ValueError as exc:
|
|
494
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
495
|
+
return 2
|
|
496
|
+
job_id = target.stem
|
|
497
|
+
print(f"submitted job {job_id} -> {target}")
|
|
498
|
+
return 0
|
|
499
|
+
|
|
500
|
+
if args.command == "add-agent":
|
|
501
|
+
try:
|
|
502
|
+
target, kind = cmd_add_agent(args.path, args.name, data_dir)
|
|
503
|
+
except (FileNotFoundError, OSError) as exc:
|
|
504
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
505
|
+
return 2
|
|
506
|
+
if kind:
|
|
507
|
+
print(f"installed agent -> {target} (registers kind {kind!r})")
|
|
508
|
+
else:
|
|
509
|
+
print(f"installed agent -> {target}")
|
|
510
|
+
return 0
|
|
511
|
+
|
|
512
|
+
if args.command == "status":
|
|
513
|
+
return cmd_status(data_dir)
|
|
514
|
+
|
|
515
|
+
if args.command == "watch":
|
|
516
|
+
return cmd_watch(data_dir, args.interval)
|
|
517
|
+
|
|
518
|
+
if args.command == "logs":
|
|
519
|
+
return cmd_logs(data_dir)
|
|
520
|
+
|
|
521
|
+
if args.command == "eval":
|
|
522
|
+
return cmd_eval(data_dir)
|
|
523
|
+
|
|
524
|
+
if args.command == "replay":
|
|
525
|
+
return cmd_replay(
|
|
526
|
+
data_dir,
|
|
527
|
+
as_json=getattr(args, "as_json", False),
|
|
528
|
+
verbose=getattr(args, "verbose", False),
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
parser.error(f"unknown command: {args.command!r}") # pragma: no cover
|
|
532
|
+
return 2 # pragma: no cover
|
|
533
|
+
# #EXT-008-REQ-1 End
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
if __name__ == "__main__": # pragma: no cover
|
|
537
|
+
raise SystemExit(main())
|
jaros/comms/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Communication Fabric (EXT-006).
|
|
2
|
+
|
|
3
|
+
The only two sanctioned inter-agent channels: rigid typed queues and a shared
|
|
4
|
+
file system with a fixed layout. No direct agent-to-agent calls exist.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from jaros.comms.fs import (
|
|
10
|
+
LAYOUT_DIRS,
|
|
11
|
+
LayoutViolationError,
|
|
12
|
+
SharedFileSystem,
|
|
13
|
+
)
|
|
14
|
+
from jaros.comms.queue import Queue, QueueContractError
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Queue",
|
|
18
|
+
"QueueContractError",
|
|
19
|
+
"SharedFileSystem",
|
|
20
|
+
"LayoutViolationError",
|
|
21
|
+
"LAYOUT_DIRS",
|
|
22
|
+
]
|