trinity-lite 0.1.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.
@@ -0,0 +1,3 @@
1
+ """Trinity Lite: a minimal public multi-agent task bus."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+
4
+ raise SystemExit(main())
@@ -0,0 +1,136 @@
1
+ """Agent adapters for Trinity Lite workers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .config import string_list
14
+
15
+
16
+ @dataclass
17
+ class AgentSpec:
18
+ agent_id: str
19
+ mode: str = "mock"
20
+ command: list[str] | None = None
21
+ timeout: int = 1800
22
+ roles: list[str] | None = None
23
+ capabilities: list[str] | None = None
24
+ priority: int = 0
25
+
26
+
27
+ class AdapterError(RuntimeError):
28
+ """Raised when an agent adapter fails."""
29
+
30
+
31
+ class BaseAdapter:
32
+ def __init__(self, spec: AgentSpec) -> None:
33
+ self.spec = spec
34
+
35
+ def run(self, task: dict[str, Any]) -> str:
36
+ raise NotImplementedError
37
+
38
+
39
+ class MockAdapter(BaseAdapter):
40
+ def run(self, task: dict[str, Any]) -> str:
41
+ prompt = task["prompt"].strip().splitlines()[0][:120]
42
+ return (
43
+ f"[mock:{self.spec.agent_id}] completed task {task['id']} "
44
+ f"({task.get('task_type') or 'unspecified'}): {prompt}"
45
+ )
46
+
47
+
48
+ class CommandAdapter(BaseAdapter):
49
+ def run(self, task: dict[str, Any]) -> str:
50
+ if not self.spec.command:
51
+ raise AdapterError(f"agent {self.spec.agent_id} has no command")
52
+ command = [self._format_arg(arg, task) for arg in self.spec.command]
53
+ executable = command[0]
54
+ if shutil.which(executable) is None and not Path(executable).exists():
55
+ raise AdapterError(f"executable not found for {self.spec.agent_id}: {executable}")
56
+ uses_prompt_placeholder = any("{prompt}" in arg for arg in self.spec.command)
57
+ completed = subprocess.run(
58
+ command,
59
+ cwd=task["cwd"],
60
+ input=None if uses_prompt_placeholder else task["prompt"],
61
+ text=True,
62
+ capture_output=True,
63
+ timeout=self.spec.timeout,
64
+ shell=False,
65
+ check=False,
66
+ )
67
+ output = (completed.stdout or "").strip()
68
+ stderr = (completed.stderr or "").strip()
69
+ if completed.returncode != 0:
70
+ detail = stderr or output or f"exit code {completed.returncode}"
71
+ raise AdapterError(f"{self.spec.agent_id} failed: {detail}")
72
+ return output or stderr or f"{self.spec.agent_id} completed without output"
73
+
74
+ @staticmethod
75
+ def _format_arg(arg: str, task: dict[str, Any]) -> str:
76
+ return (
77
+ arg.replace("{prompt}", task["prompt"])
78
+ .replace("{cwd}", task["cwd"])
79
+ .replace("{task_id}", task["id"])
80
+ .replace("{task_type}", task.get("task_type") or "")
81
+ )
82
+
83
+
84
+ def default_specs() -> dict[str, AgentSpec]:
85
+ return {
86
+ "codex": AgentSpec(
87
+ agent_id="codex",
88
+ roles=["primary_engineer"],
89
+ capabilities=[
90
+ "architecture_design",
91
+ "code_edit",
92
+ "documentation",
93
+ "project_audit",
94
+ "test_run",
95
+ ],
96
+ priority=80,
97
+ ),
98
+ "claude_code": AgentSpec(
99
+ agent_id="claude_code",
100
+ roles=["reviewer"],
101
+ capabilities=["code_review", "risk_check", "source_scan"],
102
+ priority=70,
103
+ ),
104
+ "hermes": AgentSpec(
105
+ agent_id="hermes",
106
+ roles=["orchestrator", "acceptance"],
107
+ capabilities=["acceptance", "orchestration", "verification"],
108
+ priority=60,
109
+ ),
110
+ }
111
+
112
+
113
+ def load_specs(path: str | os.PathLike[str] | None = None) -> dict[str, AgentSpec]:
114
+ if path is None:
115
+ return default_specs()
116
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
117
+ specs: dict[str, AgentSpec] = {}
118
+ for agent_id, raw in data.get("agents", {}).items():
119
+ specs[agent_id] = AgentSpec(
120
+ agent_id=agent_id,
121
+ mode=raw.get("mode", "mock"),
122
+ command=raw.get("command"),
123
+ timeout=int(raw.get("timeout", 1800)),
124
+ roles=string_list(raw.get("roles")),
125
+ capabilities=string_list(raw.get("capabilities")),
126
+ priority=int(raw.get("priority", 0)),
127
+ )
128
+ return specs or default_specs()
129
+
130
+
131
+ def build_adapter(spec: AgentSpec) -> BaseAdapter:
132
+ if spec.mode == "mock":
133
+ return MockAdapter(spec)
134
+ if spec.mode == "command":
135
+ return CommandAdapter(spec)
136
+ raise AdapterError(f"unknown adapter mode for {spec.agent_id}: {spec.mode}")
trinity_lite/bus.py ADDED
@@ -0,0 +1,229 @@
1
+ """SQLite task and message bus for Trinity Lite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sqlite3
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .guard import GuardError, ensure_inside_roots
13
+ from .paths import default_allowed_roots, default_db_path
14
+
15
+
16
+ MAX_DEPTH = 2
17
+ TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
18
+
19
+
20
+ def utc_now() -> str:
21
+ return datetime.now(timezone.utc).isoformat()
22
+
23
+
24
+ class TrinityBus:
25
+ """Persistent task bus backed by SQLite."""
26
+
27
+ def __init__(
28
+ self,
29
+ db_path: str | os.PathLike[str] | None = None,
30
+ allowed_roots: list[Path] | None = None,
31
+ ) -> None:
32
+ self.db_path = Path(db_path).expanduser() if db_path else default_db_path()
33
+ self.allowed_roots = allowed_roots or default_allowed_roots()
34
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
35
+ self._init_db()
36
+
37
+ def connect(self) -> sqlite3.Connection:
38
+ conn = sqlite3.connect(self.db_path)
39
+ conn.row_factory = sqlite3.Row
40
+ conn.execute("PRAGMA journal_mode=WAL")
41
+ conn.execute("PRAGMA foreign_keys=ON")
42
+ return conn
43
+
44
+ def _init_db(self) -> None:
45
+ with self.connect() as conn:
46
+ conn.execute(
47
+ """
48
+ CREATE TABLE IF NOT EXISTS tasks (
49
+ id TEXT PRIMARY KEY,
50
+ source_agent TEXT NOT NULL,
51
+ target_agent TEXT NOT NULL,
52
+ task_type TEXT,
53
+ prompt TEXT NOT NULL,
54
+ cwd TEXT NOT NULL,
55
+ status TEXT NOT NULL,
56
+ depth INTEGER NOT NULL DEFAULT 0,
57
+ result TEXT,
58
+ error TEXT,
59
+ created_at TEXT NOT NULL,
60
+ started_at TEXT,
61
+ finished_at TEXT,
62
+ heartbeat_at TEXT
63
+ )
64
+ """
65
+ )
66
+ conn.execute(
67
+ """
68
+ CREATE TABLE IF NOT EXISTS messages (
69
+ id TEXT PRIMARY KEY,
70
+ source_agent TEXT NOT NULL,
71
+ target_agent TEXT NOT NULL,
72
+ task_id TEXT,
73
+ message TEXT NOT NULL,
74
+ read INTEGER NOT NULL DEFAULT 0,
75
+ created_at TEXT NOT NULL
76
+ )
77
+ """
78
+ )
79
+
80
+ def submit_task(
81
+ self,
82
+ source_agent: str,
83
+ target_agent: str,
84
+ prompt: str,
85
+ task_type: str | None = None,
86
+ cwd: str | os.PathLike[str] | None = None,
87
+ depth: int = 0,
88
+ ) -> dict[str, Any]:
89
+ if source_agent == target_agent:
90
+ raise GuardError("self-delegation is not allowed")
91
+ if depth > MAX_DEPTH:
92
+ raise GuardError(f"delegation depth exceeds max depth {MAX_DEPTH}")
93
+ workdir = ensure_inside_roots(cwd or os.getcwd(), self.allowed_roots)
94
+ task_id = uuid.uuid4().hex[:12]
95
+ now = utc_now()
96
+ with self.connect() as conn:
97
+ conn.execute(
98
+ """
99
+ INSERT INTO tasks (
100
+ id, source_agent, target_agent, task_type, prompt, cwd,
101
+ status, depth, created_at, heartbeat_at
102
+ )
103
+ VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?)
104
+ """,
105
+ (
106
+ task_id,
107
+ source_agent,
108
+ target_agent,
109
+ task_type,
110
+ prompt,
111
+ str(workdir),
112
+ depth,
113
+ now,
114
+ now,
115
+ ),
116
+ )
117
+ return self.get_task(task_id)
118
+
119
+ def get_task(self, task_id: str) -> dict[str, Any]:
120
+ with self.connect() as conn:
121
+ row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
122
+ if row is None:
123
+ raise KeyError(f"task not found: {task_id}")
124
+ return dict(row)
125
+
126
+ def list_tasks(self, agent: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
127
+ sql = "SELECT * FROM tasks"
128
+ params: list[Any] = []
129
+ if agent:
130
+ sql += " WHERE source_agent = ? OR target_agent = ?"
131
+ params.extend([agent, agent])
132
+ sql += " ORDER BY created_at DESC LIMIT ?"
133
+ params.append(limit)
134
+ with self.connect() as conn:
135
+ rows = conn.execute(sql, params).fetchall()
136
+ return [dict(r) for r in rows]
137
+
138
+ def task_for_worker(self, target_agent: str) -> dict[str, Any] | None:
139
+ now = utc_now()
140
+ with self.connect() as conn:
141
+ conn.execute("BEGIN IMMEDIATE")
142
+ row = conn.execute(
143
+ """
144
+ SELECT * FROM tasks
145
+ WHERE target_agent = ? AND status = 'queued'
146
+ ORDER BY created_at ASC
147
+ LIMIT 1
148
+ """,
149
+ (target_agent,),
150
+ ).fetchone()
151
+ if row is None:
152
+ conn.commit()
153
+ return None
154
+ conn.execute(
155
+ """
156
+ UPDATE tasks
157
+ SET status = 'running', started_at = ?, heartbeat_at = ?
158
+ WHERE id = ? AND status = 'queued'
159
+ """,
160
+ (now, now, row["id"]),
161
+ )
162
+ conn.commit()
163
+ return self.get_task(row["id"])
164
+
165
+ def finish_worker(
166
+ self,
167
+ task_id: str,
168
+ result: str | None = None,
169
+ error: str | None = None,
170
+ ) -> dict[str, Any]:
171
+ status = "failed" if error else "completed"
172
+ now = utc_now()
173
+ with self.connect() as conn:
174
+ conn.execute(
175
+ """
176
+ UPDATE tasks
177
+ SET status = ?, result = ?, error = ?, finished_at = ?, heartbeat_at = ?
178
+ WHERE id = ?
179
+ """,
180
+ (status, result, error, now, now, task_id),
181
+ )
182
+ return self.get_task(task_id)
183
+
184
+ def send_message(
185
+ self,
186
+ source_agent: str,
187
+ target_agent: str,
188
+ message: str,
189
+ task_id: str | None = None,
190
+ ) -> dict[str, Any]:
191
+ if source_agent == target_agent:
192
+ raise GuardError("self-messaging is not allowed")
193
+ msg_id = uuid.uuid4().hex[:12]
194
+ with self.connect() as conn:
195
+ conn.execute(
196
+ """
197
+ INSERT INTO messages (
198
+ id, source_agent, target_agent, task_id, message, read, created_at
199
+ )
200
+ VALUES (?, ?, ?, ?, ?, 0, ?)
201
+ """,
202
+ (msg_id, source_agent, target_agent, task_id, message, utc_now()),
203
+ )
204
+ row = conn.execute("SELECT * FROM messages WHERE id = ?", (msg_id,)).fetchone()
205
+ return dict(row)
206
+
207
+ def inbox(
208
+ self,
209
+ agent: str,
210
+ unread_only: bool = True,
211
+ mark_read: bool = False,
212
+ limit: int = 20,
213
+ ) -> list[dict[str, Any]]:
214
+ sql = "SELECT * FROM messages WHERE target_agent = ?"
215
+ params: list[Any] = [agent]
216
+ if unread_only:
217
+ sql += " AND read = 0"
218
+ sql += " ORDER BY created_at DESC LIMIT ?"
219
+ params.append(limit)
220
+ with self.connect() as conn:
221
+ rows = conn.execute(sql, params).fetchall()
222
+ messages = [dict(r) for r in rows]
223
+ if mark_read and messages:
224
+ for message in messages:
225
+ conn.execute(
226
+ "UPDATE messages SET read = 1 WHERE id = ?",
227
+ (message["id"],),
228
+ )
229
+ return messages
trinity_lite/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ """Command line interface for Trinity Lite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ from typing import Any
9
+
10
+ from .bus import TrinityBus
11
+ from .doctor import run_doctor
12
+ from .orchestrator import run_review_flow
13
+ from .router import resolve_route
14
+ from .worker import run_loop, run_once
15
+
16
+
17
+ def print_json(data: Any) -> None:
18
+ print(json.dumps(data, ensure_ascii=False, indent=2))
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ parser = argparse.ArgumentParser(prog="trinity-lite")
23
+ parser.add_argument("--db", default=None, help="SQLite database path")
24
+ parser.add_argument("--routes", default=None, help="routes JSON path")
25
+ parser.add_argument("--agents", default=None, help="agents JSON path")
26
+ common = argparse.ArgumentParser(add_help=False)
27
+ common.add_argument("--db", default=argparse.SUPPRESS, help="SQLite database path")
28
+ common.add_argument("--routes", default=argparse.SUPPRESS, help="routes JSON path")
29
+ common.add_argument("--agents", default=argparse.SUPPRESS, help="agents JSON path")
30
+ sub = parser.add_subparsers(dest="command", required=True)
31
+
32
+ route = sub.add_parser("route", parents=[common], help="resolve a route without dispatching")
33
+ route.add_argument("task")
34
+ route.add_argument("--type", dest="task_type")
35
+ route.add_argument("--previous-agent")
36
+
37
+ dispatch = sub.add_parser("dispatch", parents=[common], help="dispatch to an explicit agent")
38
+ dispatch.add_argument("target_agent")
39
+ dispatch.add_argument("task")
40
+ dispatch.add_argument("--source", default="user")
41
+ dispatch.add_argument("--type", dest="task_type")
42
+ dispatch.add_argument("--cwd", default=os.getcwd())
43
+
44
+ auto = sub.add_parser("dispatch-auto", parents=[common], help="resolve route then dispatch")
45
+ auto.add_argument("task")
46
+ auto.add_argument("--source", default="user")
47
+ auto.add_argument("--type", dest="task_type")
48
+ auto.add_argument("--previous-agent")
49
+ auto.add_argument("--cwd", default=os.getcwd())
50
+
51
+ orchestrate = sub.add_parser("orchestrate", parents=[common], help="dispatch and run a primary task with optional review")
52
+ orchestrate.add_argument("task")
53
+ orchestrate.add_argument("--source", default="user")
54
+ orchestrate.add_argument("--type", dest="task_type")
55
+ orchestrate.add_argument("--previous-agent")
56
+ orchestrate.add_argument("--cwd", default=os.getcwd())
57
+ orchestrate.add_argument("--no-run", action="store_true", help="dispatch the primary task without running workers")
58
+
59
+ status = sub.add_parser("status", parents=[common], help="show task status")
60
+ status.add_argument("task_id")
61
+
62
+ tasks = sub.add_parser("tasks", parents=[common], help="list recent tasks")
63
+ tasks.add_argument("--agent")
64
+ tasks.add_argument("--limit", type=int, default=20)
65
+
66
+ worker = sub.add_parser("worker", parents=[common], help="run a worker for one agent")
67
+ worker.add_argument("agent")
68
+ worker.add_argument("--once", action="store_true")
69
+ worker.add_argument("--poll", type=float, default=2.0)
70
+
71
+ send = sub.add_parser("send", parents=[common], help="send a durable message")
72
+ send.add_argument("target_agent")
73
+ send.add_argument("message")
74
+ send.add_argument("--source", default="user")
75
+ send.add_argument("--task-id")
76
+
77
+ inbox = sub.add_parser("inbox", parents=[common], help="read durable messages")
78
+ inbox.add_argument("agent")
79
+ inbox.add_argument("--all", action="store_true")
80
+ inbox.add_argument("--mark-read", action="store_true")
81
+ inbox.add_argument("--limit", type=int, default=20)
82
+
83
+ doctor = sub.add_parser("doctor", parents=[common], help="run environment checks")
84
+ doctor.add_argument("--scan-root")
85
+ doctor.add_argument("--runtime-root", help="runtime state directory for hygiene checks")
86
+ doctor.add_argument(
87
+ "--retired-port",
88
+ action="append",
89
+ type=int,
90
+ default=[],
91
+ help="TCP port that should not be listening, repeatable",
92
+ )
93
+
94
+ return parser
95
+
96
+
97
+ def main(argv: list[str] | None = None) -> int:
98
+ args = build_parser().parse_args(argv)
99
+ try:
100
+ return run_command(args)
101
+ except (KeyError, OSError, ValueError) as exc:
102
+ print_json({"error": str(exc)})
103
+ return 2
104
+
105
+
106
+ def run_command(args: argparse.Namespace) -> int:
107
+ bus = TrinityBus(args.db)
108
+
109
+ if args.command == "route":
110
+ print_json(resolve_route(args.task, args.task_type, args.previous_agent, args.routes, args.agents))
111
+ return 0
112
+ if args.command == "dispatch":
113
+ print_json(bus.submit_task(
114
+ source_agent=args.source,
115
+ target_agent=args.target_agent,
116
+ prompt=args.task,
117
+ task_type=args.task_type,
118
+ cwd=args.cwd,
119
+ ))
120
+ return 0
121
+ if args.command == "dispatch-auto":
122
+ route = resolve_route(args.task, args.task_type, args.previous_agent, args.routes, args.agents)
123
+ task = bus.submit_task(
124
+ source_agent=args.source,
125
+ target_agent=route["agent"],
126
+ prompt=args.task,
127
+ task_type=route["task_type"],
128
+ cwd=args.cwd,
129
+ )
130
+ task["route"] = route
131
+ print_json(task)
132
+ return 0
133
+ if args.command == "orchestrate":
134
+ print_json(run_review_flow(
135
+ args.task,
136
+ bus,
137
+ args.routes,
138
+ args.agents,
139
+ args.source,
140
+ args.task_type,
141
+ args.previous_agent,
142
+ args.cwd,
143
+ run_workers=not args.no_run,
144
+ ))
145
+ return 0
146
+ if args.command == "status":
147
+ print_json(bus.get_task(args.task_id))
148
+ return 0
149
+ if args.command == "tasks":
150
+ print_json(bus.list_tasks(args.agent, args.limit))
151
+ return 0
152
+ if args.command == "worker":
153
+ if args.once:
154
+ print_json(run_once(args.agent, bus, args.agents))
155
+ return 0
156
+ run_loop(args.agent, bus, args.agents, args.poll)
157
+ return 0
158
+ if args.command == "send":
159
+ print_json(bus.send_message(args.source, args.target_agent, args.message, args.task_id))
160
+ return 0
161
+ if args.command == "inbox":
162
+ print_json(bus.inbox(args.agent, unread_only=not args.all, mark_read=args.mark_read, limit=args.limit))
163
+ return 0
164
+ if args.command == "doctor":
165
+ print_json(run_doctor(
166
+ args.db,
167
+ args.routes,
168
+ args.agents,
169
+ args.scan_root,
170
+ args.runtime_root,
171
+ args.retired_port,
172
+ ))
173
+ return 0
174
+ raise AssertionError(f"unhandled command: {args.command}")
175
+
176
+
177
+ if __name__ == "__main__":
178
+ raise SystemExit(main())
trinity_lite/config.py ADDED
@@ -0,0 +1,16 @@
1
+ """Configuration normalization helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def string_list(value: Any) -> list[str]:
9
+ """Return a string list from a string, list, or absent value."""
10
+ if value is None:
11
+ return []
12
+ if isinstance(value, str):
13
+ return [value]
14
+ if isinstance(value, list):
15
+ return [str(item) for item in value]
16
+ raise ValueError(f"expected string or list of strings, got {type(value).__name__}")