team-core 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
team/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """team — orchestrate a cluster of containerized local LLMs.
2
+
3
+ A `team` is a set of `Member`s (each backed by an Ollama model running in its
4
+ own Docker container) that collaborate to accomplish a `goal` according to a
5
+ chosen `workflow` (round-robin, manager-driven, review-loop, ...).
6
+
7
+ Public entry points:
8
+
9
+ * :func:`team.config.load_team` — parse a YAML team spec.
10
+ * :class:`team.orchestrator.Orchestrator` — bring members up, run a workflow,
11
+ tear them down.
12
+ * :mod:`team.cli` — the ``team`` command-line interface.
13
+ """
14
+
15
+ from team._version import __version__
16
+
17
+ __all__ = ["__version__"]
team/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
team/beliefs.py ADDED
@@ -0,0 +1,259 @@
1
+ """Shared team belief board — structured collective knowledge.
2
+
3
+ The :class:`BeliefBoard` lets team members collaboratively build and maintain
4
+ a structured record of what the team collectively knows, believes, and contests.
5
+ Each belief is a versioned claim with:
6
+
7
+ * an author and confidence score (0.0 – 1.0)
8
+ * optional supporting evidence
9
+ * a **voting record**: members can accept or contest each belief
10
+ * a status driven by consensus: ``pending`` → ``accepted`` / ``contested``
11
+
12
+ The board is stored as ``<workspace>/beliefs.json`` so it persists across
13
+ sessions and can be inspected with ``team beliefs myteam.yaml``.
14
+
15
+ Consensus rules
16
+ ---------------
17
+ A belief transitions from ``pending`` to ``accepted`` when the fraction of
18
+ members who voted *for* it is ≥ ``consensus_threshold`` (default 0.5). Any
19
+ member can contest a belief, which immediately moves it to ``contested`` —
20
+ regardless of prior votes — forcing the team to re-evaluate.
21
+
22
+ Members can accept a previously contested belief, which re-triggers the
23
+ consensus check. A rejected belief stays ``rejected`` permanently unless
24
+ explicitly re-asserted with a new claim.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import time
31
+ import uuid
32
+ from dataclasses import asdict, dataclass, field
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+
37
+ # --------------------------------------------------------------------------- #
38
+ # Data model
39
+ # --------------------------------------------------------------------------- #
40
+
41
+
42
+ @dataclass
43
+ class Belief:
44
+ id: str
45
+ claim: str
46
+ author: str
47
+ confidence: float # 0.0 – 1.0
48
+ evidence: str
49
+ status: str # pending | accepted | contested | rejected
50
+ votes_for: list[str] # member names who accepted
51
+ votes_against: list[str] # member names who contested
52
+ reasons: dict[str, str] # member → reason for contesting
53
+ created_at: float
54
+ updated_at: float
55
+
56
+
57
+ # --------------------------------------------------------------------------- #
58
+ # BeliefBoard
59
+ # --------------------------------------------------------------------------- #
60
+
61
+
62
+ class BeliefBoard:
63
+ """Shared, persistent belief store for a team."""
64
+
65
+ def __init__(
66
+ self,
67
+ path: Path,
68
+ member_names: list[str],
69
+ consensus_threshold: float = 0.5,
70
+ ) -> None:
71
+ self.path = Path(path)
72
+ self.member_names = list(member_names)
73
+ self.consensus_threshold = max(0.0, min(1.0, consensus_threshold))
74
+ self._beliefs: dict[str, Belief] = {}
75
+ self._load()
76
+
77
+ # ------------------------------------------------------------------ #
78
+ # Persistence
79
+ # ------------------------------------------------------------------ #
80
+
81
+ def _load(self) -> None:
82
+ if not self.path.exists():
83
+ return
84
+ try:
85
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
86
+ for item in raw:
87
+ b = Belief(**item)
88
+ self._beliefs[b.id] = b
89
+ except (json.JSONDecodeError, TypeError, KeyError):
90
+ # Corrupt file — start fresh rather than crashing.
91
+ self._beliefs = {}
92
+
93
+ def _save(self) -> None:
94
+ self.path.parent.mkdir(parents=True, exist_ok=True)
95
+ data = [asdict(b) for b in self._beliefs.values()]
96
+ self.path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
97
+
98
+ # ------------------------------------------------------------------ #
99
+ # Consensus check (internal)
100
+ # ------------------------------------------------------------------ #
101
+
102
+ def _check_consensus(self, b: Belief) -> None:
103
+ """Update ``b.status`` based on current votes (modifies in place)."""
104
+ n = len(self.member_names)
105
+ if n == 0:
106
+ return
107
+ ratio_for = len(b.votes_for) / n
108
+ # A contested belief only un-contests if enough votes_for accumulate.
109
+ if ratio_for >= self.consensus_threshold:
110
+ if b.status != "rejected":
111
+ b.status = "accepted"
112
+ elif b.status == "accepted":
113
+ # Acceptance lost (e.g. a new member was added or a vote was removed).
114
+ b.status = "pending"
115
+ # Contested status is only cleared by explicit accept calls that move
116
+ # it to accepted — staying contested is fine.
117
+
118
+ # ------------------------------------------------------------------ #
119
+ # Public API
120
+ # ------------------------------------------------------------------ #
121
+
122
+ def assert_belief(
123
+ self,
124
+ claim: str,
125
+ author: str,
126
+ confidence: float = 0.5,
127
+ evidence: str = "",
128
+ ) -> Belief:
129
+ """Create a new belief.
130
+
131
+ The author implicitly casts an *accept* vote. Raises
132
+ :class:`ValueError` if *author* is not in ``member_names``.
133
+ """
134
+ belief_id = str(uuid.uuid4())[:8]
135
+ now = time.time()
136
+ b = Belief(
137
+ id=belief_id,
138
+ claim=claim,
139
+ author=author,
140
+ confidence=confidence,
141
+ evidence=evidence,
142
+ status="pending",
143
+ votes_for=[author],
144
+ votes_against=[],
145
+ reasons={},
146
+ created_at=now,
147
+ updated_at=now,
148
+ )
149
+ self._beliefs[belief_id] = b
150
+ self._check_consensus(b)
151
+ self._save()
152
+ return b
153
+
154
+ def accept_belief(self, belief_id: str, voter: str) -> Belief:
155
+ """Cast an *accept* vote for an existing belief.
156
+
157
+ Removes any previous *against* vote by the same member. Re-checks
158
+ consensus after the vote, which may transition the belief to
159
+ ``accepted``. Raises :class:`KeyError` if the belief does not exist.
160
+ """
161
+ b = self._beliefs[belief_id]
162
+ if voter not in b.votes_for:
163
+ b.votes_for.append(voter)
164
+ if voter in b.votes_against:
165
+ b.votes_against.remove(voter)
166
+ if voter in b.reasons:
167
+ del b.reasons[voter]
168
+ b.updated_at = time.time()
169
+ # Accepting can pull a belief out of contested status.
170
+ if b.status == "contested":
171
+ self._check_consensus(b)
172
+ else:
173
+ self._check_consensus(b)
174
+ self._save()
175
+ return b
176
+
177
+ def contest_belief(
178
+ self,
179
+ belief_id: str,
180
+ voter: str,
181
+ reason: str = "",
182
+ ) -> Belief:
183
+ """Contest a belief, moving it to ``contested`` status.
184
+
185
+ Any previous *accept* vote by *voter* is removed. Raises
186
+ :class:`KeyError` if the belief does not exist.
187
+ """
188
+ b = self._beliefs[belief_id]
189
+ if voter not in b.votes_against:
190
+ b.votes_against.append(voter)
191
+ if voter in b.votes_for:
192
+ b.votes_for.remove(voter)
193
+ if reason:
194
+ b.reasons[voter] = reason
195
+ b.status = "contested"
196
+ b.updated_at = time.time()
197
+ self._save()
198
+ return b
199
+
200
+ def reject_belief(self, belief_id: str) -> Belief:
201
+ """Permanently reject a belief.
202
+
203
+ Only useful for orchestrator-level overrides; normally beliefs are
204
+ contested by individual members rather than rejected outright.
205
+ """
206
+ b = self._beliefs[belief_id]
207
+ b.status = "rejected"
208
+ b.updated_at = time.time()
209
+ self._save()
210
+ return b
211
+
212
+ def get_belief(self, belief_id: str) -> Belief | None:
213
+ """Return the belief with *belief_id*, or ``None``."""
214
+ return self._beliefs.get(belief_id)
215
+
216
+ def list_beliefs(
217
+ self,
218
+ status: str | None = None,
219
+ ) -> list[Belief]:
220
+ """Return all beliefs, optionally filtered by *status*.
221
+
222
+ Results are ordered by recency (most recent first).
223
+ """
224
+ results = list(self._beliefs.values())
225
+ if status:
226
+ results = [b for b in results if b.status == status]
227
+ return sorted(results, key=lambda b: b.updated_at, reverse=True)
228
+
229
+ def count(self) -> int:
230
+ """Return the total number of beliefs."""
231
+ return len(self._beliefs)
232
+
233
+ def summary_for_prompt(self, limit: int = 10) -> str:
234
+ """Compact Markdown summary of the belief board for context injection.
235
+
236
+ Returns an empty string when the board is empty.
237
+ """
238
+ beliefs = self.list_beliefs()[:limit]
239
+ if not beliefs:
240
+ return ""
241
+ _ICONS: dict[str, str] = {
242
+ "accepted": "✓",
243
+ "contested": "⚡",
244
+ "rejected": "✗",
245
+ "pending": "?",
246
+ }
247
+ lines = [f"## Shared team belief board ({len(beliefs)} entries)"]
248
+ for b in beliefs:
249
+ icon = _ICONS.get(b.status, "?")
250
+ conf = f"{b.confidence:.0%}"
251
+ lines.append(
252
+ f"- [{icon}] `{b.id}` {b.claim} "
253
+ f"(conf: {conf}, by @{b.author}, status: {b.status})"
254
+ )
255
+ return "\n".join(lines)
256
+
257
+ def to_dict_list(self) -> list[dict[str, Any]]:
258
+ """Return all beliefs as a list of plain dicts (for serialisation)."""
259
+ return [asdict(b) for b in self._beliefs.values()]
team/bridge.py ADDED
@@ -0,0 +1,197 @@
1
+ """Cross-team collaboration bridge — protocol layer.
2
+
3
+ Two ``team`` clusters running on separate machines can collaborate on a
4
+ shared goal through the bridge protocol. One cluster acts as the *client*
5
+ (it delegates a sub-task) and the other acts as the *server* (it receives
6
+ the task, runs its full team workflow, and returns the results).
7
+
8
+ Protocol objects
9
+ ----------------
10
+ :class:`BridgeTask`
11
+ Sent by the client when it wants to delegate work. Carries the goal,
12
+ an optional free-text context blurb, and any workspace files the remote
13
+ team needs to start from.
14
+
15
+ :class:`BridgeResult`
16
+ Returned by the server. Contains a free-text summary of what the remote
17
+ team accomplished, the files it produced, and a status field.
18
+
19
+ :class:`TaskStore`
20
+ Server-side, thread-safe registry of in-flight and completed tasks.
21
+ The HTTP server layer reads and writes through this store.
22
+
23
+ Wire format
24
+ -----------
25
+ All objects are serialised as plain JSON so any language/tool can interact
26
+ with a bridge server — no shared code required.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import threading
32
+ import time
33
+ import uuid
34
+ from dataclasses import asdict, dataclass, field
35
+ from typing import Any, Literal
36
+
37
+
38
+ # --------------------------------------------------------------------------- #
39
+ # Protocol dataclasses
40
+ # --------------------------------------------------------------------------- #
41
+
42
+ TaskStatus = Literal["pending", "running", "complete", "error"]
43
+
44
+
45
+ @dataclass
46
+ class BridgeTask:
47
+ """A unit of work delegated from one team cluster to another.
48
+
49
+ Parameters
50
+ ----------
51
+ goal:
52
+ What the remote team should accomplish (free text; becomes the
53
+ ``goal`` for a one-shot Orchestrator run on the server side).
54
+ context:
55
+ Optional background information to include in the remote team's
56
+ kickoff prompt.
57
+ files:
58
+ Workspace files to transfer to the remote team, keyed by their
59
+ relative path. The server writes them into the remote team's
60
+ shared workspace before running the workflow.
61
+ sender:
62
+ Name of the sending team (informational; logged by the server).
63
+ task_id:
64
+ Stable identifier for this task. Auto-generated if not provided.
65
+ created_at:
66
+ Unix timestamp. Auto-set if not provided.
67
+ """
68
+
69
+ goal: str
70
+ context: str = ""
71
+ files: dict[str, str] = field(default_factory=dict)
72
+ sender: str = "unknown"
73
+ task_id: str = field(default_factory=lambda: str(uuid.uuid4()))
74
+ created_at: float = field(default_factory=time.time)
75
+
76
+ # ------------------------------------------------------------------ #
77
+
78
+ def to_dict(self) -> dict[str, Any]:
79
+ return asdict(self)
80
+
81
+ @classmethod
82
+ def from_dict(cls, data: dict[str, Any]) -> "BridgeTask":
83
+ return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
84
+
85
+
86
+ @dataclass
87
+ class BridgeResult:
88
+ """The outcome of a delegated task, returned by the bridge server.
89
+
90
+ Parameters
91
+ ----------
92
+ task_id:
93
+ Matches the originating :class:`BridgeTask`.
94
+ status:
95
+ ``"pending"`` — task queued but not started.
96
+ ``"running"`` — team workflow in progress.
97
+ ``"complete"`` — workflow finished; inspect *summary* and *files*.
98
+ ``"error"`` — workflow raised an exception; inspect *error*.
99
+ summary:
100
+ Free-text description of what the remote team accomplished.
101
+ files:
102
+ Files produced by the remote team's shared workspace, keyed by
103
+ relative path. Written into the local workspace by the client.
104
+ error:
105
+ Set when ``status == "error"``; contains the exception message.
106
+ completed_at:
107
+ Unix timestamp of when the workflow finished (or ``None``).
108
+ """
109
+
110
+ task_id: str
111
+ status: TaskStatus = "pending"
112
+ summary: str = ""
113
+ files: dict[str, str] = field(default_factory=dict)
114
+ error: str | None = None
115
+ completed_at: float | None = None
116
+
117
+ # ------------------------------------------------------------------ #
118
+
119
+ def to_dict(self) -> dict[str, Any]:
120
+ return asdict(self)
121
+
122
+ @classmethod
123
+ def from_dict(cls, data: dict[str, Any]) -> "BridgeResult":
124
+ return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
125
+
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # Server-side task store
129
+ # --------------------------------------------------------------------------- #
130
+
131
+
132
+ class TaskStore:
133
+ """Thread-safe registry of tasks and their results.
134
+
135
+ The HTTP server layer submits tasks here and workers update them.
136
+ Clients poll through the same store.
137
+ """
138
+
139
+ def __init__(self) -> None:
140
+ self._lock = threading.Lock()
141
+ self._tasks: dict[str, BridgeTask] = {}
142
+ self._results: dict[str, BridgeResult] = {}
143
+
144
+ # ------------------------------------------------------------------ #
145
+ # Writing
146
+ # ------------------------------------------------------------------ #
147
+
148
+ def add_task(self, task: BridgeTask) -> None:
149
+ """Register a new task (status: pending)."""
150
+ with self._lock:
151
+ self._tasks[task.task_id] = task
152
+ self._results[task.task_id] = BridgeResult(
153
+ task_id=task.task_id, status="pending"
154
+ )
155
+
156
+ def mark_running(self, task_id: str) -> None:
157
+ with self._lock:
158
+ if task_id in self._results:
159
+ self._results[task_id].status = "running"
160
+
161
+ def mark_complete(
162
+ self,
163
+ task_id: str,
164
+ summary: str,
165
+ files: dict[str, str],
166
+ ) -> None:
167
+ with self._lock:
168
+ if task_id in self._results:
169
+ r = self._results[task_id]
170
+ r.status = "complete"
171
+ r.summary = summary
172
+ r.files = files
173
+ r.completed_at = time.time()
174
+
175
+ def mark_error(self, task_id: str, error: str) -> None:
176
+ with self._lock:
177
+ if task_id in self._results:
178
+ r = self._results[task_id]
179
+ r.status = "error"
180
+ r.error = error
181
+ r.completed_at = time.time()
182
+
183
+ # ------------------------------------------------------------------ #
184
+ # Reading
185
+ # ------------------------------------------------------------------ #
186
+
187
+ def get_task(self, task_id: str) -> BridgeTask | None:
188
+ with self._lock:
189
+ return self._tasks.get(task_id)
190
+
191
+ def get_result(self, task_id: str) -> BridgeResult | None:
192
+ with self._lock:
193
+ return self._results.get(task_id)
194
+
195
+ def list_task_ids(self) -> list[str]:
196
+ with self._lock:
197
+ return list(self._tasks.keys())
team/bridge_client.py ADDED
@@ -0,0 +1,157 @@
1
+ """Bridge HTTP client — submit tasks to a remote team cluster and collect results.
2
+
3
+ Usage::
4
+
5
+ from team.bridge_client import BridgeClient
6
+ from team.bridge import BridgeTask
7
+
8
+ client = BridgeClient("http://lab-b.example.com:7001")
9
+
10
+ task = BridgeTask(
11
+ goal="Run the survival analysis on the preprocessed BRCA data.",
12
+ context="Preprocessing complete; data.csv contains 1 142 samples.",
13
+ files={"data/preprocessed.csv": "<csv content>"},
14
+ sender="lab-a",
15
+ )
16
+
17
+ task_id = client.submit_task(task)
18
+ result = client.wait_for_result(task_id, timeout=600)
19
+
20
+ if result.status == "complete":
21
+ for path, content in result.files.items():
22
+ print(f"received: {path} ({len(content)} chars)")
23
+ else:
24
+ print(f"task failed: {result.error}")
25
+
26
+ All network calls use ``requests`` (already a project dependency) and raise
27
+ :class:`BridgeClientError` on HTTP or connection failures.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ import time
34
+
35
+ import requests
36
+
37
+ from team.bridge import BridgeResult, BridgeTask
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+ _DEFAULT_POLL_INTERVAL = 5.0 # seconds between status polls
42
+ _DEFAULT_TIMEOUT = 600 # seconds to wait for a result
43
+
44
+
45
+ class BridgeClientError(RuntimeError):
46
+ """Raised when a bridge HTTP call fails."""
47
+
48
+
49
+ class BridgeClient:
50
+ """HTTP client for the cross-team bridge protocol.
51
+
52
+ Parameters
53
+ ----------
54
+ base_url:
55
+ Root URL of the remote bridge server, e.g.
56
+ ``"http://lab-b.example.com:7001"``. Trailing slashes are stripped.
57
+ request_timeout:
58
+ Per-HTTP-call timeout in seconds (default: 30).
59
+ """
60
+
61
+ def __init__(self, base_url: str, *, request_timeout: int = 30) -> None:
62
+ self.base_url = base_url.rstrip("/")
63
+ self._timeout = request_timeout
64
+
65
+ # ------------------------------------------------------------------ #
66
+ # Public API
67
+ # ------------------------------------------------------------------ #
68
+
69
+ def health(self) -> dict:
70
+ """Return the server's health payload or raise :class:`BridgeClientError`."""
71
+ return self._get("/health")
72
+
73
+ def submit_task(self, task: BridgeTask) -> str:
74
+ """Submit a task and return the assigned ``task_id``.
75
+
76
+ Raises :class:`BridgeClientError` on any HTTP or network error.
77
+ """
78
+ resp = self._post("/tasks", task.to_dict())
79
+ task_id = resp.get("task_id")
80
+ if not task_id:
81
+ raise BridgeClientError(f"server returned no task_id: {resp}")
82
+ log.info("bridge client: submitted task %s to %s", task_id, self.base_url)
83
+ return task_id
84
+
85
+ def poll_result(self, task_id: str) -> BridgeResult:
86
+ """Fetch the current result/status for *task_id* (single call, no waiting).
87
+
88
+ Raises :class:`BridgeClientError` if the task is not found or the
89
+ server returns an error.
90
+ """
91
+ data = self._get(f"/tasks/{task_id}")
92
+ return BridgeResult.from_dict(data)
93
+
94
+ def wait_for_result(
95
+ self,
96
+ task_id: str,
97
+ timeout: float = _DEFAULT_TIMEOUT,
98
+ poll_interval: float = _DEFAULT_POLL_INTERVAL,
99
+ ) -> BridgeResult:
100
+ """Poll until the task reaches a terminal state and return the result.
101
+
102
+ Terminal states are ``"complete"`` and ``"error"``.
103
+
104
+ Parameters
105
+ ----------
106
+ task_id:
107
+ The task to wait for.
108
+ timeout:
109
+ Maximum total seconds to wait before raising :class:`BridgeClientError`.
110
+ poll_interval:
111
+ Seconds to sleep between polls (default: 5).
112
+
113
+ Raises :class:`BridgeClientError` if the timeout expires or if a
114
+ network error occurs during polling.
115
+ """
116
+ deadline = time.monotonic() + timeout
117
+ while True:
118
+ result = self.poll_result(task_id)
119
+ log.debug(
120
+ "bridge client: task %s status=%s", task_id, result.status
121
+ )
122
+ if result.status in ("complete", "error"):
123
+ return result
124
+ remaining = deadline - time.monotonic()
125
+ if remaining <= 0:
126
+ raise BridgeClientError(
127
+ f"timed out waiting for task {task_id!r} after {timeout}s "
128
+ f"(last status: {result.status!r})"
129
+ )
130
+ time.sleep(min(poll_interval, max(0, remaining)))
131
+
132
+ # ------------------------------------------------------------------ #
133
+ # Low-level HTTP helpers
134
+ # ------------------------------------------------------------------ #
135
+
136
+ def _get(self, path: str) -> dict:
137
+ url = self.base_url + path
138
+ try:
139
+ resp = requests.get(url, timeout=self._timeout)
140
+ resp.raise_for_status()
141
+ return resp.json()
142
+ except requests.RequestException as exc:
143
+ raise BridgeClientError(f"GET {url} failed: {exc}") from exc
144
+
145
+ def _post(self, path: str, payload: dict) -> dict:
146
+ url = self.base_url + path
147
+ try:
148
+ resp = requests.post(
149
+ url,
150
+ json=payload,
151
+ timeout=self._timeout,
152
+ headers={"Content-Type": "application/json"},
153
+ )
154
+ resp.raise_for_status()
155
+ return resp.json()
156
+ except requests.RequestException as exc:
157
+ raise BridgeClientError(f"POST {url} failed: {exc}") from exc