@team-agent/installer 0.1.0

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 (36) hide show
  1. package/README.md +201 -0
  2. package/crates/team-agent-core/Cargo.toml +12 -0
  3. package/crates/team-agent-core/src/lib.rs +287 -0
  4. package/crates/team-agent-core/src/main.rs +152 -0
  5. package/examples/team.spec.yaml +206 -0
  6. package/examples/team_state.md +35 -0
  7. package/npm/install.mjs +266 -0
  8. package/package.json +28 -0
  9. package/pyproject.toml +18 -0
  10. package/schemas/result-envelope.schema.json +76 -0
  11. package/schemas/team.schema.json +241 -0
  12. package/scripts/install.py +88 -0
  13. package/scripts/run_regression_tests.py +79 -0
  14. package/skills/team-agent/SKILL.md +173 -0
  15. package/src/team_agent/__init__.py +3 -0
  16. package/src/team_agent/__main__.py +5 -0
  17. package/src/team_agent/cli.py +857 -0
  18. package/src/team_agent/compiler.py +269 -0
  19. package/src/team_agent/coordinator.py +62 -0
  20. package/src/team_agent/errors.py +10 -0
  21. package/src/team_agent/events.py +37 -0
  22. package/src/team_agent/fake_worker.py +80 -0
  23. package/src/team_agent/mcp_server.py +579 -0
  24. package/src/team_agent/message_store.py +497 -0
  25. package/src/team_agent/paths.py +45 -0
  26. package/src/team_agent/permissions.py +123 -0
  27. package/src/team_agent/profiles.py +882 -0
  28. package/src/team_agent/providers.py +1045 -0
  29. package/src/team_agent/routing.py +84 -0
  30. package/src/team_agent/runtime.py +5213 -0
  31. package/src/team_agent/rust_core.py +156 -0
  32. package/src/team_agent/simple_yaml.py +236 -0
  33. package/src/team_agent/spec.py +308 -0
  34. package/src/team_agent/state.py +112 -0
  35. package/src/team_agent/task_graph.py +80 -0
  36. package/templates/team_state.md +32 -0
@@ -0,0 +1,269 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from team_agent.errors import ValidationError
7
+ from team_agent.paths import team_workspace
8
+ from team_agent.profiles import AUTH_MODES, known_profiles, load_profile
9
+ from team_agent.rust_core import contains_inline_secret, validate_profile_metadata
10
+ from team_agent.simple_yaml import dumps, loads
11
+ from team_agent.spec import SUPPORTED_PROVIDERS, validate_spec
12
+
13
+
14
+ REQUIRED_ROLE_FIELDS = {"name", "role", "provider", "tools"}
15
+ DEFAULT_PROVIDER_MODELS = {
16
+ "codex": "gpt-5.5",
17
+ "claude": "claude-sonnet-4-6",
18
+ "claude_code": "claude-sonnet-4-6",
19
+ }
20
+
21
+
22
+ def compile_team(team_dir: Path, out_path: Path | None = None) -> dict[str, Any]:
23
+ team_dir = team_dir.resolve()
24
+ workspace = team_workspace(team_dir)
25
+ team_doc = team_dir / "TEAM.md"
26
+ agents_dir = team_dir / "agents"
27
+ if not team_doc.exists():
28
+ raise ValidationError(f"{team_doc}: missing TEAM.md")
29
+ if not agents_dir.exists():
30
+ raise ValidationError(f"{agents_dir}: missing agents directory")
31
+
32
+ profile_names = known_profiles(team_dir)
33
+ team_meta, team_body = _read_front_matter(team_doc)
34
+ default_model = team_meta.get("default_model") or team_meta.get("model")
35
+ provider_models = _provider_model_defaults(team_meta)
36
+ default_auth_mode = team_meta.get("default_auth_mode") or "subscription"
37
+ default_profile = team_meta.get("default_profile")
38
+ agents = []
39
+ routing_rules = []
40
+ startup_order = []
41
+ for role_doc in sorted(agents_dir.glob("*.md")):
42
+ meta, body = _read_front_matter(role_doc)
43
+ if "auth_mode" not in meta and default_auth_mode is not None:
44
+ meta["auth_mode"] = default_auth_mode
45
+ if "profile" not in meta and default_profile is not None:
46
+ meta["profile"] = default_profile
47
+ profile_model = _profile_model(workspace, meta.get("profile"), team_dir / "profiles")
48
+ if (
49
+ "model" not in meta
50
+ and not (meta.get("auth_mode") == "compatible_api" and profile_model)
51
+ ):
52
+ meta["model"] = _default_model_for_provider(meta.get("provider"), provider_models, default_model)
53
+ _validate_role_doc(role_doc, meta, body, profile_names, profile_model)
54
+ agent_id = str(meta["name"])
55
+ tools = _normalize_tools(list(meta["tools"] or []))
56
+ agent = {
57
+ "id": agent_id,
58
+ "role": str(meta["role"]),
59
+ "provider": str(meta["provider"]),
60
+ "model": str(meta["model"]) if meta.get("model") is not None else None,
61
+ "auth_mode": str(meta["auth_mode"]),
62
+ "working_directory": str(workspace),
63
+ "system_prompt": {"inline": body.strip() or str(meta["role"]), "file": None},
64
+ "tools": tools,
65
+ "permission_mode": "restricted",
66
+ "preferred_for": [agent_id, str(meta["role"])],
67
+ "avoid_for": [],
68
+ "output_contract": {
69
+ "format": "result_envelope_v1",
70
+ "required_fields": ["task_id", "status", "summary", "artifacts"],
71
+ },
72
+ }
73
+ if meta.get("profile"):
74
+ agent["profile"] = str(meta["profile"])
75
+ agent["credential_ref"] = f"profile:{meta['profile']}"
76
+ agents.append(agent)
77
+ routing_rules.append({"id": f"route-{agent_id}", "match": {"assignee": [agent_id]}, "assign_to": agent_id, "priority": 10})
78
+ startup_order.append(agent_id)
79
+ if not agents:
80
+ raise ValidationError(f"{agents_dir}: no role docs found")
81
+
82
+ default_agent = agents[0]["id"]
83
+ team_name = str(team_meta.get("name") or team_dir.parent.name or "team-agent-team")
84
+ spec = {
85
+ "version": 1,
86
+ "team": {
87
+ "name": team_name,
88
+ "mode": "supervisor_worker",
89
+ "objective": str(team_meta.get("objective") or team_body.strip() or "Team Agent document-driven team."),
90
+ "workspace": str(workspace),
91
+ },
92
+ "leader": {
93
+ "id": "leader",
94
+ "role": str(team_meta.get("leader_role") or "leader"),
95
+ "provider": str(team_meta.get("provider") or "codex"),
96
+ "model": team_meta.get("model"),
97
+ "tools": ["fs_read", "fs_list", "mcp_team"],
98
+ "context_policy": {
99
+ "keep_user_thread": True,
100
+ "receive_worker_outputs": "business_messages_and_short_summaries",
101
+ "max_worker_result_tokens": 2000,
102
+ },
103
+ },
104
+ "agents": agents,
105
+ "routing": {"default_assignee": default_agent, "rules": routing_rules},
106
+ "communication": {
107
+ "protocol": "mcp_inbox",
108
+ "topology": "leader_centered",
109
+ "worker_to_worker": bool(team_meta.get("worker_to_worker", True)),
110
+ "ack_timeout_sec": 60,
111
+ "result_format": "result_envelope_v1",
112
+ "message_store": {"sqlite": ".team/runtime/team.db", "mirror_files": ".team/messages"},
113
+ },
114
+ "runtime": {
115
+ "backend": "tmux",
116
+ "display_backend": str(team_meta.get("display_backend") or "ghostty_window"),
117
+ "session_name": str(team_meta.get("session_name") or f"team-{_slug(team_name)}"),
118
+ "auto_launch": True,
119
+ "require_user_approval_before_launch": True,
120
+ "max_active_agents": min(len(agents), 2),
121
+ "startup_order": startup_order,
122
+ "dangerous_auto_approve": bool(team_meta.get("dangerous_auto_approve", False)),
123
+ "fast": bool(team_meta.get("fast", False)),
124
+ "tick_interval_sec": int(team_meta.get("tick_interval_sec", 2)),
125
+ "push_min_interval_sec": int(team_meta.get("push_min_interval_sec", 60)),
126
+ "stuck_timeout_sec": int(team_meta.get("stuck_timeout_sec", 300)),
127
+ },
128
+ "context": {
129
+ "state_file": "team_state.md",
130
+ "artifact_dir": ".team/artifacts",
131
+ "log_dir": ".team/logs",
132
+ "summarization": {
133
+ "worker_full_logs": "retain_outside_leader_context",
134
+ "state_update": "after_each_result",
135
+ },
136
+ },
137
+ "tasks": [
138
+ {
139
+ "id": "task_initial",
140
+ "title": "Initial document-driven team task",
141
+ "type": "implementation",
142
+ "assignee": default_agent,
143
+ "deps": [],
144
+ "acceptance": ["Worker reports valid result_envelope_v1"],
145
+ "status": "pending",
146
+ "requires_tools": ["mcp_team"],
147
+ "files": [],
148
+ "risk": "low",
149
+ }
150
+ ],
151
+ }
152
+ validate_spec(spec, base_dir=workspace)
153
+ if out_path:
154
+ out_path.write_text(dumps(spec), encoding="utf-8")
155
+ return {"ok": True, "team_dir": str(team_dir), "out": str(out_path) if out_path else None, "spec": spec}
156
+
157
+
158
+ def _read_front_matter(path: Path) -> tuple[dict[str, Any], str]:
159
+ text = path.read_text(encoding="utf-8")
160
+ if not text.startswith("---\n"):
161
+ return {}, text
162
+ end = text.find("\n---", 4)
163
+ if end == -1:
164
+ raise ValidationError(f"{path}: unterminated front matter")
165
+ raw = text[4:end]
166
+ body = text[end + 4 :].lstrip("\n")
167
+ data = loads(raw) if raw.strip() else {}
168
+ if not isinstance(data, dict):
169
+ raise ValidationError(f"{path}: front matter must be a YAML object")
170
+ return data, body
171
+
172
+
173
+ def _validate_role_doc(
174
+ path: Path,
175
+ meta: dict[str, Any],
176
+ body: str,
177
+ profile_names: set[str],
178
+ profile_model: str | None = None,
179
+ ) -> None:
180
+ errors = []
181
+ missing = sorted(REQUIRED_ROLE_FIELDS - set(meta))
182
+ for field in missing:
183
+ errors.append(f"{path}: missing front matter field {field}")
184
+ provider = meta.get("provider")
185
+ if provider not in SUPPORTED_PROVIDERS:
186
+ errors.append(f"{path}: unknown provider {provider!r}")
187
+ auth_mode = meta.get("auth_mode")
188
+ if auth_mode not in AUTH_MODES:
189
+ errors.append(f"{path}: unknown auth_mode {auth_mode!r}")
190
+ profile = meta.get("profile")
191
+ if profile and profile not in profile_names:
192
+ errors.append(f"{path}: unknown profile {profile!r}")
193
+ if auth_mode != "subscription" and not profile:
194
+ errors.append(f"{path}: profile is required when auth_mode is {auth_mode!r}")
195
+ role_model = str(meta.get("model") or "") or None
196
+ if auth_mode == "compatible_api" and role_model and profile_model and role_model != profile_model:
197
+ errors.append(
198
+ f"{path}: role model {role_model!r} does not match profile MODEL {profile_model!r}; "
199
+ "keep the model in one place or make both values identical"
200
+ )
201
+ if not isinstance(meta.get("tools"), list):
202
+ errors.append(f"{path}: tools must be a list")
203
+ if profile:
204
+ profile_check = validate_profile_metadata(
205
+ {
206
+ "provider": provider or "",
207
+ "model": role_model or profile_model or "",
208
+ "auth_mode": auth_mode or "",
209
+ "profile": profile or "",
210
+ "credential_ref": f"profile:{profile}",
211
+ }
212
+ )
213
+ if not profile_check.get("ok"):
214
+ errors.extend(f"{path}: {err}" for err in profile_check.get("errors", []))
215
+ for value in [body, dumps(meta)]:
216
+ if contains_inline_secret(value):
217
+ errors.append(f"{path}: probable inline secret detected; use profile/credential_ref instead")
218
+ break
219
+ if errors:
220
+ raise ValidationError("\n".join(errors))
221
+
222
+
223
+ def _normalize_tools(tools: list[Any]) -> list[str]:
224
+ mapping = {"shell": "execute_bash"}
225
+ return [mapping.get(str(tool), str(tool)) for tool in tools]
226
+
227
+
228
+ def _profile_model(workspace: Path, profile: Any, profiles_dir: Path | None = None) -> str | None:
229
+ if not profile:
230
+ return None
231
+ values = load_profile(workspace, str(profile), profiles_dir).get("values", {})
232
+ if not isinstance(values, dict):
233
+ return None
234
+ return values.get("MODEL") or values.get("ANTHROPIC_MODEL")
235
+
236
+
237
+ def _provider_model_defaults(team_meta: dict[str, Any]) -> dict[str, str]:
238
+ raw = team_meta.get("provider_models") or {}
239
+ if not isinstance(raw, dict):
240
+ return {}
241
+ return {str(provider): str(model) for provider, model in raw.items() if model}
242
+
243
+
244
+ def _default_model_for_provider(
245
+ provider: Any,
246
+ provider_models: dict[str, str],
247
+ default_model: Any,
248
+ ) -> str | None:
249
+ provider_id = str(provider or "")
250
+ if provider_id in provider_models:
251
+ return provider_models[provider_id]
252
+ if provider_id == "claude_code" and "claude" in provider_models:
253
+ return provider_models["claude"]
254
+ if provider_id == "claude" and "claude_code" in provider_models:
255
+ return provider_models["claude_code"]
256
+ if default_model is not None:
257
+ return str(default_model)
258
+ return DEFAULT_PROVIDER_MODELS.get(provider_id)
259
+
260
+
261
+ def _slug(value: str) -> str:
262
+ out = []
263
+ for ch in value:
264
+ if ch.isalnum() or ch in {"-", "_"}:
265
+ out.append(ch)
266
+ else:
267
+ out.append("-")
268
+ slug = "".join(out).strip("-_")
269
+ return slug or "team"
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import signal
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from team_agent import runtime
10
+ from team_agent.events import EventLog
11
+ from team_agent.message_store import MessageStore
12
+ from team_agent.spec import load_spec
13
+ from team_agent.state import load_runtime_state
14
+
15
+
16
+ STOP = False
17
+
18
+
19
+ def _stop(_signum, _frame) -> None:
20
+ global STOP
21
+ STOP = True
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> None:
25
+ parser = argparse.ArgumentParser(description="Team Agent per-workspace coordinator daemon")
26
+ parser.add_argument("--workspace", required=True)
27
+ parser.add_argument("--once", action="store_true")
28
+ parser.add_argument("--tick-interval", type=float)
29
+ args = parser.parse_args(argv)
30
+ workspace = Path(args.workspace).resolve()
31
+ runtime.ensure_workspace_dirs(workspace)
32
+ runtime.coordinator_pid_path(workspace).write_text(str(__import__("os").getpid()), encoding="utf-8")
33
+ event_log = EventLog(workspace)
34
+ event_log.write("coordinator.boot", workspace=str(workspace), once=args.once)
35
+ signal.signal(signal.SIGTERM, _stop)
36
+ signal.signal(signal.SIGINT, _stop)
37
+
38
+ interval = args.tick_interval if args.tick_interval is not None else _tick_interval(workspace)
39
+ while not STOP:
40
+ result = runtime.coordinator_tick(workspace)
41
+ if result.get("stop") or args.once:
42
+ break
43
+ time.sleep(interval)
44
+ event_log.write("coordinator.exit", stop=STOP)
45
+
46
+
47
+ def _tick_interval(workspace: Path) -> float:
48
+ state = load_runtime_state(workspace)
49
+ spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
50
+ if spec_path.exists():
51
+ try:
52
+ spec = load_spec(spec_path)
53
+ return float(spec.get("runtime", {}).get("tick_interval_sec", 2))
54
+ except Exception:
55
+ pass
56
+ # Ensure schema exists even before launch; this makes doctor/tick diagnostics deterministic.
57
+ MessageStore(workspace)
58
+ return 2.0
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main(sys.argv[1:])
@@ -0,0 +1,10 @@
1
+ class TeamAgentError(Exception):
2
+ """Base exception for user-facing Team Agent errors."""
3
+
4
+
5
+ class ValidationError(TeamAgentError):
6
+ """Spec or result envelope validation failed."""
7
+
8
+
9
+ class RuntimeError(TeamAgentError):
10
+ """Runtime operation failed."""
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from team_agent.paths import logs_dir
9
+
10
+
11
+ class EventLog:
12
+ def __init__(self, workspace: Path):
13
+ self.workspace = workspace
14
+ self.path = logs_dir(workspace) / "events.jsonl"
15
+ self.path.parent.mkdir(parents=True, exist_ok=True)
16
+
17
+ def write(self, event_type: str, **fields: Any) -> dict[str, Any]:
18
+ event = {
19
+ "ts": datetime.now(timezone.utc).isoformat(),
20
+ "event": event_type,
21
+ **fields,
22
+ }
23
+ with self.path.open("a", encoding="utf-8") as f:
24
+ f.write(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n")
25
+ return event
26
+
27
+ def tail(self, limit: int = 20) -> list[dict[str, Any]]:
28
+ if not self.path.exists():
29
+ return []
30
+ lines = self.path.read_text(encoding="utf-8").splitlines()[-limit:]
31
+ out: list[dict[str, Any]] = []
32
+ for line in lines:
33
+ try:
34
+ out.append(json.loads(line))
35
+ except json.JSONDecodeError:
36
+ out.append({"raw": line})
37
+ return out
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from team_agent.runtime import report_result
10
+
11
+
12
+ def main() -> None:
13
+ parser = argparse.ArgumentParser()
14
+ parser.add_argument("--workspace", required=True)
15
+ parser.add_argument("--agent-id", required=True)
16
+ args = parser.parse_args()
17
+ workspace = Path(args.workspace)
18
+ print(f"TEAM_AGENT_FAKE_READY agent={args.agent_id}", flush=True)
19
+ rendered_message: list[str] | None = None
20
+ for line in sys.stdin:
21
+ line = line.strip()
22
+ if not line:
23
+ if rendered_message is not None:
24
+ rendered_message.append("")
25
+ continue
26
+ print(f"TEAM_AGENT_FAKE_WORKING agent={args.agent_id}", flush=True)
27
+ if line.startswith("TEAM_AGENT_MESSAGE "):
28
+ payload = json.loads(line.removeprefix("TEAM_AGENT_MESSAGE "))
29
+ _report_fake_result(workspace, args.agent_id, payload)
30
+ elif line.startswith("Team Agent message from "):
31
+ rendered_message = [line]
32
+ elif rendered_message is not None:
33
+ rendered_message.append(line)
34
+ if line.startswith("[team-agent-token:"):
35
+ payload = _parse_rendered_message(rendered_message)
36
+ _report_fake_result(workspace, args.agent_id, payload)
37
+ rendered_message = None
38
+ print(f"TEAM_AGENT_FAKE_READY agent={args.agent_id}", flush=True)
39
+
40
+
41
+ def _parse_rendered_message(lines: list[str]) -> dict[str, str | None]:
42
+ header = lines[0]
43
+ match = re.match(r"Team Agent message from (?P<sender>[^:]+?)(?: for (?P<task_id>[^:]+))?:$", header)
44
+ token_line = next((line for line in lines if line.startswith("[team-agent-token:")), "[team-agent-token:manual]")
45
+ token = token_line.removeprefix("[team-agent-token:").removesuffix("]")
46
+ content_lines = [line for line in lines[1:] if not line.startswith("[team-agent-token:")]
47
+ content = "\n".join(content_lines).strip()
48
+ return {
49
+ "message_id": token,
50
+ "task_id": match.group("task_id") if match else None,
51
+ "from": match.group("sender") if match else "leader",
52
+ "content": content,
53
+ }
54
+
55
+
56
+ def _report_fake_result(workspace: Path, agent_id: str, payload: dict) -> None:
57
+ task_id = payload.get("task_id") or "manual"
58
+ envelope = {
59
+ "schema_version": "result_envelope_v1",
60
+ "task_id": task_id,
61
+ "agent_id": agent_id,
62
+ "status": "success",
63
+ "summary": f"Fake worker handled message {payload['message_id']}",
64
+ "changes": [],
65
+ "tests": [{"command": "fake-provider", "status": "passed"}],
66
+ "risks": [],
67
+ "artifacts": [
68
+ {
69
+ "path": str(workspace / ".team" / "logs" / f"{agent_id}.scrollback"),
70
+ "description": "tmux scrollback for fake worker",
71
+ }
72
+ ],
73
+ "next_actions": [],
74
+ }
75
+ report_result(workspace, envelope)
76
+ print(json.dumps(envelope), flush=True)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()