trinity-lite 0.1.2__tar.gz → 0.1.4__tar.gz
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.
- {trinity_lite-0.1.2/trinity_lite.egg-info → trinity_lite-0.1.4}/PKG-INFO +19 -2
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/README.md +18 -1
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/pyproject.toml +1 -1
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/tests/test_bus.py +13 -0
- trinity_lite-0.1.4/tests/test_cli.py +323 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/bus.py +11 -0
- trinity_lite-0.1.4/trinity_lite/cli.py +440 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4/trinity_lite.egg-info}/PKG-INFO +19 -2
- trinity_lite-0.1.2/tests/test_cli.py +0 -150
- trinity_lite-0.1.2/trinity_lite/cli.py +0 -175
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/LICENSE +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/setup.cfg +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/tests/test_doctor.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/tests/test_guard.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/tests/test_orchestrator.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/tests/test_router.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/tests/test_worker.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/__init__.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/__main__.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/adapters.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/config.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/doctor.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/guard.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/orchestrator.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/paths.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/py.typed +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/router.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/validation.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite/worker.py +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite.egg-info/SOURCES.txt +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite.egg-info/dependency_links.txt +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite.egg-info/entry_points.txt +0 -0
- {trinity_lite-0.1.2 → trinity_lite-0.1.4}/trinity_lite.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trinity-lite
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A minimal public multi-agent task bus with routing, workers, and safety checks.
|
|
5
5
|
Author: Yomiracle
|
|
6
6
|
License-Expression: MIT
|
|
@@ -155,7 +155,24 @@ trinity-lite worker codex --once
|
|
|
155
155
|
trinity-lite tasks
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
## Upgrading
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
python3 -m pip install --upgrade trinity-lite
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Trinity Lite follows [semantic versioning](https://semver.org). Patch releases (0.1.x) are backward-compatible
|
|
165
|
+
and require no configuration or data migration. Check your version:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
trinity-lite doctor
|
|
169
|
+
# or
|
|
170
|
+
python3 -m pip show trinity-lite | grep Version
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
See [CHANGELOG.md](CHANGELOG.md) for what changed between versions.
|
|
174
|
+
|
|
175
|
+
## Quick Start (from source)
|
|
159
176
|
|
|
160
177
|
```bash
|
|
161
178
|
git clone https://github.com/Yomiracle/trinity-lite.git
|
|
@@ -134,7 +134,24 @@ trinity-lite worker codex --once
|
|
|
134
134
|
trinity-lite tasks
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
## Upgrading
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
python3 -m pip install --upgrade trinity-lite
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Trinity Lite follows [semantic versioning](https://semver.org). Patch releases (0.1.x) are backward-compatible
|
|
144
|
+
and require no configuration or data migration. Check your version:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
trinity-lite doctor
|
|
148
|
+
# or
|
|
149
|
+
python3 -m pip show trinity-lite | grep Version
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
See [CHANGELOG.md](CHANGELOG.md) for what changed between versions.
|
|
153
|
+
|
|
154
|
+
## Quick Start (from source)
|
|
138
155
|
|
|
139
156
|
```bash
|
|
140
157
|
git clone https://github.com/Yomiracle/trinity-lite.git
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "trinity-lite"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "A minimal public multi-agent task bus with routing, workers, and safety checks."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -61,6 +61,19 @@ class BusTest(unittest.TestCase):
|
|
|
61
61
|
with self.assertRaises(KeyError):
|
|
62
62
|
self.bus.get_task("rollback-test")
|
|
63
63
|
|
|
64
|
+
def test_await_task_returns_on_completion(self):
|
|
65
|
+
task = self.bus.submit_task("user", "codex", "build it", "implementation", cwd=self.root)
|
|
66
|
+
self.bus.task_for_worker("codex")
|
|
67
|
+
self.bus.finish_worker(task["id"], result="ok")
|
|
68
|
+
result = self.bus.await_task(task["id"], timeout=5)
|
|
69
|
+
self.assertEqual(result["status"], "completed")
|
|
70
|
+
self.assertEqual(result["result"], "ok")
|
|
71
|
+
|
|
72
|
+
def test_await_task_timeout_raises(self):
|
|
73
|
+
task = self.bus.submit_task("user", "codex", "slow task", "implementation", cwd=self.root)
|
|
74
|
+
with self.assertRaises(TimeoutError):
|
|
75
|
+
self.bus.await_task(task["id"], timeout=0.1)
|
|
76
|
+
|
|
64
77
|
def test_message_round_trip(self):
|
|
65
78
|
msg = self.bus.send_message("codex", "claude_code", "please review")
|
|
66
79
|
self.assertEqual(msg["read"], 0)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from contextlib import redirect_stdout
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from trinity_lite.cli import main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CliTest(unittest.TestCase):
|
|
12
|
+
def test_global_options_work_after_subcommand(self):
|
|
13
|
+
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
14
|
+
root = Path(tmp)
|
|
15
|
+
db = root / "bus.db"
|
|
16
|
+
output = io.StringIO()
|
|
17
|
+
with redirect_stdout(output):
|
|
18
|
+
code = main([
|
|
19
|
+
"dispatch-auto",
|
|
20
|
+
"implement a parser",
|
|
21
|
+
"--db",
|
|
22
|
+
str(db),
|
|
23
|
+
"--cwd",
|
|
24
|
+
str(root),
|
|
25
|
+
])
|
|
26
|
+
self.assertEqual(code, 0)
|
|
27
|
+
code = main(["worker", "codex", "--once", "--db", str(db)])
|
|
28
|
+
self.assertEqual(code, 0)
|
|
29
|
+
|
|
30
|
+
def test_route_errors_are_structured(self):
|
|
31
|
+
output = io.StringIO()
|
|
32
|
+
with redirect_stdout(output):
|
|
33
|
+
code = main(["route", "review this patch"])
|
|
34
|
+
self.assertEqual(code, 2)
|
|
35
|
+
self.assertIn('"error"', output.getvalue())
|
|
36
|
+
|
|
37
|
+
def test_doctor_accepts_runtime_hygiene_options(self):
|
|
38
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
39
|
+
root = Path(tmp)
|
|
40
|
+
runtime = root / "runtime"
|
|
41
|
+
runtime.mkdir()
|
|
42
|
+
(runtime / "metrics.jsonl").write_text("", encoding="utf-8")
|
|
43
|
+
output = io.StringIO()
|
|
44
|
+
with redirect_stdout(output):
|
|
45
|
+
code = main([
|
|
46
|
+
"doctor",
|
|
47
|
+
"--db",
|
|
48
|
+
str(root / "bus.db"),
|
|
49
|
+
"--runtime-root",
|
|
50
|
+
str(runtime),
|
|
51
|
+
"--retired-port",
|
|
52
|
+
"70000",
|
|
53
|
+
])
|
|
54
|
+
self.assertEqual(code, 0)
|
|
55
|
+
self.assertIn('"runtime_metrics"', output.getvalue())
|
|
56
|
+
self.assertIn('"retired_port:70000"', output.getvalue())
|
|
57
|
+
|
|
58
|
+
def test_dispatch_wait_blocks_until_complete(self):
|
|
59
|
+
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
60
|
+
root = Path(tmp)
|
|
61
|
+
output = io.StringIO()
|
|
62
|
+
with redirect_stdout(output):
|
|
63
|
+
code = main([
|
|
64
|
+
"dispatch",
|
|
65
|
+
"codex",
|
|
66
|
+
"implement hello world",
|
|
67
|
+
"--db",
|
|
68
|
+
str(root / "bus.db"),
|
|
69
|
+
"--cwd",
|
|
70
|
+
str(root),
|
|
71
|
+
"--wait",
|
|
72
|
+
"--wait-timeout",
|
|
73
|
+
"5",
|
|
74
|
+
])
|
|
75
|
+
self.assertEqual(code, 0)
|
|
76
|
+
data = json.loads(output.getvalue())
|
|
77
|
+
self.assertEqual(data["status"], "completed")
|
|
78
|
+
self.assertIn("target_agent", data)
|
|
79
|
+
self.assertEqual(data["target_agent"], "codex")
|
|
80
|
+
|
|
81
|
+
def test_dispatch_auto_wait_blocks_until_complete(self):
|
|
82
|
+
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
83
|
+
root = Path(tmp)
|
|
84
|
+
agents_path = root / "agents.json"
|
|
85
|
+
routes_path = root / "routes.json"
|
|
86
|
+
agents_path.write_text(json.dumps({
|
|
87
|
+
"agents": {
|
|
88
|
+
"qwen_cli": {
|
|
89
|
+
"mode": "mock",
|
|
90
|
+
"roles": ["primary_engineer"],
|
|
91
|
+
"capabilities": ["code_edit"],
|
|
92
|
+
"priority": 80,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}), encoding="utf-8")
|
|
96
|
+
routes_path.write_text(json.dumps({
|
|
97
|
+
"routes": {
|
|
98
|
+
"implementation": {
|
|
99
|
+
"requires": ["code_edit"],
|
|
100
|
+
"prefer": ["primary_engineer"],
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"opposites": {}
|
|
104
|
+
}), encoding="utf-8")
|
|
105
|
+
output = io.StringIO()
|
|
106
|
+
with redirect_stdout(output):
|
|
107
|
+
code = main([
|
|
108
|
+
"dispatch-auto",
|
|
109
|
+
"implement a parser",
|
|
110
|
+
"--db",
|
|
111
|
+
str(root / "bus.db"),
|
|
112
|
+
"--cwd",
|
|
113
|
+
str(root),
|
|
114
|
+
"--agents",
|
|
115
|
+
str(agents_path),
|
|
116
|
+
"--routes",
|
|
117
|
+
str(routes_path),
|
|
118
|
+
"--wait",
|
|
119
|
+
"--wait-timeout",
|
|
120
|
+
"5",
|
|
121
|
+
])
|
|
122
|
+
self.assertEqual(code, 0)
|
|
123
|
+
data = json.loads(output.getvalue())
|
|
124
|
+
self.assertEqual(data["status"], "completed")
|
|
125
|
+
self.assertEqual(data["target_agent"], "qwen_cli")
|
|
126
|
+
|
|
127
|
+
def test_dispatch_auto_uses_capability_routes_and_agents(self):
|
|
128
|
+
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
129
|
+
root = Path(tmp)
|
|
130
|
+
agents_path = root / "agents.json"
|
|
131
|
+
routes_path = root / "routes.json"
|
|
132
|
+
agents_path.write_text(json.dumps({
|
|
133
|
+
"agents": {
|
|
134
|
+
"qwen_cli": {
|
|
135
|
+
"mode": "mock",
|
|
136
|
+
"roles": ["primary_engineer"],
|
|
137
|
+
"capabilities": ["code_edit"],
|
|
138
|
+
"priority": 80,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}), encoding="utf-8")
|
|
142
|
+
routes_path.write_text(json.dumps({
|
|
143
|
+
"routes": {
|
|
144
|
+
"implementation": {
|
|
145
|
+
"requires": ["code_edit"],
|
|
146
|
+
"prefer": ["primary_engineer"],
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
"opposites": {}
|
|
150
|
+
}), encoding="utf-8")
|
|
151
|
+
output = io.StringIO()
|
|
152
|
+
with redirect_stdout(output):
|
|
153
|
+
code = main([
|
|
154
|
+
"dispatch-auto",
|
|
155
|
+
"implement a parser",
|
|
156
|
+
"--db",
|
|
157
|
+
str(root / "bus.db"),
|
|
158
|
+
"--cwd",
|
|
159
|
+
str(root),
|
|
160
|
+
"--agents",
|
|
161
|
+
str(agents_path),
|
|
162
|
+
"--routes",
|
|
163
|
+
str(routes_path),
|
|
164
|
+
])
|
|
165
|
+
self.assertEqual(code, 0)
|
|
166
|
+
self.assertIn('"target_agent": "qwen_cli"', output.getvalue())
|
|
167
|
+
|
|
168
|
+
def test_orchestrate_uses_capability_routes_and_agents(self):
|
|
169
|
+
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
170
|
+
root = Path(tmp)
|
|
171
|
+
agents_path = root / "agents.json"
|
|
172
|
+
routes_path = root / "routes.json"
|
|
173
|
+
agents_path.write_text(json.dumps({
|
|
174
|
+
"agents": {
|
|
175
|
+
"qwen_cli": {
|
|
176
|
+
"mode": "mock",
|
|
177
|
+
"capabilities": ["code_edit"],
|
|
178
|
+
},
|
|
179
|
+
"gemini_cli": {
|
|
180
|
+
"mode": "mock",
|
|
181
|
+
"capabilities": ["code_review"],
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
}), encoding="utf-8")
|
|
185
|
+
routes_path.write_text(json.dumps({
|
|
186
|
+
"routes": {
|
|
187
|
+
"implementation": {
|
|
188
|
+
"requires": ["code_edit"],
|
|
189
|
+
"review_required": True,
|
|
190
|
+
},
|
|
191
|
+
"code_review": {
|
|
192
|
+
"requires": ["code_review"],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
"opposites": {}
|
|
196
|
+
}), encoding="utf-8")
|
|
197
|
+
output = io.StringIO()
|
|
198
|
+
with redirect_stdout(output):
|
|
199
|
+
code = main([
|
|
200
|
+
"orchestrate",
|
|
201
|
+
"implement parser",
|
|
202
|
+
"--db",
|
|
203
|
+
str(root / "bus.db"),
|
|
204
|
+
"--cwd",
|
|
205
|
+
str(root),
|
|
206
|
+
"--agents",
|
|
207
|
+
str(agents_path),
|
|
208
|
+
"--routes",
|
|
209
|
+
str(routes_path),
|
|
210
|
+
])
|
|
211
|
+
self.assertEqual(code, 0)
|
|
212
|
+
text = output.getvalue()
|
|
213
|
+
self.assertIn('"target_agent": "qwen_cli"', text)
|
|
214
|
+
self.assertIn('"target_agent": "gemini_cli"', text)
|
|
215
|
+
self.assertIn('"acceptance_status": "accepted"', text)
|
|
216
|
+
|
|
217
|
+
def test_orchestrate_wait_runs_workers_and_completes(self):
|
|
218
|
+
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
219
|
+
root = Path(tmp)
|
|
220
|
+
agents_path = root / "agents.json"
|
|
221
|
+
routes_path = root / "routes.json"
|
|
222
|
+
agents_path.write_text(json.dumps({
|
|
223
|
+
"agents": {
|
|
224
|
+
"qwen_cli": {
|
|
225
|
+
"mode": "mock",
|
|
226
|
+
"capabilities": ["code_edit"],
|
|
227
|
+
},
|
|
228
|
+
"gemini_cli": {
|
|
229
|
+
"mode": "mock",
|
|
230
|
+
"capabilities": ["code_review"],
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
}), encoding="utf-8")
|
|
234
|
+
routes_path.write_text(json.dumps({
|
|
235
|
+
"routes": {
|
|
236
|
+
"implementation": {
|
|
237
|
+
"requires": ["code_edit"],
|
|
238
|
+
"review_required": True,
|
|
239
|
+
},
|
|
240
|
+
"code_review": {
|
|
241
|
+
"requires": ["code_review"],
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
"opposites": {}
|
|
245
|
+
}), encoding="utf-8")
|
|
246
|
+
output = io.StringIO()
|
|
247
|
+
with redirect_stdout(output):
|
|
248
|
+
code = main([
|
|
249
|
+
"orchestrate",
|
|
250
|
+
"implement parser",
|
|
251
|
+
"--db",
|
|
252
|
+
str(root / "bus.db"),
|
|
253
|
+
"--cwd",
|
|
254
|
+
str(root),
|
|
255
|
+
"--agents",
|
|
256
|
+
str(agents_path),
|
|
257
|
+
"--routes",
|
|
258
|
+
str(routes_path),
|
|
259
|
+
"--wait",
|
|
260
|
+
"--wait-timeout",
|
|
261
|
+
"10",
|
|
262
|
+
])
|
|
263
|
+
self.assertEqual(code, 0)
|
|
264
|
+
text = output.getvalue()
|
|
265
|
+
self.assertIn('"acceptance_status": "accepted"', text)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_demo_command_produces_friendly_output(self):
|
|
269
|
+
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
270
|
+
root = Path(tmp)
|
|
271
|
+
output = io.StringIO()
|
|
272
|
+
with redirect_stdout(output):
|
|
273
|
+
code = main([
|
|
274
|
+
"demo",
|
|
275
|
+
"--db",
|
|
276
|
+
str(root / "bus.db"),
|
|
277
|
+
])
|
|
278
|
+
self.assertEqual(code, 0)
|
|
279
|
+
text = output.getvalue()
|
|
280
|
+
self.assertIn("Trinity Lite Demo", text)
|
|
281
|
+
self.assertIn("Next steps:", text)
|
|
282
|
+
self.assertIn("trinity-lite tasks", text)
|
|
283
|
+
self.assertIn("docs/REAL_AGENTS.md", text)
|
|
284
|
+
|
|
285
|
+
def test_version_command_prints_version(self):
|
|
286
|
+
output = io.StringIO()
|
|
287
|
+
with redirect_stdout(output):
|
|
288
|
+
code = main(["version"])
|
|
289
|
+
self.assertEqual(code, 0)
|
|
290
|
+
self.assertIn("trinity-lite", output.getvalue())
|
|
291
|
+
|
|
292
|
+
def test_version_flag_prints_version(self):
|
|
293
|
+
output = io.StringIO()
|
|
294
|
+
with redirect_stdout(output):
|
|
295
|
+
code = main(["--version"])
|
|
296
|
+
self.assertEqual(code, 0)
|
|
297
|
+
self.assertIn("trinity-lite", output.getvalue())
|
|
298
|
+
|
|
299
|
+
def test_no_args_shows_banner(self):
|
|
300
|
+
output = io.StringIO()
|
|
301
|
+
with redirect_stdout(output):
|
|
302
|
+
code = main([])
|
|
303
|
+
self.assertEqual(code, 0)
|
|
304
|
+
text = output.getvalue()
|
|
305
|
+
self.assertIn("Trinity Lite", text)
|
|
306
|
+
self.assertIn("Quick demo", text)
|
|
307
|
+
self.assertIn("trinity-lite demo", text)
|
|
308
|
+
|
|
309
|
+
def test_help_flag_works(self):
|
|
310
|
+
import sys
|
|
311
|
+
output = io.StringIO()
|
|
312
|
+
with redirect_stdout(output):
|
|
313
|
+
try:
|
|
314
|
+
main(["--help"])
|
|
315
|
+
except SystemExit as e:
|
|
316
|
+
self.assertEqual(e.code, 0)
|
|
317
|
+
text = output.getvalue()
|
|
318
|
+
self.assertIn("demo", text)
|
|
319
|
+
self.assertIn("version", text)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
unittest.main()
|
|
@@ -216,6 +216,17 @@ class TrinityBus:
|
|
|
216
216
|
row = conn.execute("SELECT * FROM messages WHERE id = ?", (msg_id,)).fetchone()
|
|
217
217
|
return dict(row)
|
|
218
218
|
|
|
219
|
+
def await_task(self, task_id: str, timeout: float = 300) -> dict[str, Any]:
|
|
220
|
+
"""Block until task reaches terminal status or timeout."""
|
|
221
|
+
import time
|
|
222
|
+
deadline = time.monotonic() + timeout
|
|
223
|
+
while time.monotonic() < deadline:
|
|
224
|
+
task = self.get_task(task_id)
|
|
225
|
+
if task["status"] in TERMINAL_STATUSES:
|
|
226
|
+
return task
|
|
227
|
+
time.sleep(0.2)
|
|
228
|
+
raise TimeoutError(f"task {task_id} did not finish within {timeout}s")
|
|
229
|
+
|
|
219
230
|
def inbox(
|
|
220
231
|
self,
|
|
221
232
|
agent: str,
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Command line interface for Trinity Lite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .bus import TrinityBus
|
|
13
|
+
from .doctor import run_doctor
|
|
14
|
+
from .orchestrator import run_review_flow
|
|
15
|
+
from .router import resolve_route
|
|
16
|
+
from .worker import run_loop, run_once
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def print_json(data: Any) -> None:
|
|
20
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _version() -> str:
|
|
24
|
+
"""Return the installed version, favouring importlib.metadata."""
|
|
25
|
+
try:
|
|
26
|
+
from importlib.metadata import PackageNotFoundError, version # Python >= 3.8
|
|
27
|
+
|
|
28
|
+
return version("trinity-lite")
|
|
29
|
+
except (ImportError, PackageNotFoundError):
|
|
30
|
+
from . import __version__
|
|
31
|
+
|
|
32
|
+
return __version__ # pragma: no cover — fallback for development
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
BANNER = """\
|
|
36
|
+
Trinity Lite — multi-agent task bus
|
|
37
|
+
====================================
|
|
38
|
+
|
|
39
|
+
Quick demo: trinity-lite demo
|
|
40
|
+
Full help: trinity-lite --help
|
|
41
|
+
|
|
42
|
+
Commands: demo, dispatch, dispatch-auto, worker, orchestrate,
|
|
43
|
+
status, tasks, route, doctor, send, inbox"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
47
|
+
parser = argparse.ArgumentParser(prog="trinity-lite")
|
|
48
|
+
parser.add_argument("--version", action="store_true", help="show version and exit")
|
|
49
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
50
|
+
common.add_argument("--db", default=None, help="SQLite database path")
|
|
51
|
+
common.add_argument("--routes", default=None, help="routes JSON path")
|
|
52
|
+
common.add_argument("--agents", default=None, help="agents JSON path")
|
|
53
|
+
sub = parser.add_subparsers(dest="command")
|
|
54
|
+
|
|
55
|
+
route = sub.add_parser("route", parents=[common], help="resolve a route without dispatching")
|
|
56
|
+
route.add_argument("task")
|
|
57
|
+
route.add_argument("--type", dest="task_type")
|
|
58
|
+
route.add_argument("--previous-agent")
|
|
59
|
+
|
|
60
|
+
dispatch = sub.add_parser("dispatch", parents=[common], help="dispatch to an explicit agent")
|
|
61
|
+
dispatch.add_argument("target_agent")
|
|
62
|
+
dispatch.add_argument("task")
|
|
63
|
+
dispatch.add_argument("--source", default="user")
|
|
64
|
+
dispatch.add_argument("--type", dest="task_type")
|
|
65
|
+
dispatch.add_argument("--cwd", default=os.getcwd())
|
|
66
|
+
dispatch.add_argument("--wait", action="store_true", help="block until task completes and print final result")
|
|
67
|
+
dispatch.add_argument("--wait-timeout", type=float, default=300, help="timeout in seconds for --wait (default: 300)")
|
|
68
|
+
|
|
69
|
+
auto = sub.add_parser("dispatch-auto", parents=[common], help="resolve route then dispatch")
|
|
70
|
+
auto.add_argument("task")
|
|
71
|
+
auto.add_argument("--source", default="user")
|
|
72
|
+
auto.add_argument("--type", dest="task_type")
|
|
73
|
+
auto.add_argument("--previous-agent")
|
|
74
|
+
auto.add_argument("--cwd", default=os.getcwd())
|
|
75
|
+
auto.add_argument("--wait", action="store_true", help="block until task completes and print final result")
|
|
76
|
+
auto.add_argument("--wait-timeout", type=float, default=300, help="timeout in seconds for --wait (default: 300)")
|
|
77
|
+
|
|
78
|
+
orchestrate = sub.add_parser("orchestrate", parents=[common], help="dispatch and run a primary task with optional review")
|
|
79
|
+
orchestrate.add_argument("task")
|
|
80
|
+
orchestrate.add_argument("--source", default="user")
|
|
81
|
+
orchestrate.add_argument("--type", dest="task_type")
|
|
82
|
+
orchestrate.add_argument("--previous-agent")
|
|
83
|
+
orchestrate.add_argument("--cwd", default=os.getcwd())
|
|
84
|
+
orchestrate.add_argument("--no-run", action="store_true", help="dispatch the primary task without running workers")
|
|
85
|
+
orchestrate.add_argument("--wait", action="store_true", help="block until orchestrated flow completes")
|
|
86
|
+
orchestrate.add_argument("--wait-timeout", type=float, default=300, help="timeout in seconds for --wait (default: 300)")
|
|
87
|
+
|
|
88
|
+
status = sub.add_parser("status", parents=[common], help="show task status")
|
|
89
|
+
status.add_argument("task_id")
|
|
90
|
+
|
|
91
|
+
tasks = sub.add_parser("tasks", parents=[common], help="list recent tasks")
|
|
92
|
+
tasks.add_argument("--agent")
|
|
93
|
+
tasks.add_argument("--limit", type=int, default=20)
|
|
94
|
+
|
|
95
|
+
worker = sub.add_parser("worker", parents=[common], help="run a worker for one agent")
|
|
96
|
+
worker.add_argument("agent")
|
|
97
|
+
worker.add_argument("--once", action="store_true")
|
|
98
|
+
worker.add_argument("--poll", type=float, default=2.0)
|
|
99
|
+
|
|
100
|
+
send = sub.add_parser("send", parents=[common], help="send a durable message")
|
|
101
|
+
send.add_argument("target_agent")
|
|
102
|
+
send.add_argument("message")
|
|
103
|
+
send.add_argument("--source", default="user")
|
|
104
|
+
send.add_argument("--task-id")
|
|
105
|
+
|
|
106
|
+
inbox = sub.add_parser("inbox", parents=[common], help="read durable messages")
|
|
107
|
+
inbox.add_argument("agent")
|
|
108
|
+
inbox.add_argument("--all", action="store_true")
|
|
109
|
+
inbox.add_argument("--mark-read", action="store_true")
|
|
110
|
+
inbox.add_argument("--limit", type=int, default=20)
|
|
111
|
+
|
|
112
|
+
doctor = sub.add_parser("doctor", parents=[common], help="run environment checks")
|
|
113
|
+
doctor.add_argument("--scan-root")
|
|
114
|
+
doctor.add_argument("--runtime-root", help="runtime state directory for hygiene checks")
|
|
115
|
+
doctor.add_argument(
|
|
116
|
+
"--retired-port",
|
|
117
|
+
action="append",
|
|
118
|
+
type=int,
|
|
119
|
+
default=[],
|
|
120
|
+
help="TCP port that should not be listening, repeatable",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
version_cmd = sub.add_parser("version", parents=[common], help="show version and exit")
|
|
124
|
+
|
|
125
|
+
demo = sub.add_parser("demo", parents=[common], help="run a guided first-run demo")
|
|
126
|
+
|
|
127
|
+
return parser
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main(argv: list[str] | None = None) -> int:
|
|
131
|
+
parser = build_parser()
|
|
132
|
+
|
|
133
|
+
# No-args: show friendly intro
|
|
134
|
+
if argv is None:
|
|
135
|
+
argv = sys.argv[1:]
|
|
136
|
+
if not argv:
|
|
137
|
+
print(BANNER)
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
args = parser.parse_args(argv)
|
|
141
|
+
|
|
142
|
+
# --version flag (before subcommand)
|
|
143
|
+
if args.version:
|
|
144
|
+
print(f"trinity-lite {_version()}")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
# No subcommand but unknown args → show friendly intro
|
|
148
|
+
if args.command is None:
|
|
149
|
+
print(BANNER)
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
return run_command(args)
|
|
154
|
+
except (KeyError, OSError, ValueError) as exc:
|
|
155
|
+
print_json({"error": str(exc)})
|
|
156
|
+
return 2
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_command(args: argparse.Namespace) -> int:
|
|
160
|
+
bus = TrinityBus(args.db)
|
|
161
|
+
|
|
162
|
+
if args.command == "version":
|
|
163
|
+
print(f"trinity-lite {_version()}")
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
if args.command == "route":
|
|
167
|
+
print_json(resolve_route(args.task, args.task_type, args.previous_agent, args.routes, args.agents))
|
|
168
|
+
return 0
|
|
169
|
+
if args.command == "dispatch":
|
|
170
|
+
task = bus.submit_task(
|
|
171
|
+
source_agent=args.source,
|
|
172
|
+
target_agent=args.target_agent,
|
|
173
|
+
prompt=args.task,
|
|
174
|
+
task_type=args.task_type,
|
|
175
|
+
cwd=args.cwd,
|
|
176
|
+
)
|
|
177
|
+
if getattr(args, "wait", False):
|
|
178
|
+
task = _wait_for_task(bus, task["id"], args.target_agent, args.agents, args.wait_timeout)
|
|
179
|
+
print_json(task)
|
|
180
|
+
return 0
|
|
181
|
+
if args.command == "dispatch-auto":
|
|
182
|
+
route = resolve_route(args.task, args.task_type, args.previous_agent, args.routes, args.agents)
|
|
183
|
+
task = bus.submit_task(
|
|
184
|
+
source_agent=args.source,
|
|
185
|
+
target_agent=route["agent"],
|
|
186
|
+
prompt=args.task,
|
|
187
|
+
task_type=route["task_type"],
|
|
188
|
+
cwd=args.cwd,
|
|
189
|
+
)
|
|
190
|
+
task["route"] = route
|
|
191
|
+
if getattr(args, "wait", False):
|
|
192
|
+
task = _wait_for_task(bus, task["id"], route["agent"], args.agents, args.wait_timeout)
|
|
193
|
+
print_json(task)
|
|
194
|
+
return 0
|
|
195
|
+
if args.command == "orchestrate":
|
|
196
|
+
result = run_review_flow(
|
|
197
|
+
args.task,
|
|
198
|
+
bus,
|
|
199
|
+
args.routes,
|
|
200
|
+
args.agents,
|
|
201
|
+
args.source,
|
|
202
|
+
args.task_type,
|
|
203
|
+
args.previous_agent,
|
|
204
|
+
args.cwd,
|
|
205
|
+
run_workers=not args.no_run and not getattr(args, "wait", False),
|
|
206
|
+
)
|
|
207
|
+
if getattr(args, "wait", False):
|
|
208
|
+
result = _wait_for_flow(bus, result, args.agents, args.routes, args.wait_timeout)
|
|
209
|
+
print_json(result)
|
|
210
|
+
return 0
|
|
211
|
+
if args.command == "status":
|
|
212
|
+
print_json(bus.get_task(args.task_id))
|
|
213
|
+
return 0
|
|
214
|
+
if args.command == "tasks":
|
|
215
|
+
print_json(bus.list_tasks(args.agent, args.limit))
|
|
216
|
+
return 0
|
|
217
|
+
if args.command == "worker":
|
|
218
|
+
if args.once:
|
|
219
|
+
print_json(run_once(args.agent, bus, args.agents))
|
|
220
|
+
return 0
|
|
221
|
+
run_loop(args.agent, bus, args.agents, args.poll)
|
|
222
|
+
return 0
|
|
223
|
+
if args.command == "send":
|
|
224
|
+
print_json(bus.send_message(args.source, args.target_agent, args.message, args.task_id))
|
|
225
|
+
return 0
|
|
226
|
+
if args.command == "inbox":
|
|
227
|
+
print_json(bus.inbox(args.agent, unread_only=not args.all, mark_read=args.mark_read, limit=args.limit))
|
|
228
|
+
return 0
|
|
229
|
+
if args.command == "doctor":
|
|
230
|
+
print_json(run_doctor(
|
|
231
|
+
args.db,
|
|
232
|
+
args.routes,
|
|
233
|
+
args.agents,
|
|
234
|
+
args.scan_root,
|
|
235
|
+
args.runtime_root,
|
|
236
|
+
args.retired_port,
|
|
237
|
+
))
|
|
238
|
+
return 0
|
|
239
|
+
if args.command == "demo":
|
|
240
|
+
return _demo(args, bus)
|
|
241
|
+
|
|
242
|
+
raise AssertionError(f"unhandled command: {args.command}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _wait_for_task(
|
|
246
|
+
bus: TrinityBus,
|
|
247
|
+
task_id: str,
|
|
248
|
+
agent: str,
|
|
249
|
+
agents_path: str | None,
|
|
250
|
+
timeout: float,
|
|
251
|
+
) -> dict[str, Any]:
|
|
252
|
+
"""Run worker cycles for agent until task completes or timeout."""
|
|
253
|
+
import time as _time
|
|
254
|
+
from .bus import TERMINAL_STATUSES
|
|
255
|
+
deadline = _time.monotonic() + timeout
|
|
256
|
+
while _time.monotonic() < deadline:
|
|
257
|
+
task = bus.get_task(task_id)
|
|
258
|
+
if task["status"] in TERMINAL_STATUSES:
|
|
259
|
+
return task
|
|
260
|
+
# Try to process one queued/running task
|
|
261
|
+
run_once(agent, bus, agents_path, task_id=task_id)
|
|
262
|
+
_time.sleep(0.1)
|
|
263
|
+
raise TimeoutError(f"task {task_id} did not finish within {timeout}s")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _wait_for_flow(
|
|
267
|
+
bus: TrinityBus,
|
|
268
|
+
flow: dict[str, Any],
|
|
269
|
+
agents_path: str | None,
|
|
270
|
+
routes_path: str | None,
|
|
271
|
+
timeout: float,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
"""Run workers to complete a review flow until terminal."""
|
|
274
|
+
import time as _time
|
|
275
|
+
from .bus import TERMINAL_STATUSES
|
|
276
|
+
from .orchestrator import _review_prompt
|
|
277
|
+
|
|
278
|
+
primary_id = flow["primary_task"]["id"]
|
|
279
|
+
primary_agent = flow["route"]["agent"]
|
|
280
|
+
route = flow["route"]
|
|
281
|
+
|
|
282
|
+
# Review may not have been dispatched yet (if run_workers=False was used)
|
|
283
|
+
review_id = None
|
|
284
|
+
review_agent = None
|
|
285
|
+
review = flow.get("review_task")
|
|
286
|
+
if review:
|
|
287
|
+
review_id = review["id"]
|
|
288
|
+
review_agent = flow.get("review_route", {}).get("agent") if flow.get("review_route") else None
|
|
289
|
+
|
|
290
|
+
deadline = _time.monotonic() + timeout
|
|
291
|
+
review_dispatched = review_id is not None
|
|
292
|
+
|
|
293
|
+
while _time.monotonic() < deadline:
|
|
294
|
+
primary = bus.get_task(primary_id)
|
|
295
|
+
|
|
296
|
+
# If primary just completed and review is required but not yet dispatched
|
|
297
|
+
if (primary["status"] == "completed"
|
|
298
|
+
and route["review_required"]
|
|
299
|
+
and not review_dispatched):
|
|
300
|
+
review_prompt_text = _review_prompt(primary)
|
|
301
|
+
review_route = resolve_route(
|
|
302
|
+
review_prompt_text,
|
|
303
|
+
"code_review",
|
|
304
|
+
route["agent"],
|
|
305
|
+
routes_path,
|
|
306
|
+
agents_path,
|
|
307
|
+
)
|
|
308
|
+
review_task = bus.submit_task(
|
|
309
|
+
source_agent=route["agent"],
|
|
310
|
+
target_agent=review_route["agent"],
|
|
311
|
+
prompt=review_prompt_text,
|
|
312
|
+
task_type=review_route["task_type"],
|
|
313
|
+
cwd=primary["cwd"],
|
|
314
|
+
depth=int(primary["depth"]) + 1,
|
|
315
|
+
)
|
|
316
|
+
review_id = review_task["id"]
|
|
317
|
+
review_agent = review_route["agent"]
|
|
318
|
+
review_dispatched = True
|
|
319
|
+
flow["review_route"] = review_route
|
|
320
|
+
flow["review_task"] = review_task
|
|
321
|
+
|
|
322
|
+
review_done = True
|
|
323
|
+
if review_id is not None:
|
|
324
|
+
review_task = bus.get_task(review_id)
|
|
325
|
+
review_done = review_task["status"] in TERMINAL_STATUSES
|
|
326
|
+
|
|
327
|
+
if primary["status"] in TERMINAL_STATUSES and review_done:
|
|
328
|
+
flow["primary_task"] = primary
|
|
329
|
+
if review_id is not None:
|
|
330
|
+
flow["review_task"] = bus.get_task(review_id)
|
|
331
|
+
flow["acceptance_status"] = _acceptance_status_from_flow(
|
|
332
|
+
flow["route"], primary, flow.get("review_task")
|
|
333
|
+
)
|
|
334
|
+
return flow
|
|
335
|
+
|
|
336
|
+
if primary["status"] not in TERMINAL_STATUSES:
|
|
337
|
+
run_once(primary_agent, bus, agents_path, task_id=primary_id)
|
|
338
|
+
elif review_id is not None and review_agent:
|
|
339
|
+
run_once(review_agent, bus, agents_path, task_id=review_id)
|
|
340
|
+
_time.sleep(0.1)
|
|
341
|
+
raise TimeoutError(f"flow for {primary_id} did not finish within {timeout}s")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _acceptance_status_from_flow(
|
|
345
|
+
route: dict[str, Any],
|
|
346
|
+
primary_task: dict[str, Any],
|
|
347
|
+
review_task: dict[str, Any] | None,
|
|
348
|
+
) -> str:
|
|
349
|
+
from .orchestrator import _acceptance_status
|
|
350
|
+
return _acceptance_status(route, primary_task, review_task)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _demo(args: argparse.Namespace, bus: TrinityBus) -> int:
|
|
354
|
+
"""Run a guided demo flow with human-friendly output."""
|
|
355
|
+
|
|
356
|
+
print("Trinity Lite Demo")
|
|
357
|
+
print("=================")
|
|
358
|
+
|
|
359
|
+
# ---- 1. Doctor ----
|
|
360
|
+
print("→ Running environment checks...")
|
|
361
|
+
try:
|
|
362
|
+
health = run_doctor(args.db, args.routes, args.agents)
|
|
363
|
+
status_label = "healthy" if health["status"] == "healthy" else "unhealthy"
|
|
364
|
+
icon = "✓" if health["status"] == "healthy" else "✗"
|
|
365
|
+
print(f" {icon} Doctor: {status_label}")
|
|
366
|
+
for check in health.get("checks", []):
|
|
367
|
+
mark = "✓" if check["ok"] else "✗"
|
|
368
|
+
print(f" {mark} {check['name']}: {str(check.get('detail', '')).strip()}")
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
print(f" ✗ Doctor: {exc}")
|
|
371
|
+
print()
|
|
372
|
+
|
|
373
|
+
# ---- 2. Dispatch + worker cycle (full orchestrated flow) ----
|
|
374
|
+
demo_prompt = "write a hello world function in Python"
|
|
375
|
+
print("→ Dispatching demo task...")
|
|
376
|
+
flow = run_review_flow(
|
|
377
|
+
demo_prompt,
|
|
378
|
+
bus,
|
|
379
|
+
args.routes,
|
|
380
|
+
args.agents,
|
|
381
|
+
source_agent="user",
|
|
382
|
+
cwd=str(Path.home()),
|
|
383
|
+
run_workers=True,
|
|
384
|
+
)
|
|
385
|
+
primary = flow["primary_task"]
|
|
386
|
+
route = flow["route"]
|
|
387
|
+
|
|
388
|
+
print(f" Task ID: {primary['id']}")
|
|
389
|
+
print(f" Routed to: {route['agent']}")
|
|
390
|
+
print()
|
|
391
|
+
|
|
392
|
+
# ---- 3. Codex worker already ran in run_review_flow ----
|
|
393
|
+
print(f"→ Running mock worker ({route['agent']})...")
|
|
394
|
+
icon = "✓" if primary["status"] == "completed" else "✗"
|
|
395
|
+
print(f" {icon} Task {primary['id']} {primary['status']}")
|
|
396
|
+
print()
|
|
397
|
+
|
|
398
|
+
# ---- 4. Claude Code review (already ran via run_review_flow) ----
|
|
399
|
+
print("→ Running mock worker (claude_code)...")
|
|
400
|
+
review_task = flow.get("review_task")
|
|
401
|
+
if review_task:
|
|
402
|
+
icon = "✓" if review_task.get("status") == "completed" else "✗"
|
|
403
|
+
print(f" {icon} Review {review_task.get('status')}")
|
|
404
|
+
else:
|
|
405
|
+
print(" - No review task required for this demo")
|
|
406
|
+
print()
|
|
407
|
+
|
|
408
|
+
# ---- 5. Results ----
|
|
409
|
+
print("→ Results:")
|
|
410
|
+
final = bus.get_task(primary["id"])
|
|
411
|
+
print(f" Task: {final['prompt']}")
|
|
412
|
+
print(f" Status: {final['status']}")
|
|
413
|
+
if final.get("result"):
|
|
414
|
+
result_text = final["result"]
|
|
415
|
+
if len(result_text) > 200:
|
|
416
|
+
result_text = result_text[:200] + "..."
|
|
417
|
+
print(f" Result: {result_text}")
|
|
418
|
+
if final.get("error"):
|
|
419
|
+
print(f" Error: {final['error']}")
|
|
420
|
+
if review_task:
|
|
421
|
+
review_final = bus.get_task(review_task["id"])
|
|
422
|
+
print(f" Review: {review_final.get('status')}")
|
|
423
|
+
if review_final.get("result"):
|
|
424
|
+
r_text = review_final["result"]
|
|
425
|
+
if len(r_text) > 200:
|
|
426
|
+
r_text = r_text[:200] + "..."
|
|
427
|
+
print(f" Review result: {r_text}")
|
|
428
|
+
print()
|
|
429
|
+
|
|
430
|
+
# ---- 6. Next steps ----
|
|
431
|
+
print("Next steps:")
|
|
432
|
+
print(" • See all tasks: trinity-lite tasks")
|
|
433
|
+
print(" • Connect real agents: docs/REAL_AGENTS.md")
|
|
434
|
+
print(" • Full docs: https://github.com/Yomiracle/trinity-lite")
|
|
435
|
+
|
|
436
|
+
return 0
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
if __name__ == "__main__":
|
|
440
|
+
raise SystemExit(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trinity-lite
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A minimal public multi-agent task bus with routing, workers, and safety checks.
|
|
5
5
|
Author: Yomiracle
|
|
6
6
|
License-Expression: MIT
|
|
@@ -155,7 +155,24 @@ trinity-lite worker codex --once
|
|
|
155
155
|
trinity-lite tasks
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
## Upgrading
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
python3 -m pip install --upgrade trinity-lite
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Trinity Lite follows [semantic versioning](https://semver.org). Patch releases (0.1.x) are backward-compatible
|
|
165
|
+
and require no configuration or data migration. Check your version:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
trinity-lite doctor
|
|
169
|
+
# or
|
|
170
|
+
python3 -m pip show trinity-lite | grep Version
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
See [CHANGELOG.md](CHANGELOG.md) for what changed between versions.
|
|
174
|
+
|
|
175
|
+
## Quick Start (from source)
|
|
159
176
|
|
|
160
177
|
```bash
|
|
161
178
|
git clone https://github.com/Yomiracle/trinity-lite.git
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import io
|
|
2
|
-
import json
|
|
3
|
-
import tempfile
|
|
4
|
-
import unittest
|
|
5
|
-
from contextlib import redirect_stdout
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
from trinity_lite.cli import main
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class CliTest(unittest.TestCase):
|
|
12
|
-
def test_global_options_work_after_subcommand(self):
|
|
13
|
-
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
14
|
-
root = Path(tmp)
|
|
15
|
-
db = root / "bus.db"
|
|
16
|
-
output = io.StringIO()
|
|
17
|
-
with redirect_stdout(output):
|
|
18
|
-
code = main([
|
|
19
|
-
"dispatch-auto",
|
|
20
|
-
"implement a parser",
|
|
21
|
-
"--db",
|
|
22
|
-
str(db),
|
|
23
|
-
"--cwd",
|
|
24
|
-
str(root),
|
|
25
|
-
])
|
|
26
|
-
self.assertEqual(code, 0)
|
|
27
|
-
code = main(["worker", "codex", "--once", "--db", str(db)])
|
|
28
|
-
self.assertEqual(code, 0)
|
|
29
|
-
|
|
30
|
-
def test_route_errors_are_structured(self):
|
|
31
|
-
output = io.StringIO()
|
|
32
|
-
with redirect_stdout(output):
|
|
33
|
-
code = main(["route", "review this patch"])
|
|
34
|
-
self.assertEqual(code, 2)
|
|
35
|
-
self.assertIn('"error"', output.getvalue())
|
|
36
|
-
|
|
37
|
-
def test_doctor_accepts_runtime_hygiene_options(self):
|
|
38
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
39
|
-
root = Path(tmp)
|
|
40
|
-
runtime = root / "runtime"
|
|
41
|
-
runtime.mkdir()
|
|
42
|
-
(runtime / "metrics.jsonl").write_text("", encoding="utf-8")
|
|
43
|
-
output = io.StringIO()
|
|
44
|
-
with redirect_stdout(output):
|
|
45
|
-
code = main([
|
|
46
|
-
"doctor",
|
|
47
|
-
"--db",
|
|
48
|
-
str(root / "bus.db"),
|
|
49
|
-
"--runtime-root",
|
|
50
|
-
str(runtime),
|
|
51
|
-
"--retired-port",
|
|
52
|
-
"70000",
|
|
53
|
-
])
|
|
54
|
-
self.assertEqual(code, 0)
|
|
55
|
-
self.assertIn('"runtime_metrics"', output.getvalue())
|
|
56
|
-
self.assertIn('"retired_port:70000"', output.getvalue())
|
|
57
|
-
|
|
58
|
-
def test_dispatch_auto_uses_capability_routes_and_agents(self):
|
|
59
|
-
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
60
|
-
root = Path(tmp)
|
|
61
|
-
agents_path = root / "agents.json"
|
|
62
|
-
routes_path = root / "routes.json"
|
|
63
|
-
agents_path.write_text(json.dumps({
|
|
64
|
-
"agents": {
|
|
65
|
-
"qwen_cli": {
|
|
66
|
-
"mode": "mock",
|
|
67
|
-
"roles": ["primary_engineer"],
|
|
68
|
-
"capabilities": ["code_edit"],
|
|
69
|
-
"priority": 80,
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}), encoding="utf-8")
|
|
73
|
-
routes_path.write_text(json.dumps({
|
|
74
|
-
"routes": {
|
|
75
|
-
"implementation": {
|
|
76
|
-
"requires": ["code_edit"],
|
|
77
|
-
"prefer": ["primary_engineer"],
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
|
-
"opposites": {}
|
|
81
|
-
}), encoding="utf-8")
|
|
82
|
-
output = io.StringIO()
|
|
83
|
-
with redirect_stdout(output):
|
|
84
|
-
code = main([
|
|
85
|
-
"dispatch-auto",
|
|
86
|
-
"implement a parser",
|
|
87
|
-
"--db",
|
|
88
|
-
str(root / "bus.db"),
|
|
89
|
-
"--cwd",
|
|
90
|
-
str(root),
|
|
91
|
-
"--agents",
|
|
92
|
-
str(agents_path),
|
|
93
|
-
"--routes",
|
|
94
|
-
str(routes_path),
|
|
95
|
-
])
|
|
96
|
-
self.assertEqual(code, 0)
|
|
97
|
-
self.assertIn('"target_agent": "qwen_cli"', output.getvalue())
|
|
98
|
-
|
|
99
|
-
def test_orchestrate_uses_capability_routes_and_agents(self):
|
|
100
|
-
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
101
|
-
root = Path(tmp)
|
|
102
|
-
agents_path = root / "agents.json"
|
|
103
|
-
routes_path = root / "routes.json"
|
|
104
|
-
agents_path.write_text(json.dumps({
|
|
105
|
-
"agents": {
|
|
106
|
-
"qwen_cli": {
|
|
107
|
-
"mode": "mock",
|
|
108
|
-
"capabilities": ["code_edit"],
|
|
109
|
-
},
|
|
110
|
-
"gemini_cli": {
|
|
111
|
-
"mode": "mock",
|
|
112
|
-
"capabilities": ["code_review"],
|
|
113
|
-
},
|
|
114
|
-
}
|
|
115
|
-
}), encoding="utf-8")
|
|
116
|
-
routes_path.write_text(json.dumps({
|
|
117
|
-
"routes": {
|
|
118
|
-
"implementation": {
|
|
119
|
-
"requires": ["code_edit"],
|
|
120
|
-
"review_required": True,
|
|
121
|
-
},
|
|
122
|
-
"code_review": {
|
|
123
|
-
"requires": ["code_review"],
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
"opposites": {}
|
|
127
|
-
}), encoding="utf-8")
|
|
128
|
-
output = io.StringIO()
|
|
129
|
-
with redirect_stdout(output):
|
|
130
|
-
code = main([
|
|
131
|
-
"orchestrate",
|
|
132
|
-
"implement parser",
|
|
133
|
-
"--db",
|
|
134
|
-
str(root / "bus.db"),
|
|
135
|
-
"--cwd",
|
|
136
|
-
str(root),
|
|
137
|
-
"--agents",
|
|
138
|
-
str(agents_path),
|
|
139
|
-
"--routes",
|
|
140
|
-
str(routes_path),
|
|
141
|
-
])
|
|
142
|
-
self.assertEqual(code, 0)
|
|
143
|
-
text = output.getvalue()
|
|
144
|
-
self.assertIn('"target_agent": "qwen_cli"', text)
|
|
145
|
-
self.assertIn('"target_agent": "gemini_cli"', text)
|
|
146
|
-
self.assertIn('"acceptance_status": "accepted"', text)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if __name__ == "__main__":
|
|
150
|
-
unittest.main()
|
|
@@ -1,175 +0,0 @@
|
|
|
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
|
-
common = argparse.ArgumentParser(add_help=False)
|
|
24
|
-
common.add_argument("--db", default=None, help="SQLite database path")
|
|
25
|
-
common.add_argument("--routes", default=None, help="routes JSON path")
|
|
26
|
-
common.add_argument("--agents", default=None, help="agents JSON path")
|
|
27
|
-
sub = parser.add_subparsers(dest="command", required=True)
|
|
28
|
-
|
|
29
|
-
route = sub.add_parser("route", parents=[common], help="resolve a route without dispatching")
|
|
30
|
-
route.add_argument("task")
|
|
31
|
-
route.add_argument("--type", dest="task_type")
|
|
32
|
-
route.add_argument("--previous-agent")
|
|
33
|
-
|
|
34
|
-
dispatch = sub.add_parser("dispatch", parents=[common], help="dispatch to an explicit agent")
|
|
35
|
-
dispatch.add_argument("target_agent")
|
|
36
|
-
dispatch.add_argument("task")
|
|
37
|
-
dispatch.add_argument("--source", default="user")
|
|
38
|
-
dispatch.add_argument("--type", dest="task_type")
|
|
39
|
-
dispatch.add_argument("--cwd", default=os.getcwd())
|
|
40
|
-
|
|
41
|
-
auto = sub.add_parser("dispatch-auto", parents=[common], help="resolve route then dispatch")
|
|
42
|
-
auto.add_argument("task")
|
|
43
|
-
auto.add_argument("--source", default="user")
|
|
44
|
-
auto.add_argument("--type", dest="task_type")
|
|
45
|
-
auto.add_argument("--previous-agent")
|
|
46
|
-
auto.add_argument("--cwd", default=os.getcwd())
|
|
47
|
-
|
|
48
|
-
orchestrate = sub.add_parser("orchestrate", parents=[common], help="dispatch and run a primary task with optional review")
|
|
49
|
-
orchestrate.add_argument("task")
|
|
50
|
-
orchestrate.add_argument("--source", default="user")
|
|
51
|
-
orchestrate.add_argument("--type", dest="task_type")
|
|
52
|
-
orchestrate.add_argument("--previous-agent")
|
|
53
|
-
orchestrate.add_argument("--cwd", default=os.getcwd())
|
|
54
|
-
orchestrate.add_argument("--no-run", action="store_true", help="dispatch the primary task without running workers")
|
|
55
|
-
|
|
56
|
-
status = sub.add_parser("status", parents=[common], help="show task status")
|
|
57
|
-
status.add_argument("task_id")
|
|
58
|
-
|
|
59
|
-
tasks = sub.add_parser("tasks", parents=[common], help="list recent tasks")
|
|
60
|
-
tasks.add_argument("--agent")
|
|
61
|
-
tasks.add_argument("--limit", type=int, default=20)
|
|
62
|
-
|
|
63
|
-
worker = sub.add_parser("worker", parents=[common], help="run a worker for one agent")
|
|
64
|
-
worker.add_argument("agent")
|
|
65
|
-
worker.add_argument("--once", action="store_true")
|
|
66
|
-
worker.add_argument("--poll", type=float, default=2.0)
|
|
67
|
-
|
|
68
|
-
send = sub.add_parser("send", parents=[common], help="send a durable message")
|
|
69
|
-
send.add_argument("target_agent")
|
|
70
|
-
send.add_argument("message")
|
|
71
|
-
send.add_argument("--source", default="user")
|
|
72
|
-
send.add_argument("--task-id")
|
|
73
|
-
|
|
74
|
-
inbox = sub.add_parser("inbox", parents=[common], help="read durable messages")
|
|
75
|
-
inbox.add_argument("agent")
|
|
76
|
-
inbox.add_argument("--all", action="store_true")
|
|
77
|
-
inbox.add_argument("--mark-read", action="store_true")
|
|
78
|
-
inbox.add_argument("--limit", type=int, default=20)
|
|
79
|
-
|
|
80
|
-
doctor = sub.add_parser("doctor", parents=[common], help="run environment checks")
|
|
81
|
-
doctor.add_argument("--scan-root")
|
|
82
|
-
doctor.add_argument("--runtime-root", help="runtime state directory for hygiene checks")
|
|
83
|
-
doctor.add_argument(
|
|
84
|
-
"--retired-port",
|
|
85
|
-
action="append",
|
|
86
|
-
type=int,
|
|
87
|
-
default=[],
|
|
88
|
-
help="TCP port that should not be listening, repeatable",
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
return parser
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def main(argv: list[str] | None = None) -> int:
|
|
95
|
-
args = build_parser().parse_args(argv)
|
|
96
|
-
try:
|
|
97
|
-
return run_command(args)
|
|
98
|
-
except (KeyError, OSError, ValueError) as exc:
|
|
99
|
-
print_json({"error": str(exc)})
|
|
100
|
-
return 2
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def run_command(args: argparse.Namespace) -> int:
|
|
104
|
-
bus = TrinityBus(args.db)
|
|
105
|
-
|
|
106
|
-
if args.command == "route":
|
|
107
|
-
print_json(resolve_route(args.task, args.task_type, args.previous_agent, args.routes, args.agents))
|
|
108
|
-
return 0
|
|
109
|
-
if args.command == "dispatch":
|
|
110
|
-
print_json(bus.submit_task(
|
|
111
|
-
source_agent=args.source,
|
|
112
|
-
target_agent=args.target_agent,
|
|
113
|
-
prompt=args.task,
|
|
114
|
-
task_type=args.task_type,
|
|
115
|
-
cwd=args.cwd,
|
|
116
|
-
))
|
|
117
|
-
return 0
|
|
118
|
-
if args.command == "dispatch-auto":
|
|
119
|
-
route = resolve_route(args.task, args.task_type, args.previous_agent, args.routes, args.agents)
|
|
120
|
-
task = bus.submit_task(
|
|
121
|
-
source_agent=args.source,
|
|
122
|
-
target_agent=route["agent"],
|
|
123
|
-
prompt=args.task,
|
|
124
|
-
task_type=route["task_type"],
|
|
125
|
-
cwd=args.cwd,
|
|
126
|
-
)
|
|
127
|
-
task["route"] = route
|
|
128
|
-
print_json(task)
|
|
129
|
-
return 0
|
|
130
|
-
if args.command == "orchestrate":
|
|
131
|
-
print_json(run_review_flow(
|
|
132
|
-
args.task,
|
|
133
|
-
bus,
|
|
134
|
-
args.routes,
|
|
135
|
-
args.agents,
|
|
136
|
-
args.source,
|
|
137
|
-
args.task_type,
|
|
138
|
-
args.previous_agent,
|
|
139
|
-
args.cwd,
|
|
140
|
-
run_workers=not args.no_run,
|
|
141
|
-
))
|
|
142
|
-
return 0
|
|
143
|
-
if args.command == "status":
|
|
144
|
-
print_json(bus.get_task(args.task_id))
|
|
145
|
-
return 0
|
|
146
|
-
if args.command == "tasks":
|
|
147
|
-
print_json(bus.list_tasks(args.agent, args.limit))
|
|
148
|
-
return 0
|
|
149
|
-
if args.command == "worker":
|
|
150
|
-
if args.once:
|
|
151
|
-
print_json(run_once(args.agent, bus, args.agents))
|
|
152
|
-
return 0
|
|
153
|
-
run_loop(args.agent, bus, args.agents, args.poll)
|
|
154
|
-
return 0
|
|
155
|
-
if args.command == "send":
|
|
156
|
-
print_json(bus.send_message(args.source, args.target_agent, args.message, args.task_id))
|
|
157
|
-
return 0
|
|
158
|
-
if args.command == "inbox":
|
|
159
|
-
print_json(bus.inbox(args.agent, unread_only=not args.all, mark_read=args.mark_read, limit=args.limit))
|
|
160
|
-
return 0
|
|
161
|
-
if args.command == "doctor":
|
|
162
|
-
print_json(run_doctor(
|
|
163
|
-
args.db,
|
|
164
|
-
args.routes,
|
|
165
|
-
args.agents,
|
|
166
|
-
args.scan_root,
|
|
167
|
-
args.runtime_root,
|
|
168
|
-
args.retired_port,
|
|
169
|
-
))
|
|
170
|
-
return 0
|
|
171
|
-
raise AssertionError(f"unhandled command: {args.command}")
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if __name__ == "__main__":
|
|
175
|
-
raise SystemExit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|