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.
- {trinity_lite-0.2.3/trinity_lite.egg-info → trinity_lite-0.4.0}/PKG-INFO +44 -1
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/README.md +43 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/pyproject.toml +1 -1
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_mcp_server.py +3 -3
- trinity_lite-0.4.0/tests/test_pipeline.py +185 -0
- trinity_lite-0.4.0/tests/test_worker.py +161 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/__init__.py +1 -1
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/cli.py +60 -22
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/mcp_server.py +160 -2
- trinity_lite-0.4.0/trinity_lite/model_autodetect.py +207 -0
- trinity_lite-0.4.0/trinity_lite/model_pool_wizard.py +194 -0
- trinity_lite-0.4.0/trinity_lite/model_selector.py +329 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/orchestrator.py +11 -0
- trinity_lite-0.4.0/trinity_lite/pipeline.py +158 -0
- trinity_lite-0.4.0/trinity_lite/worker.py +206 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0/trinity_lite.egg-info}/PKG-INFO +44 -1
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite.egg-info/SOURCES.txt +5 -0
- trinity_lite-0.2.3/tests/test_worker.py +0 -67
- trinity_lite-0.2.3/trinity_lite/worker.py +0 -45
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/LICENSE +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/setup.cfg +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_bus.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_cli.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_doctor.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_guard.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_orchestrator.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/tests/test_router.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/__main__.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/adapters.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/bus.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/config.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/doctor.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/guard.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/paths.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/py.typed +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/router.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite/validation.py +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite.egg-info/dependency_links.txt +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite.egg-info/entry_points.txt +0 -0
- {trinity_lite-0.2.3 → trinity_lite-0.4.0}/trinity_lite.egg-info/requires.txt +0 -0
- {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.
|
|
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/)
|
|
@@ -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.
|
|
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),
|
|
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"],
|
|
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()
|
|
@@ -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
|
-
|
|
202
|
-
args.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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,
|