trinity-lite 0.2.3__tar.gz → 0.4.0__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.
Files changed (41) hide show
  1. {trinity_lite-0.2.3/trinity_lite.egg-info → trinity_lite-0.4.0}/PKG-INFO +44 -1
  2. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/README.md +43 -0
  3. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/pyproject.toml +1 -1
  4. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_mcp_server.py +3 -3
  5. trinity_lite-0.4.0/tests/test_pipeline.py +185 -0
  6. trinity_lite-0.4.0/tests/test_worker.py +161 -0
  7. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/__init__.py +1 -1
  8. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/cli.py +60 -22
  9. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/mcp_server.py +160 -2
  10. trinity_lite-0.4.0/trinity_lite/model_autodetect.py +207 -0
  11. trinity_lite-0.4.0/trinity_lite/model_pool_wizard.py +194 -0
  12. trinity_lite-0.4.0/trinity_lite/model_selector.py +329 -0
  13. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/orchestrator.py +11 -0
  14. trinity_lite-0.4.0/trinity_lite/pipeline.py +158 -0
  15. trinity_lite-0.4.0/trinity_lite/worker.py +206 -0
  16. {trinity_lite-0.2.3 → trinity_lite-0.4.0/trinity_lite.egg-info}/PKG-INFO +44 -1
  17. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite.egg-info/SOURCES.txt +5 -0
  18. trinity_lite-0.2.3/tests/test_worker.py +0 -67
  19. trinity_lite-0.2.3/trinity_lite/worker.py +0 -45
  20. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/LICENSE +0 -0
  21. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/setup.cfg +0 -0
  22. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_bus.py +0 -0
  23. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_cli.py +0 -0
  24. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_doctor.py +0 -0
  25. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_guard.py +0 -0
  26. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_orchestrator.py +0 -0
  27. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_router.py +0 -0
  28. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/__main__.py +0 -0
  29. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/adapters.py +0 -0
  30. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/bus.py +0 -0
  31. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/config.py +0 -0
  32. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/doctor.py +0 -0
  33. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/guard.py +0 -0
  34. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/paths.py +0 -0
  35. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/py.typed +0 -0
  36. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/router.py +0 -0
  37. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/validation.py +0 -0
  38. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite.egg-info/dependency_links.txt +0 -0
  39. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite.egg-info/entry_points.txt +0 -0
  40. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite.egg-info/requires.txt +0 -0
  41. {trinity_lite-0.2.3 → trinity_lite-0.4.0}/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.2.3
3
+ Version: 0.4.0
4
4
  Summary: The bus that lets your AI agents talk to each other.
5
5
  Author: Yomiracle
6
6
  License-Expression: MIT
@@ -86,6 +86,7 @@ LangGraph and CrewAI give you primitives for building agents from scratch — gr
86
86
  - **Check health in one pass.** `trinity-lite doctor` verifies Python, SQLite, route config, agent config, and publish readiness.
87
87
  - **Zero dependencies.** Runtime is Python standard library only. Nothing to install but Python 3.10+.
88
88
  - **109 tests guarding the surface area.** Mock workflows, safety checks, routing, persistence — all covered.
89
+ - **Smart model selection.** Automatically picks the right LLM for each task. Simple CRUD → cheap model. Architecture design → strong reasoning model. Define your own model pool with tiers and strength tags.
89
90
 
90
91
  ## Install
91
92
 
@@ -153,6 +154,48 @@ trinity-lite mcp serve
153
154
 
154
155
  **3 resources:** `trinity://health`, `trinity://tasks/recent`, `trinity://tasks/{task_id}`
155
156
 
157
+ ## Model Selector (NEW in v0.4.0)
158
+
159
+ Auto-pick the best LLM for each task based on complexity:
160
+
161
+ ```bash
162
+ # Auto-detect your available models (zero config)
163
+ trinity-lite detect-models
164
+
165
+ # Or set up interactively (no JSON needed)
166
+ trinity-lite setup-models
167
+ ```
168
+
169
+ **How it works**: Define your model pool with tiers (`budget` / `standard` / `premium`) and strength tags. The selector picks automatically:
170
+
171
+ | Task | → Tier | → Model |
172
+ |---|---|---|
173
+ | "Fix typo in README" | budget | cheap model |
174
+ | "Add search endpoint" | budget | cheap model |
175
+ | "Refactor auth module" | standard | mid-tier |
176
+ | "Design microservice architecture" | premium | strongest |
177
+
178
+ **Manual call** (API usage):
179
+
180
+ ```python
181
+ from trinity_lite.model_selector import select_model
182
+
183
+ result = select_model("Design a rate limiter", task_type="architecture_design")
184
+ print(result["model"]) # → gpt-5.5
185
+ print(result["reason"]) # → hard_signal:architecture
186
+ ```
187
+
188
+ **Custom pool** — create `~/.trinity/model_pool.json`:
189
+
190
+ ```json
191
+ {
192
+ "your-cheap-model": {"tier": "budget", "strengths": ["coding"], "api_type": "anthropic"},
193
+ "your-strong-model": {"tier": "premium", "strengths": ["reasoning", "architecture"], "api_type": "openai"}
194
+ }
195
+ ```
196
+
197
+ Works with 1 model, 2 models, or 10 models. No agent names hardcoded.
198
+
156
199
  ## Links
157
200
 
158
201
  - [PyPI](https://pypi.org/project/trinity-lite/)
@@ -61,6 +61,7 @@ LangGraph and CrewAI give you primitives for building agents from scratch — gr
61
61
  - **Check health in one pass.** `trinity-lite doctor` verifies Python, SQLite, route config, agent config, and publish readiness.
62
62
  - **Zero dependencies.** Runtime is Python standard library only. Nothing to install but Python 3.10+.
63
63
  - **109 tests guarding the surface area.** Mock workflows, safety checks, routing, persistence — all covered.
64
+ - **Smart model selection.** Automatically picks the right LLM for each task. Simple CRUD → cheap model. Architecture design → strong reasoning model. Define your own model pool with tiers and strength tags.
64
65
 
65
66
  ## Install
66
67
 
@@ -128,6 +129,48 @@ trinity-lite mcp serve
128
129
 
129
130
  **3 resources:** `trinity://health`, `trinity://tasks/recent`, `trinity://tasks/{task_id}`
130
131
 
132
+ ## Model Selector (NEW in v0.4.0)
133
+
134
+ Auto-pick the best LLM for each task based on complexity:
135
+
136
+ ```bash
137
+ # Auto-detect your available models (zero config)
138
+ trinity-lite detect-models
139
+
140
+ # Or set up interactively (no JSON needed)
141
+ trinity-lite setup-models
142
+ ```
143
+
144
+ **How it works**: Define your model pool with tiers (`budget` / `standard` / `premium`) and strength tags. The selector picks automatically:
145
+
146
+ | Task | → Tier | → Model |
147
+ |---|---|---|
148
+ | "Fix typo in README" | budget | cheap model |
149
+ | "Add search endpoint" | budget | cheap model |
150
+ | "Refactor auth module" | standard | mid-tier |
151
+ | "Design microservice architecture" | premium | strongest |
152
+
153
+ **Manual call** (API usage):
154
+
155
+ ```python
156
+ from trinity_lite.model_selector import select_model
157
+
158
+ result = select_model("Design a rate limiter", task_type="architecture_design")
159
+ print(result["model"]) # → gpt-5.5
160
+ print(result["reason"]) # → hard_signal:architecture
161
+ ```
162
+
163
+ **Custom pool** — create `~/.trinity/model_pool.json`:
164
+
165
+ ```json
166
+ {
167
+ "your-cheap-model": {"tier": "budget", "strengths": ["coding"], "api_type": "anthropic"},
168
+ "your-strong-model": {"tier": "premium", "strengths": ["reasoning", "architecture"], "api_type": "openai"}
169
+ }
170
+ ```
171
+
172
+ Works with 1 model, 2 models, or 10 models. No agent names hardcoded.
173
+
131
174
  ## Links
132
175
 
133
176
  - [PyPI](https://pypi.org/project/trinity-lite/)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trinity-lite"
7
- version = "0.2.3"
7
+ version = "0.4.0"
8
8
  description = "The bus that lets your AI agents talk to each other."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -68,7 +68,7 @@ class McpServerTest(unittest.TestCase):
68
68
  self.assertIn("resources", resp["result"]["capabilities"])
69
69
  self.assertIn("serverInfo", resp["result"])
70
70
  self.assertEqual(resp["result"]["serverInfo"]["name"], "trinity-lite-mcp")
71
- self.assertEqual(resp["result"]["serverInfo"]["version"], "0.2.2")
71
+ self.assertEqual(resp["result"]["serverInfo"]["version"], "0.3.1")
72
72
 
73
73
  def test_initialized_returns_none(self):
74
74
  resp = self._call(self._msg("initialized"))
@@ -90,7 +90,7 @@ class McpServerTest(unittest.TestCase):
90
90
  resp = self._call(self._msg("tools/list", 1))
91
91
  tools = resp["result"]["tools"]
92
92
  self.assertIsInstance(tools, list)
93
- self.assertEqual(len(tools), 10)
93
+ self.assertEqual(len(tools), 12)
94
94
  names = [t["name"] for t in tools]
95
95
  self.assertIn("trinity_dispatch", names)
96
96
  self.assertIn("trinity_dispatch_auto", names)
@@ -133,7 +133,7 @@ class McpServerTest(unittest.TestCase):
133
133
  data = json.loads(contents[0]["text"])
134
134
  self.assertEqual(data["status"], "ok")
135
135
  self.assertEqual(data["bus"], "connected")
136
- self.assertEqual(data["tools"], 10)
136
+ self.assertEqual(data["tools"], 12)
137
137
  self.assertEqual(data["resources"], 3)
138
138
 
139
139
  def test_resource_health_degraded(self):
@@ -0,0 +1,185 @@
1
+ """Tests for pipeline orchestration."""
2
+
3
+ import json
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ from trinity_lite.bus import TrinityBus
9
+ from trinity_lite.pipeline import (
10
+ load_pipeline,
11
+ resolve_step_prompt,
12
+ run_pipeline,
13
+ )
14
+
15
+
16
+ class PipelineTest(unittest.TestCase):
17
+ def setUp(self):
18
+ self.tmp = tempfile.TemporaryDirectory(dir=str(Path.home()))
19
+ self.root = Path(self.tmp.name)
20
+
21
+ def tearDown(self):
22
+ self.tmp.cleanup()
23
+
24
+ def _write_yaml(self, name, content):
25
+ path = self.root / name
26
+ path.write_text(content, encoding="utf-8")
27
+ return str(path)
28
+
29
+ def test_load_valid_pipeline(self):
30
+ yaml_text = "name: test-pipeline\nsteps:\n - id: step1\n agent: codex\n task_type: implementation\n prompt_template: 'Implement: {task}'\n"
31
+ yaml_path = self._write_yaml("test.yaml", yaml_text)
32
+ p = load_pipeline(yaml_path)
33
+ self.assertEqual(p["name"], "test-pipeline")
34
+ self.assertEqual(len(p["steps"]), 1)
35
+ self.assertEqual(p["steps"][0]["id"], "step1")
36
+
37
+ def test_load_invalid_yaml_raises(self):
38
+ json_path = self._write_yaml("bad.json", "not valid json{{{")
39
+ with self.assertRaises(ValueError):
40
+ load_pipeline(json_path)
41
+
42
+ def test_missing_required_field_raises(self):
43
+ yaml_text = "name: bad\nsteps:\n - id: step1\n task_type: implementation\n prompt_template: 'ok'\n"
44
+ yaml_path = self._write_yaml("bad.yaml", yaml_text)
45
+ with self.assertRaises(ValueError):
46
+ load_pipeline(yaml_path)
47
+
48
+ def test_empty_steps_raises(self):
49
+ yaml_path = self._write_yaml("empty.yaml", "name: empty\nsteps: []")
50
+ with self.assertRaises(ValueError):
51
+ load_pipeline(yaml_path)
52
+
53
+ def test_duplicate_step_ids_raises(self):
54
+ yaml_text = "name: dup\nsteps:\n - id: step1\n agent: codex\n task_type: implementation\n prompt_template: 'ok'\n - id: step1\n agent: codex\n task_type: implementation\n prompt_template: 'ok'\n"
55
+ yaml_path = self._write_yaml("dup.yaml", yaml_text)
56
+ with self.assertRaises(ValueError):
57
+ load_pipeline(yaml_path)
58
+
59
+ def test_unknown_step_reference_raises(self):
60
+ yaml_text = "name: badref\nsteps:\n - id: step1\n agent: codex\n task_type: implementation\n prompt_template: 'Result: {steps.nonexistent.result}'\n"
61
+ yaml_path = self._write_yaml("badref.yaml", yaml_text)
62
+ with self.assertRaises(ValueError):
63
+ load_pipeline(yaml_path)
64
+
65
+ def test_prompt_template_rendering_task_only(self):
66
+ step = {"prompt_template": "Do: {task}"}
67
+ result = resolve_step_prompt(step, "write tests", {})
68
+ self.assertEqual(result, "Do: write tests")
69
+
70
+ def test_prompt_template_rendering_with_step_result(self):
71
+ step = {"prompt_template": "Result was: {steps.step1.result}"}
72
+ step_results = {"step1": {"result": "all done", "status": "completed"}}
73
+ result = resolve_step_prompt(step, "task", step_results)
74
+ self.assertEqual(result, "Result was: all done")
75
+
76
+ def test_prompt_template_rendering_with_missing_step(self):
77
+ step = {"prompt_template": "Got: {steps.missing.result}"}
78
+ result = resolve_step_prompt(step, "task", {})
79
+ self.assertEqual(result, "Got: ")
80
+
81
+ def test_prompt_template_rendering_with_error(self):
82
+ step = {"prompt_template": "Error: {steps.step1.result}"}
83
+ step_results = {"step1": {"error": "failed hard", "status": "failed"}}
84
+ result = resolve_step_prompt(step, "task", step_results)
85
+ self.assertEqual(result, "Error: failed hard")
86
+
87
+ def test_run_single_step_pipeline(self):
88
+ bus = TrinityBus(self.root / "bus.db", allowed_roots=[self.root])
89
+ pipeline = {
90
+ "name": "test-single",
91
+ "steps": [
92
+ {"id": "implement", "agent": "codex", "task_type": "implementation",
93
+ "prompt_template": "Implement: {task}"}
94
+ ],
95
+ }
96
+ result = run_pipeline(pipeline, "hello world", bus, cwd=str(self.root))
97
+ self.assertEqual(result["pipeline_name"], "test-single")
98
+ self.assertEqual(result["overall_status"], "completed")
99
+ self.assertEqual(len(result["steps"]), 1)
100
+ self.assertEqual(result["steps"][0]["status"], "completed")
101
+
102
+ def test_run_two_step_pipeline(self):
103
+ bus = TrinityBus(self.root / "bus.db", allowed_roots=[self.root])
104
+ pipeline = {
105
+ "name": "test-two",
106
+ "steps": [
107
+ {"id": "implement", "agent": "codex", "task_type": "implementation",
108
+ "prompt_template": "Implement: {task}"},
109
+ {"id": "review", "agent": "claude_code", "task_type": "code_review",
110
+ "prompt_template": "Review: {task}\nResult: {steps.implement.result}"},
111
+ ],
112
+ }
113
+ result = run_pipeline(pipeline, "hello", bus, cwd=str(self.root))
114
+ self.assertEqual(result["overall_status"], "completed")
115
+ self.assertEqual(len(result["steps"]), 2)
116
+ self.assertEqual(result["steps"][0]["status"], "completed")
117
+ self.assertEqual(result["steps"][1]["status"], "completed")
118
+
119
+ def test_pipeline_step_results_accessible(self):
120
+ bus = TrinityBus(self.root / "bus.db", allowed_roots=[self.root])
121
+ pipeline = {
122
+ "name": "test-ref",
123
+ "steps": [
124
+ {"id": "step1", "agent": "codex", "task_type": "implementation",
125
+ "prompt_template": "Task: {task}"},
126
+ {"id": "step2", "agent": "claude_code", "task_type": "code_review",
127
+ "prompt_template": "Previous result was: {steps.step1.result}"},
128
+ ],
129
+ }
130
+ result = run_pipeline(pipeline, "check refs", bus, cwd=str(self.root))
131
+ self.assertEqual(result["overall_status"], "completed")
132
+ step2 = bus.get_task(result["steps"][1]["task_id"])
133
+ self.assertIn("Previous result was:", step2["prompt"])
134
+
135
+ def test_pipeline_no_run_workers(self):
136
+ bus = TrinityBus(self.root / "bus.db", allowed_roots=[self.root])
137
+ pipeline = {
138
+ "name": "test-no-run",
139
+ "steps": [
140
+ {"id": "step1", "agent": "codex", "task_type": "implementation",
141
+ "prompt_template": "{task}"}
142
+ ],
143
+ }
144
+ result = run_pipeline(pipeline, "stuff", bus, cwd=str(self.root), run_workers=False)
145
+ self.assertEqual(result["overall_status"], "queued")
146
+
147
+ def test_pipeline_fail_fast(self):
148
+ bus = TrinityBus(self.root / "bus.db", allowed_roots=[self.root])
149
+ pipeline = {
150
+ "name": "test-fail",
151
+ "steps": [
152
+ {"id": "step1", "agent": "no_such_agent", "task_type": "implementation",
153
+ "prompt_template": "{task}"},
154
+ {"id": "step2", "agent": "codex", "task_type": "implementation",
155
+ "prompt_template": "{task}"},
156
+ ],
157
+ }
158
+ result = run_pipeline(pipeline, "stuff", bus, cwd=str(self.root))
159
+ self.assertEqual(result["overall_status"], "failed")
160
+ self.assertEqual(len(result["steps"]), 1)
161
+
162
+ def test_cli_orchestrate_with_pipeline_flag(self):
163
+ import io as _io
164
+ import json as _json
165
+ from contextlib import redirect_stdout as _redirect_stdout
166
+ from trinity_lite.cli import main
167
+ yaml_text = "name: cli-pipe\nsteps:\n - id: step1\n agent: codex\n task_type: implementation\n prompt_template: 'ok'\n"
168
+ pipeline_path = self._write_yaml("cli_test.yaml", yaml_text)
169
+ output = _io.StringIO()
170
+ with _redirect_stdout(output):
171
+ code = main([
172
+ "orchestrate",
173
+ "test task",
174
+ "--pipeline", pipeline_path,
175
+ "--db", str(self.root / "cli_bus.db"),
176
+ "--cwd", str(self.root),
177
+ ])
178
+ self.assertEqual(code, 0)
179
+ data = _json.loads(output.getvalue())
180
+ self.assertEqual(data["pipeline_name"], "cli-pipe")
181
+ self.assertEqual(data["overall_status"], "completed")
182
+
183
+
184
+ if __name__ == "__main__":
185
+ unittest.main()
@@ -0,0 +1,161 @@
1
+ import os, signal, tempfile, time, unittest, json
2
+ from pathlib import Path
3
+ from unittest.mock import patch
4
+
5
+ from trinity_lite.bus import TrinityBus
6
+ from trinity_lite.worker import (
7
+ run_once, run_loop, _acquire_pid_file, _cleanup_pid_file,
8
+ _check_existing_pid, _read_pid_file, _stop_flag,
9
+ _install_signal_handlers, _default_pid_path,
10
+ )
11
+
12
+
13
+ class WorkerTest(unittest.TestCase):
14
+ def test_mock_worker_completes_task(self):
15
+ with tempfile.TemporaryDirectory() as tmp:
16
+ root = Path(tmp)
17
+ bus = TrinityBus(root / "bus.db", allowed_roots=[root])
18
+ task = bus.submit_task("user", "codex", "implement hello", cwd=root)
19
+ done = run_once("codex", bus)
20
+ self.assertEqual(done["id"], task["id"])
21
+ self.assertEqual(done["status"], "completed")
22
+ self.assertIn("[mock:codex]", done["result"])
23
+
24
+ def test_missing_configured_agent_fails_task(self):
25
+ with tempfile.TemporaryDirectory() as tmp:
26
+ root = Path(tmp)
27
+ bus = TrinityBus(root / "bus.db", allowed_roots=[root])
28
+ task = bus.submit_task("user", "unknown", "work", cwd=root)
29
+ done = run_once("unknown", bus)
30
+ self.assertEqual(done["id"], task["id"])
31
+ self.assertEqual(done["status"], "failed")
32
+ self.assertIn("agent not configured", done["error"])
33
+
34
+ def test_command_adapter_failure_is_recorded(self):
35
+ with tempfile.TemporaryDirectory() as tmp:
36
+ root = Path(tmp)
37
+ agents_path = root / "agents.json"
38
+ agents_path.write_text(json.dumps({
39
+ "agents": {
40
+ "codex": {
41
+ "mode": "command",
42
+ "command": ["definitely-not-a-real-trinity-lite-command"]
43
+ }
44
+ }
45
+ }), encoding="utf-8")
46
+ bus = TrinityBus(root / "bus.db", allowed_roots=[root])
47
+ task = bus.submit_task("user", "codex", "work", cwd=root)
48
+ done = run_once("codex", bus, str(agents_path))
49
+ self.assertEqual(done["id"], task["id"])
50
+ self.assertEqual(done["status"], "failed")
51
+ self.assertIn("executable not found", done["error"])
52
+
53
+ def test_memory_error_is_not_swallowed(self):
54
+ class OomAdapter:
55
+ def run(self, task):
56
+ raise MemoryError("oom")
57
+
58
+ with tempfile.TemporaryDirectory() as tmp:
59
+ root = Path(tmp)
60
+ bus = TrinityBus(root / "bus.db", allowed_roots=[root])
61
+ task = bus.submit_task("user", "codex", "work", cwd=root)
62
+ with patch("trinity_lite.worker.build_adapter", return_value=OomAdapter()):
63
+ with self.assertRaises(MemoryError):
64
+ run_once("codex", bus)
65
+ self.assertEqual(bus.get_task(task["id"])["status"], "running")
66
+
67
+ # ── New daemon tests ──
68
+
69
+ def test_run_loop_stop_flag(self):
70
+ """Start loop, set stop_flag, verify exits cleanly."""
71
+ with tempfile.TemporaryDirectory() as tmp:
72
+ root = Path(tmp)
73
+ bus = TrinityBus(root / "bus.db", allowed_roots=[root])
74
+ _stop_flag.clear()
75
+ # Set stop flag after a short delay in a separate thread
76
+ import threading
77
+ def set_stop():
78
+ time.sleep(0.15)
79
+ _stop_flag.set()
80
+ t = threading.Thread(target=set_stop)
81
+ t.start()
82
+ exit_code = run_loop("codex", bus, poll_seconds=0.05)
83
+ t.join()
84
+ self.assertEqual(exit_code, 0)
85
+
86
+ def test_pid_file_creation(self):
87
+ """Verify PID file created with correct contents."""
88
+ with tempfile.TemporaryDirectory() as tmp:
89
+ pid_path = Path(tmp) / "test.pid"
90
+ _acquire_pid_file(pid_path)
91
+ self.assertTrue(pid_path.exists())
92
+ pid = int(pid_path.read_text().strip())
93
+ self.assertEqual(pid, os.getpid())
94
+ _cleanup_pid_file(pid_path)
95
+
96
+ def test_pid_file_cleanup_on_exit(self):
97
+ """Verify PID file created during daemon loop and cleanup works."""
98
+ with tempfile.TemporaryDirectory() as tmp:
99
+ root = Path(tmp)
100
+ pid_path = Path(tmp) / "cleanup_test.pid"
101
+ bus = TrinityBus(root / "bus.db", allowed_roots=[root])
102
+ _stop_flag.clear()
103
+ import threading
104
+ def set_stop():
105
+ time.sleep(0.15)
106
+ _stop_flag.set()
107
+ t = threading.Thread(target=set_stop)
108
+ t.start()
109
+ exit_code = run_loop("codex", bus, poll_seconds=0.05, pid_file=pid_path)
110
+ t.join()
111
+ self.assertEqual(exit_code, 0)
112
+ # PID file was created
113
+ self.assertTrue(pid_path.exists())
114
+ # Manually verify cleanup works (atexit fires at process exit)
115
+ _cleanup_pid_file(pid_path)
116
+ self.assertFalse(pid_path.exists())
117
+
118
+ def test_duplicate_daemon_prevented(self):
119
+ """Verify second attempt to acquire PID raises error."""
120
+ with tempfile.TemporaryDirectory() as tmp:
121
+ pid_path = Path(tmp) / "dup.pid"
122
+ # Write our own PID first
123
+ _acquire_pid_file(pid_path)
124
+ self.assertTrue(pid_path.exists())
125
+ # Second attempt should raise
126
+ with self.assertRaises(RuntimeError):
127
+ _check_existing_pid(pid_path)
128
+ _cleanup_pid_file(pid_path)
129
+
130
+ def test_stale_pid_cleanup(self):
131
+ """Write fake PID, verify it's treated as stale and can be re-acquired."""
132
+ with tempfile.TemporaryDirectory() as tmp:
133
+ pid_path = Path(tmp) / "stale.pid"
134
+ # Write a PID that doesn't exist
135
+ pid_path.write_text("99999")
136
+ # Should be treated as stale (None returned)
137
+ result = _check_existing_pid(pid_path)
138
+ self.assertIsNone(result)
139
+
140
+ def test_read_pid_file(self):
141
+ """Test _read_pid_file returns correct PID."""
142
+ with tempfile.TemporaryDirectory() as tmp:
143
+ pid_path = Path(tmp) / "read.pid"
144
+ pid_path.write_text("42")
145
+ self.assertEqual(_read_pid_file(pid_path), 42)
146
+ # Corrupt file
147
+ pid_path.write_text("not_a_pid")
148
+ self.assertIsNone(_read_pid_file(pid_path))
149
+ # Missing file
150
+ self.assertIsNone(_read_pid_file(Path(tmp) / "nonexistent.pid"))
151
+
152
+ def test_default_pid_path(self):
153
+ """Test default PID path resolution."""
154
+ path = _default_pid_path("codex")
155
+ self.assertIn(".trinity", str(path))
156
+ self.assertIn("workers", str(path))
157
+ self.assertTrue(str(path).endswith("codex.pid"))
158
+
159
+
160
+ if __name__ == "__main__":
161
+ unittest.main()
@@ -1,3 +1,3 @@
1
1
  """Trinity Lite: a minimal public multi-agent task bus."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.4.0"
@@ -12,8 +12,9 @@ from typing import Any
12
12
  from .bus import TrinityBus
13
13
  from .doctor import run_doctor
14
14
  from .orchestrator import run_review_flow
15
+ from .pipeline import load_pipeline, run_pipeline
15
16
  from .router import resolve_route
16
- from .worker import run_loop, run_once
17
+ from .worker import _default_pid_path, run_loop, run_once
17
18
 
18
19
 
19
20
  def print_json(data: Any) -> None:
@@ -24,11 +25,9 @@ def _version() -> str:
24
25
  """Return the installed version, favouring importlib.metadata."""
25
26
  try:
26
27
  from importlib.metadata import PackageNotFoundError, version # Python >= 3.8
27
-
28
28
  return version("trinity-lite")
29
29
  except (ImportError, PackageNotFoundError):
30
30
  from . import __version__
31
-
32
31
  return __version__ # pragma: no cover — fallback for development
33
32
 
34
33
 
@@ -40,7 +39,8 @@ Quick demo: trinity-lite demo
40
39
  Full help: trinity-lite --help
41
40
 
42
41
  Commands: demo, dispatch, dispatch-auto, worker, orchestrate,
43
- status, tasks, route, doctor, send, inbox, mcp"""
42
+ status, tasks, route, doctor, send, inbox, mcp,
43
+ setup-models, detect-models"""
44
44
 
45
45
 
46
46
  def build_parser() -> argparse.ArgumentParser:
@@ -77,6 +77,7 @@ def build_parser() -> argparse.ArgumentParser:
77
77
 
78
78
  orchestrate = sub.add_parser("orchestrate", parents=[common], help="dispatch and run a primary task with optional review")
79
79
  orchestrate.add_argument("task")
80
+ orchestrate.add_argument("--pipeline", default=None, help="path to pipeline YAML (uses pipeline orchestration instead of review flow)")
80
81
  orchestrate.add_argument("--source", default="user")
81
82
  orchestrate.add_argument("--type", dest="task_type")
82
83
  orchestrate.add_argument("--previous-agent")
@@ -94,8 +95,10 @@ def build_parser() -> argparse.ArgumentParser:
94
95
 
95
96
  worker = sub.add_parser("worker", parents=[common], help="run a worker for one agent")
96
97
  worker.add_argument("agent")
97
- worker.add_argument("--once", action="store_true")
98
- worker.add_argument("--poll", type=float, default=2.0)
98
+ worker.add_argument("--once", action="store_true", help="run a single worker cycle and exit")
99
+ worker.add_argument("--poll", type=float, default=2.0, help="poll interval in seconds (default: 2.0)")
100
+ worker.add_argument("--daemon", action="store_true", help="run in continuous daemon mode with signal handling and PID locking")
101
+ worker.add_argument("--pid-file", default=None, help="custom PID file path (default: ~/.trinity/workers/<agent>.pid)")
99
102
 
100
103
  send = sub.add_parser("send", parents=[common], help="send a durable message")
101
104
  send.add_argument("target_agent")
@@ -124,6 +127,14 @@ def build_parser() -> argparse.ArgumentParser:
124
127
 
125
128
  demo = sub.add_parser("demo", parents=[common], help="run a guided first-run demo")
126
129
 
130
+ # Model pool commands
131
+ setup_models = sub.add_parser("setup-models", parents=[common],
132
+ help="interactive model pool setup (no JSON knowledge needed)")
133
+ detect_models = sub.add_parser("detect-models", parents=[common],
134
+ help="auto-detect available LLM backends from your environment")
135
+ detect_models.add_argument("--no-save", action="store_true",
136
+ help="print result without saving to disk")
137
+
127
138
  mcp = sub.add_parser("mcp", help="MCP server control")
128
139
  mcp_sub = mcp.add_subparsers(dest="mcp_command")
129
140
  mcp_sub.required = False
@@ -135,7 +146,6 @@ def build_parser() -> argparse.ArgumentParser:
135
146
  def main(argv: list[str] | None = None) -> int:
136
147
  parser = build_parser()
137
148
 
138
- # No-args: show friendly intro
139
149
  if argv is None:
140
150
  argv = sys.argv[1:]
141
151
  if not argv:
@@ -144,12 +154,10 @@ def main(argv: list[str] | None = None) -> int:
144
154
 
145
155
  args = parser.parse_args(argv)
146
156
 
147
- # --version flag (before subcommand)
148
157
  if args.version:
149
158
  print(f"trinity-lite {_version()}")
150
159
  return 0
151
160
 
152
- # No subcommand but unknown args → show friendly intro
153
161
  if args.command is None:
154
162
  print(BANNER)
155
163
  return 0
@@ -198,17 +206,30 @@ def run_command(args: argparse.Namespace) -> int:
198
206
  print_json(task)
199
207
  return 0
200
208
  if args.command == "orchestrate":
201
- result = run_review_flow(
202
- args.task,
203
- bus,
204
- args.routes,
205
- args.agents,
206
- args.source,
207
- args.task_type,
208
- args.previous_agent,
209
- args.cwd,
210
- run_workers=not args.no_run and not getattr(args, "wait", False),
211
- )
209
+ if args.pipeline:
210
+ pipeline = load_pipeline(args.pipeline)
211
+ result = run_pipeline(
212
+ pipeline,
213
+ args.task,
214
+ bus,
215
+ args.routes,
216
+ args.agents,
217
+ args.source,
218
+ cwd=args.cwd,
219
+ run_workers=not args.no_run and not getattr(args, "wait", False),
220
+ )
221
+ else:
222
+ result = run_review_flow(
223
+ args.task,
224
+ bus,
225
+ args.routes,
226
+ args.agents,
227
+ args.source,
228
+ args.task_type,
229
+ args.previous_agent,
230
+ args.cwd,
231
+ run_workers=not args.no_run and not getattr(args, "wait", False),
232
+ )
212
233
  if getattr(args, "wait", False):
213
234
  result = _wait_for_flow(bus, result, args.agents, args.routes, args.wait_timeout)
214
235
  print_json(result)
@@ -223,6 +244,9 @@ def run_command(args: argparse.Namespace) -> int:
223
244
  if args.once:
224
245
  print_json(run_once(args.agent, bus, args.agents))
225
246
  return 0
247
+ if args.daemon:
248
+ pid_file = args.pid_file or str(_default_pid_path(args.agent))
249
+ return run_loop(args.agent, bus, args.agents, args.poll, pid_file=pid_file)
226
250
  run_loop(args.agent, bus, args.agents, args.poll)
227
251
  return 0
228
252
  if args.command == "send":
@@ -246,9 +270,23 @@ def run_command(args: argparse.Namespace) -> int:
246
270
  if args.command == "mcp":
247
271
  return _mcp(args, bus)
248
272
 
249
- raise AssertionError(f"unhandled command: {args.command}")
250
-
273
+ # Model pool commands (no bus needed)
274
+ if args.command == "setup-models":
275
+ from .model_pool_wizard import main as wizard_main
276
+ wizard_main()
277
+ return 0
278
+ if args.command == "detect-models":
279
+ from .model_autodetect import scan, save_pool
280
+ pool = scan()
281
+ print(f"Detected {len(pool)} backend(s):")
282
+ for name, info in pool.items():
283
+ print(f" • {name} ({info['tier']}) — {', '.join(info['strengths'][:3])}")
284
+ if not getattr(args, "no_save", False):
285
+ path = save_pool(pool)
286
+ print(f"Saved to {path}")
287
+ return 0
251
288
 
289
+ raise AssertionError(f"unhandled command: {args.command}")
252
290
  def _wait_for_task(
253
291
  bus: TrinityBus,
254
292
  task_id: str,