nexus-lite 0.2.2__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.
Files changed (43) hide show
  1. nexus_lite/__init__.py +190 -0
  2. nexus_lite/__main__.py +7 -0
  3. nexus_lite/_db.py +196 -0
  4. nexus_lite/_proc.py +17 -0
  5. nexus_lite/artifacts.py +83 -0
  6. nexus_lite/cli.py +491 -0
  7. nexus_lite/colab/__init__.py +55 -0
  8. nexus_lite/colab/__main__.py +9 -0
  9. nexus_lite/colab/env.py +298 -0
  10. nexus_lite/colab/tunnel.py +1245 -0
  11. nexus_lite/compute.py +803 -0
  12. nexus_lite/core.py +845 -0
  13. nexus_lite/demos/__init__.py +39 -0
  14. nexus_lite/demos/__main__.py +7 -0
  15. nexus_lite/demos/cli.py +251 -0
  16. nexus_lite/demos/loom_multi_agent.py +689 -0
  17. nexus_lite/demos/loom_multi_task.py +418 -0
  18. nexus_lite/demos/loom_radiomics_tasks.py +1175 -0
  19. nexus_lite/demos/orchestration.py +2232 -0
  20. nexus_lite/demos/scenarios.py +1808 -0
  21. nexus_lite/demos/viz.py +1017 -0
  22. nexus_lite/guard.py +81 -0
  23. nexus_lite/loom.py +282 -0
  24. nexus_lite/loom_multi_agent.py +12 -0
  25. nexus_lite/loom_multi_task.py +12 -0
  26. nexus_lite/loom_radiomics_tasks.py +12 -0
  27. nexus_lite/memory/__init__.py +24 -0
  28. nexus_lite/memory/ltm.py +96 -0
  29. nexus_lite/memory/mtm.py +269 -0
  30. nexus_lite/memory/stm.py +756 -0
  31. nexus_lite/phi.py +35 -0
  32. nexus_lite/protocol.py +106 -0
  33. nexus_lite/provider_errors.py +144 -0
  34. nexus_lite/pty.py +913 -0
  35. nexus_lite/roles.py +140 -0
  36. nexus_lite/stm.py +13 -0
  37. nexus_lite/viz.py +12 -0
  38. nexus_lite-0.2.2.dist-info/METADATA +377 -0
  39. nexus_lite-0.2.2.dist-info/RECORD +43 -0
  40. nexus_lite-0.2.2.dist-info/WHEEL +5 -0
  41. nexus_lite-0.2.2.dist-info/entry_points.txt +4 -0
  42. nexus_lite-0.2.2.dist-info/licenses/LICENSE +21 -0
  43. nexus_lite-0.2.2.dist-info/top_level.txt +1 -0
nexus_lite/__init__.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ nexus-lite: Open-Core Kernel for Nexus multi-agent primitives.
3
+
4
+ Eagerly loads only the light modules (`pty`, `roles`, `protocol`); `core` and the memory tiers
5
+ (`nexus_lite.memory.{stm,mtm,ltm}`) load on first attribute access (PEP 562), so
6
+ `import nexus_lite.memory.ltm` pulls in no other tier.
7
+
8
+ Demos/orchestration scenarios (`nexus_lite.demos`) and Colab helpers (`nexus_lite.colab`) are
9
+ outside the kernel; their old top-level names resolve lazily with a DeprecationWarning.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import warnings
16
+ from importlib import metadata as _metadata
17
+ from typing import Any
18
+
19
+ from . import protocol
20
+ from . import pty
21
+ from . import roles
22
+ from .protocol import (
23
+ POINTER_MAX_CHARS,
24
+ format_pointer_line,
25
+ parse_pointer_line,
26
+ pointer_parts,
27
+ sanitize_pointer_token,
28
+ truncate_words,
29
+ )
30
+ from .pty import (
31
+ KEY_MAP,
32
+ KEY_NAMES,
33
+ key_to_bytes,
34
+ keys_to_bytes,
35
+ )
36
+ from .roles import (
37
+ REAL_AGENTS,
38
+ ROLE_FOR_AGENT,
39
+ ROLE_PROMPTS,
40
+ ROLE_TIER,
41
+ TIER_ORDER,
42
+ get_role_prompt,
43
+ resolve_role_dispatch,
44
+ tier_for_role,
45
+ tier_rank,
46
+ )
47
+
48
+ try: # single source of truth: pyproject.toml via installed metadata
49
+ __version__ = _metadata.version("nexus-lite")
50
+ except _metadata.PackageNotFoundError: # bare source tree / shipped bundle: read pyproject.toml if adjacent
51
+ import re as _re
52
+ from pathlib import Path as _Path
53
+
54
+ try:
55
+ _m = _re.search(r'(?m)^version\s*=\s*"([^"]+)"',
56
+ (_Path(__file__).resolve().parent.parent / "pyproject.toml").read_text(encoding="utf-8"))
57
+ except OSError:
58
+ _m = None
59
+ __version__ = _m.group(1) if _m else "0+unknown"
60
+
61
+ _LAZY_MODULES = {"compute", "core", "demos"}
62
+
63
+ _LAZY_SYMBOLS: dict[str, str] = {
64
+ # compute (zero-preinstall remote execution on Turing & Colab)
65
+ "compute_run": "compute",
66
+ "pack_lite_bundle": "compute",
67
+ "pack_self_contained_script": "compute",
68
+ "run_local_sandbox": "compute",
69
+ "run_on_colab": "compute",
70
+ "run_on_turing": "compute",
71
+ "turing_job_status": "compute",
72
+ # core
73
+ "chat": "core",
74
+ "delegate": "core",
75
+ "ensure_opencode": "core",
76
+ "run_pipeline": "core",
77
+ "run_swarm": "core",
78
+ # _db / memory tiers
79
+ "get_workspace": "_db",
80
+ "init": "_db",
81
+ "bus_read": "memory.mtm",
82
+ "bus_send": "memory.mtm",
83
+ "wiki_list": "memory.ltm",
84
+ "wiki_read": "memory.ltm",
85
+ "wiki_search": "memory.ltm",
86
+ "wiki_write": "memory.ltm",
87
+ "stm_list": "memory.stm",
88
+ "stm_pack": "memory.stm",
89
+ "stm_read": "memory.stm",
90
+ "stm_rebuild_index": "memory.stm",
91
+ "stm_search": "memory.stm",
92
+ "stm_write": "memory.stm",
93
+ }
94
+
95
+ # Deprecated top-level names: resolved lazily with a DeprecationWarning.
96
+ _DEPRECATED_SYMBOLS: dict[str, str] = {
97
+ "get_db": "_db",
98
+ "get_db_path": "_db",
99
+ **{n: "demos.scenarios" for n in (
100
+ "run_all_demos", "run_hipaa_privacy_pipeline", "run_lung1_audit_pipeline",
101
+ "run_multimodal_imaging_swarm", "run_opencode_free_demo", "run_phantom_shape_swarm")},
102
+ **{n: "demos.orchestration" for n in (
103
+ "run_agent_interaction", "run_cooperative_problem_solver", "run_multi_tier_swarm")},
104
+ **{n: "demos.viz" for n in ("dashboard", "render_dashboard_html", "watch")},
105
+ **{n: "colab.env" for n in (
106
+ "ColabInitResult", "detect_env", "ensure_colab", "ensure_env", "sync_dotdir_to_drive")},
107
+ }
108
+
109
+
110
+ def __getattr__(name: str) -> Any:
111
+ if name in _LAZY_MODULES:
112
+ mod = importlib.import_module(f".{name}", __name__)
113
+ globals()[name] = mod
114
+ return mod
115
+ if name in _LAZY_SYMBOLS:
116
+ mod_name = _LAZY_SYMBOLS[name]
117
+ mod = importlib.import_module(f".{mod_name}", __name__)
118
+ val = getattr(mod, name)
119
+ globals()[name] = val
120
+ return val
121
+ if name in _DEPRECATED_SYMBOLS:
122
+ mod_name = _DEPRECATED_SYMBOLS[name]
123
+ warnings.warn(
124
+ f"nexus_lite.{name} is deprecated; use nexus_lite.{mod_name}.{name}",
125
+ DeprecationWarning,
126
+ stacklevel=2,
127
+ )
128
+ return getattr(importlib.import_module(f".{mod_name}", __name__), name)
129
+ if name == "stm": # deprecated module alias, see nexus_lite/stm.py
130
+ warnings.warn(
131
+ "nexus_lite.stm is deprecated; import nexus_lite.memory.stm instead",
132
+ DeprecationWarning,
133
+ stacklevel=2,
134
+ )
135
+ return importlib.import_module(".memory.stm", __name__)
136
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
137
+
138
+
139
+ __all__ = [
140
+ "pty",
141
+ "compute",
142
+ "core",
143
+ "roles",
144
+ "protocol",
145
+ "compute_run",
146
+ "run_on_turing",
147
+ "run_on_colab",
148
+ "run_local_sandbox",
149
+ "pack_lite_bundle",
150
+ "pack_self_contained_script",
151
+ "turing_job_status",
152
+ "init",
153
+ "get_workspace",
154
+ "bus_send",
155
+ "bus_read",
156
+ "delegate",
157
+ "run_swarm",
158
+ "run_pipeline",
159
+ "stm_write",
160
+ "stm_read",
161
+ "stm_list",
162
+ "stm_search",
163
+ "stm_pack",
164
+ "stm_rebuild_index",
165
+ "wiki_write",
166
+ "wiki_read",
167
+ "wiki_search",
168
+ "wiki_list",
169
+ "chat",
170
+ "ensure_opencode",
171
+ "POINTER_MAX_CHARS",
172
+ "sanitize_pointer_token",
173
+ "truncate_words",
174
+ "format_pointer_line",
175
+ "pointer_parts",
176
+ "parse_pointer_line",
177
+ "KEY_NAMES",
178
+ "KEY_MAP",
179
+ "key_to_bytes",
180
+ "keys_to_bytes",
181
+ "ROLE_PROMPTS",
182
+ "ROLE_FOR_AGENT",
183
+ "REAL_AGENTS",
184
+ "ROLE_TIER",
185
+ "TIER_ORDER",
186
+ "tier_rank",
187
+ "tier_for_role",
188
+ "resolve_role_dispatch",
189
+ "get_role_prompt",
190
+ ]
nexus_lite/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow `python -m nexus_lite` (same entrypoint as the `nexus-lite` console script)."""
2
+ import sys
3
+
4
+ from nexus_lite.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
nexus_lite/_db.py ADDED
@@ -0,0 +1,196 @@
1
+ """
2
+ nexus_lite._db — workspace path + SQLite WAL connection + schema bootstrap.
3
+
4
+ Shared by the memory tiers (stm/mtm/ltm) and the task/session code in core. Stdlib
5
+ only; imports no other nexus_lite module at import time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import os
12
+ import sqlite3
13
+ import threading
14
+ from pathlib import Path
15
+ from typing import Iterator, Optional, Union
16
+
17
+
18
+ def get_workspace() -> Path:
19
+ """Return canonical workspace root Path (.nexus/ or NEXUS_LITE_DIR or NEXUS_LITE_HOME)."""
20
+ env = os.environ.get("NEXUS_LITE_DIR") or os.environ.get("NEXUS_LITE_HOME")
21
+ if env:
22
+ return Path(env).resolve()
23
+ return Path(".nexus").resolve()
24
+
25
+
26
+ def get_db_path() -> Path:
27
+ """Return path to SQLite WAL database."""
28
+ return get_workspace() / "lite.db"
29
+
30
+
31
+ def mark_session(name: str, live: bool) -> None:
32
+ """Best-effort: flip an EXISTING sessions-registry row live/unregistered (lite#8).
33
+
34
+ Update-only and never creates the workspace/DB: a socket with no row already counts as
35
+ live on the bus, so only a stale 'unregistered' row (or a kill) needs a write. Called by
36
+ pty.spawn / pty.kill (edge pty -> _db; _db imports no lite module, so no cycle).
37
+ """
38
+ try:
39
+ if not get_db_path().exists():
40
+ return
41
+ with connect() as con:
42
+ if live:
43
+ con.execute(
44
+ "UPDATE sessions SET status = 'live', last_seen = strftime('%s','now') "
45
+ "WHERE name = ?;", (name,))
46
+ else:
47
+ con.execute("UPDATE sessions SET status = 'unregistered' WHERE name = ?;", (name,))
48
+ except Exception:
49
+ pass
50
+
51
+
52
+ def get_db() -> sqlite3.Connection:
53
+ """Open SQLite connection configured with WAL journal mode and Row factory.
54
+
55
+ The caller owns the connection and MUST close it (``with con:`` only commits). Prefer
56
+ :func:`connect`, which commits/rolls back and closes.
57
+ """
58
+ db_p = get_db_path()
59
+ db_p.parent.mkdir(parents=True, exist_ok=True)
60
+ con = sqlite3.connect(str(db_p), timeout=30.0)
61
+ con.row_factory = sqlite3.Row
62
+ con.execute("PRAGMA journal_mode=WAL;")
63
+ con.execute("PRAGMA synchronous=NORMAL;")
64
+ return con
65
+
66
+
67
+ @contextlib.contextmanager
68
+ def connect() -> Iterator[sqlite3.Connection]:
69
+ """Yield a connection; commit on success, roll back on error, always close."""
70
+ con = get_db()
71
+ try:
72
+ with con:
73
+ yield con
74
+ finally:
75
+ con.close()
76
+
77
+
78
+ # --- schema versioning (PRAGMA user_version) -------------------------------------------
79
+ # v0 = any DB without user_version (the original CREATE TABLE IF NOT EXISTS schema).
80
+ # Table/column names are frozen. Append new steps; never edit old ones.
81
+
82
+
83
+ def _m1_stamp_baseline(con: sqlite3.Connection) -> None:
84
+ """v0 -> v1: baseline schema (created idempotently by _ensure_base_schema); stamp only."""
85
+
86
+
87
+ def _m2_bus_v1(con: sqlite3.Connection) -> None:
88
+ """v1 -> v2 (lite#8): messages.seq (backfilled), bus_cursors, bus_meta, sessions.last_seen."""
89
+ cols = {r[1] for r in con.execute("PRAGMA table_info(messages);")}
90
+ if "seq" not in cols:
91
+ con.execute("ALTER TABLE messages ADD COLUMN seq INTEGER;")
92
+ rows = con.execute("SELECT rowid FROM messages ORDER BY timestamp, rowid;").fetchall()
93
+ for i, r in enumerate(rows, 1):
94
+ con.execute("UPDATE messages SET seq = ? WHERE rowid = ?;", (i, r[0]))
95
+ con.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq);")
96
+ con.execute(
97
+ "CREATE TABLE IF NOT EXISTS bus_cursors ("
98
+ "target TEXT PRIMARY KEY, acked_seq INTEGER NOT NULL DEFAULT 0, updated REAL);"
99
+ )
100
+ con.execute("CREATE TABLE IF NOT EXISTS bus_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);")
101
+ con.execute("INSERT OR REPLACE INTO bus_meta (key, value) VALUES ('last_seq', ?);", (len(rows),))
102
+ scols = {r[1] for r in con.execute("PRAGMA table_info(sessions);")}
103
+ if "last_seen" not in scols:
104
+ con.execute("ALTER TABLE sessions ADD COLUMN last_seen REAL;")
105
+
106
+
107
+ MIGRATIONS = [_m1_stamp_baseline, _m2_bus_v1] # index i migrates v<i> -> v<i+1>
108
+ SCHEMA_VERSION = len(MIGRATIONS)
109
+
110
+ _BASE_DDL = (
111
+ """CREATE TABLE IF NOT EXISTS sessions (
112
+ name TEXT PRIMARY KEY, sock TEXT, cmd TEXT, created_at REAL, status TEXT);""",
113
+ """CREATE TABLE IF NOT EXISTS messages (
114
+ id TEXT PRIMARY KEY, sender TEXT, target TEXT, kind TEXT, summary TEXT,
115
+ path TEXT, content TEXT, timestamp REAL);""",
116
+ """CREATE TABLE IF NOT EXISTS tasks (
117
+ id TEXT PRIMARY KEY, caller TEXT, agent_cmd TEXT, status TEXT, rc INTEGER,
118
+ created_at REAL, completed_at REAL, artifact_dir TEXT);""",
119
+ "CREATE VIRTUAL TABLE IF NOT EXISTS wiki_fts USING fts5(slug, content);",
120
+ )
121
+
122
+
123
+ def migrate(con: sqlite3.Connection) -> int:
124
+ """Bring `con` to SCHEMA_VERSION inside one transaction; refuse a newer DB."""
125
+ cur = con.execute("PRAGMA user_version;").fetchone()[0]
126
+ if cur > SCHEMA_VERSION:
127
+ raise RuntimeError(
128
+ f"lite.db schema version {cur} is newer than this nexus_lite supports "
129
+ f"({SCHEMA_VERSION}); upgrade nexus_lite"
130
+ )
131
+ if cur == SCHEMA_VERSION:
132
+ return cur
133
+ con.execute("BEGIN IMMEDIATE;")
134
+ try:
135
+ cur = con.execute("PRAGMA user_version;").fetchone()[0] # re-read under the write lock
136
+ if cur > SCHEMA_VERSION:
137
+ raise RuntimeError(f"lite.db schema version {cur} is newer than {SCHEMA_VERSION}")
138
+ for ddl in _BASE_DDL:
139
+ con.execute(ddl)
140
+ for i in range(cur, SCHEMA_VERSION):
141
+ MIGRATIONS[i](con)
142
+ con.execute(f"PRAGMA user_version = {SCHEMA_VERSION};")
143
+ con.execute("COMMIT;")
144
+ except BaseException:
145
+ con.execute("ROLLBACK;")
146
+ raise
147
+ return SCHEMA_VERSION
148
+
149
+
150
+ _INIT_DONE: set[str] = set()
151
+ _INIT_LOCK = threading.Lock()
152
+
153
+
154
+ def reset_init() -> None:
155
+ """Forget memoized init() state (tests / explicit re-init)."""
156
+ with _INIT_LOCK:
157
+ _INIT_DONE.clear()
158
+
159
+
160
+ def init(
161
+ workspace: Optional[Union[str, Path]] = None,
162
+ root: Optional[Union[str, Path]] = None,
163
+ force: bool = False,
164
+ ) -> Path:
165
+ """Initialize workspace layout and SQLite schema; once per (process, db path).
166
+
167
+ `force=True` (or :func:`reset_init`) re-runs it.
168
+ """
169
+ target = workspace or root
170
+ if target:
171
+ ws = Path(target).resolve()
172
+ os.environ["NEXUS_LITE_HOME"] = str(ws)
173
+ os.environ["NEXUS_LITE_DIR"] = str(ws)
174
+ else:
175
+ ws = get_workspace()
176
+
177
+ key = str(get_db_path())
178
+ if not force and key in _INIT_DONE and Path(key).exists() and (ws / "inbox").is_dir():
179
+ return ws
180
+
181
+ ws.mkdir(parents=True, exist_ok=True)
182
+ (ws / "inbox").mkdir(parents=True, exist_ok=True)
183
+ (ws / "artifacts").mkdir(parents=True, exist_ok=True)
184
+ (ws / "wiki").mkdir(parents=True, exist_ok=True)
185
+ try:
186
+ from .memory import stm as _stm # lazy: keeps _db import-free of the tiers
187
+
188
+ _stm.ensure_project_node(ws)
189
+ except Exception:
190
+ pass
191
+
192
+ with contextlib.closing(get_db()) as con:
193
+ migrate(con)
194
+ with _INIT_LOCK:
195
+ _INIT_DONE.add(key)
196
+ return ws
nexus_lite/_proc.py ADDED
@@ -0,0 +1,17 @@
1
+ """nexus_lite._proc — reap intentionally detached children. Stdlib only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import threading
7
+
8
+
9
+ def reap_in_background(proc: "subprocess.Popen") -> "subprocess.Popen":
10
+ """Wait for a detached child on a daemon thread so it never lingers as a zombie.
11
+
12
+ The child keeps running independently (start_new_session=True) and outlives this
13
+ process; the thread only collects its exit status if we are still alive. The thread
14
+ holds a reference, so no 'subprocess still running' ResourceWarning is emitted.
15
+ """
16
+ threading.Thread(target=proc.wait, name=f"reap-{proc.pid}", daemon=True).start()
17
+ return proc
@@ -0,0 +1,83 @@
1
+ """Task-artifact layout contract (stdlib only, no nexus_lite imports).
2
+
3
+ One per-task directory holds every artifact of a delegated task:
4
+
5
+ <root>/<task_id>/
6
+ prompt.md input prompt output.md worker stdout (lite name)
7
+ result.md same bytes as output.md (code-style readers)
8
+ meta.json {task_id, agent, status, rc, started, finished}
9
+ rc exit code as text spec.json bg runner spec
10
+ (timeout, runner.pid, runner.log, stderr.log, escalation.json, ...)
11
+
12
+ Root: ``<workspace>/artifacts``. The root is an argument; a downstream
13
+ multi-project host passes its own root to ``task_dir``/``ensure_task_dir``.
14
+
15
+ Env exported to workers (one release of overlap):
16
+ NEXUS_TASK_DIR canonical per-task directory
17
+ NEXUS_ARTIFACT_DIR deprecated alias, same value
18
+ Readers use ``task_dir_from_env``: canonical wins, alias accepted.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from pathlib import Path
25
+ from typing import Mapping, Optional, Union
26
+
27
+ PROMPT = "prompt.md"
28
+ OUTPUT = "output.md"
29
+ RESULT = "result.md"
30
+ META = "meta.json"
31
+ RC = "rc"
32
+ SPEC = "spec.json"
33
+
34
+ TASK_DIR_ENV = "NEXUS_TASK_DIR"
35
+ TASK_DIR_ENV_DEPRECATED = "NEXUS_ARTIFACT_DIR"
36
+
37
+ PathLike = Union[str, "os.PathLike[str]"]
38
+
39
+
40
+ def artifacts_root(workspace: PathLike) -> Path:
41
+ """Default artifact root: <workspace>/artifacts (no env override)."""
42
+ return Path(workspace) / "artifacts"
43
+
44
+
45
+ def task_dir(task_id: str, root: PathLike) -> Path:
46
+ """Pure path: <root>/<task_id>."""
47
+ return Path(root) / task_id
48
+
49
+
50
+ def ensure_task_dir(task_id: str, root: PathLike) -> Path:
51
+ d = task_dir(task_id, root)
52
+ d.mkdir(parents=True, exist_ok=True)
53
+ return d
54
+
55
+
56
+ def task_dir_from_env(env: Optional[Mapping[str, str]] = None) -> Optional[Path]:
57
+ """Per-task dir from env: canonical name wins, deprecated alias accepted."""
58
+ e = os.environ if env is None else env
59
+ v = e.get(TASK_DIR_ENV) or e.get(TASK_DIR_ENV_DEPRECATED)
60
+ return Path(v) if v else None
61
+
62
+
63
+ def export_env(env: dict, art_dir: PathLike) -> dict:
64
+ """Set canonical and deprecated per-task dir vars on ``env`` (in place)."""
65
+ env[TASK_DIR_ENV] = str(art_dir)
66
+ env[TASK_DIR_ENV_DEPRECATED] = str(art_dir)
67
+ return env
68
+
69
+
70
+ def write_result(art_dir: PathLike, output: str, rc: int) -> None:
71
+ """Write output.md, result.md (same content) and rc."""
72
+ d = Path(art_dir)
73
+ (d / OUTPUT).write_text(output, encoding="utf-8")
74
+ (d / RESULT).write_text(output, encoding="utf-8")
75
+ (d / RC).write_text(str(rc), encoding="utf-8")
76
+
77
+
78
+ def write_meta(art_dir: PathLike, *, task_id: str, agent: str, status: str,
79
+ rc: int, started: float, finished: float) -> None:
80
+ (Path(art_dir) / META).write_text(
81
+ json.dumps({"task_id": task_id, "agent": agent, "status": status,
82
+ "rc": rc, "started": started, "finished": finished}),
83
+ encoding="utf-8")