@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.
- package/README.md +201 -0
- package/crates/team-agent-core/Cargo.toml +12 -0
- package/crates/team-agent-core/src/lib.rs +287 -0
- package/crates/team-agent-core/src/main.rs +152 -0
- package/examples/team.spec.yaml +206 -0
- package/examples/team_state.md +35 -0
- package/npm/install.mjs +266 -0
- package/package.json +28 -0
- package/pyproject.toml +18 -0
- package/schemas/result-envelope.schema.json +76 -0
- package/schemas/team.schema.json +241 -0
- package/scripts/install.py +88 -0
- package/scripts/run_regression_tests.py +79 -0
- package/skills/team-agent/SKILL.md +173 -0
- package/src/team_agent/__init__.py +3 -0
- package/src/team_agent/__main__.py +5 -0
- package/src/team_agent/cli.py +857 -0
- package/src/team_agent/compiler.py +269 -0
- package/src/team_agent/coordinator.py +62 -0
- package/src/team_agent/errors.py +10 -0
- package/src/team_agent/events.py +37 -0
- package/src/team_agent/fake_worker.py +80 -0
- package/src/team_agent/mcp_server.py +579 -0
- package/src/team_agent/message_store.py +497 -0
- package/src/team_agent/paths.py +45 -0
- package/src/team_agent/permissions.py +123 -0
- package/src/team_agent/profiles.py +882 -0
- package/src/team_agent/providers.py +1045 -0
- package/src/team_agent/routing.py +84 -0
- package/src/team_agent/runtime.py +5213 -0
- package/src/team_agent/rust_core.py +156 -0
- package/src/team_agent/simple_yaml.py +236 -0
- package/src/team_agent/spec.py +308 -0
- package/src/team_agent/state.py +112 -0
- package/src/team_agent/task_graph.py +80 -0
- package/templates/team_state.md +32 -0
|
@@ -0,0 +1,308 @@
|
|
|
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.permissions import CANONICAL_TOOLS, expand_tools
|
|
8
|
+
from team_agent.profiles import AUTH_MODES
|
|
9
|
+
from team_agent.simple_yaml import loads
|
|
10
|
+
from team_agent.task_graph import find_dependency_cycle
|
|
11
|
+
|
|
12
|
+
SUPPORTED_PROVIDERS = {"claude", "claude_code", "codex", "gemini_cli", "fake"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_yaml(path: Path) -> dict[str, Any]:
|
|
16
|
+
try:
|
|
17
|
+
data = loads(path.read_text(encoding="utf-8"))
|
|
18
|
+
except OSError as exc:
|
|
19
|
+
raise ValidationError(f"Cannot read {path}: {exc}") from exc
|
|
20
|
+
except ValueError as exc:
|
|
21
|
+
raise ValidationError(f"Invalid YAML in {path}: {exc}") from exc
|
|
22
|
+
if not isinstance(data, dict):
|
|
23
|
+
raise ValidationError(f"{path} must contain a YAML object")
|
|
24
|
+
return data
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_spec(path: Path) -> dict[str, Any]:
|
|
28
|
+
spec = load_yaml(path)
|
|
29
|
+
validate_spec(spec, base_dir=path.parent)
|
|
30
|
+
return spec
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_spec(spec: dict[str, Any], base_dir: Path | None = None) -> None:
|
|
34
|
+
messages = _basic_schema_errors(spec)
|
|
35
|
+
messages.extend(_semantic_errors(spec, base_dir or Path.cwd()))
|
|
36
|
+
if messages:
|
|
37
|
+
joined = "\n".join(f"- {m}" for m in messages)
|
|
38
|
+
raise ValidationError(f"team.spec.yaml validation failed:\n{joined}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
RESULT_COLLECTION_SCHEMAS: dict[str, tuple[set[str], set[str]]] = {
|
|
42
|
+
"changes": ({"path", "kind", "description"}, {"path", "kind", "description"}),
|
|
43
|
+
"tests": ({"command", "status"}, {"command", "status", "detail"}),
|
|
44
|
+
"risks": ({"severity", "description"}, {"severity", "description"}),
|
|
45
|
+
"artifacts": ({"path", "description"}, {"path", "description"}),
|
|
46
|
+
"next_actions": ({"description"}, {"description"}),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_result_envelope(envelope: dict[str, Any]) -> None:
|
|
51
|
+
errors = _result_schema_errors(envelope)
|
|
52
|
+
if errors:
|
|
53
|
+
joined = "\n".join(f"- {error}" for error in errors)
|
|
54
|
+
raise ValidationError(f"result_envelope_v1 validation failed:\n{joined}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _basic_schema_errors(spec: dict[str, Any]) -> list[str]:
|
|
58
|
+
errors: list[str] = []
|
|
59
|
+
root_keys = {"version", "team", "leader", "agents", "routing", "communication", "runtime", "context", "tasks"}
|
|
60
|
+
_check_keys(spec, "/", root_keys, root_keys, errors)
|
|
61
|
+
if spec.get("version") != 1:
|
|
62
|
+
errors.append("/version: must equal 1")
|
|
63
|
+
_check_keys(spec.get("team"), "/team", {"name", "mode", "objective", "workspace"}, {"name", "mode", "objective", "workspace"}, errors)
|
|
64
|
+
if spec.get("team", {}).get("mode") not in {"supervisor_worker", "swarm_limited"}:
|
|
65
|
+
errors.append("/team/mode: invalid mode")
|
|
66
|
+
_check_keys(
|
|
67
|
+
spec.get("leader"),
|
|
68
|
+
"/leader",
|
|
69
|
+
{"id", "role", "provider", "model", "tools", "context_policy"},
|
|
70
|
+
{"id", "role", "provider", "model", "tools", "context_policy"},
|
|
71
|
+
errors,
|
|
72
|
+
)
|
|
73
|
+
_check_context_policy(spec.get("leader", {}).get("context_policy"), errors)
|
|
74
|
+
if not isinstance(spec.get("agents"), list) or not spec.get("agents"):
|
|
75
|
+
errors.append("/agents: must be a non-empty list")
|
|
76
|
+
else:
|
|
77
|
+
for idx, agent in enumerate(spec["agents"]):
|
|
78
|
+
_check_agent(agent, f"/agents/{idx}", errors)
|
|
79
|
+
_check_routing(spec.get("routing"), errors)
|
|
80
|
+
_check_communication(spec.get("communication"), errors)
|
|
81
|
+
_check_runtime(spec.get("runtime"), errors)
|
|
82
|
+
_check_context(spec.get("context"), errors)
|
|
83
|
+
if not isinstance(spec.get("tasks"), list):
|
|
84
|
+
errors.append("/tasks: must be a list")
|
|
85
|
+
else:
|
|
86
|
+
for idx, task in enumerate(spec["tasks"]):
|
|
87
|
+
_check_task(task, f"/tasks/{idx}", errors)
|
|
88
|
+
return errors
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _result_schema_errors(envelope: Any) -> list[str]:
|
|
92
|
+
errors: list[str] = []
|
|
93
|
+
required = {"schema_version", "task_id", "agent_id", "status", "summary", "changes", "tests", "risks", "artifacts", "next_actions"}
|
|
94
|
+
_check_keys(envelope, "/", required, required, errors)
|
|
95
|
+
if not isinstance(envelope, dict):
|
|
96
|
+
return errors
|
|
97
|
+
if envelope.get("schema_version") != "result_envelope_v1":
|
|
98
|
+
errors.append("/schema_version: must be result_envelope_v1")
|
|
99
|
+
for field in ["task_id", "agent_id", "summary"]:
|
|
100
|
+
if field in envelope and not isinstance(envelope[field], str):
|
|
101
|
+
errors.append(f"/{field}: must be a string")
|
|
102
|
+
elif field in envelope and not envelope[field]:
|
|
103
|
+
errors.append(f"/{field}: must not be empty")
|
|
104
|
+
if envelope.get("status") not in {"success", "blocked", "failed", "partial"}:
|
|
105
|
+
errors.append("/status: invalid result status")
|
|
106
|
+
if "schema" in envelope:
|
|
107
|
+
errors.append("/schema: use schema_version, not schema")
|
|
108
|
+
for field, (item_required, item_allowed) in RESULT_COLLECTION_SCHEMAS.items():
|
|
109
|
+
if field not in envelope:
|
|
110
|
+
continue
|
|
111
|
+
value = envelope[field]
|
|
112
|
+
if not isinstance(value, list):
|
|
113
|
+
errors.append(f"/{field}: must be a list")
|
|
114
|
+
continue
|
|
115
|
+
for idx, item in enumerate(value):
|
|
116
|
+
item_path = f"/{field}/{idx}"
|
|
117
|
+
_check_keys(item, item_path, item_required, item_allowed, errors)
|
|
118
|
+
if not isinstance(item, dict):
|
|
119
|
+
continue
|
|
120
|
+
if field == "changes" and item.get("kind") not in {"created", "modified", "deleted", "observed"}:
|
|
121
|
+
errors.append(f"{item_path}/kind: invalid change kind")
|
|
122
|
+
if field == "tests" and item.get("status") not in {"passed", "failed", "not_run", "skipped"}:
|
|
123
|
+
errors.append(f"{item_path}/status: invalid test status")
|
|
124
|
+
if field == "risks" and item.get("severity") not in {"low", "medium", "high"}:
|
|
125
|
+
errors.append(f"{item_path}/severity: invalid risk severity")
|
|
126
|
+
for key, child in item.items():
|
|
127
|
+
if key in item_allowed and not isinstance(child, str):
|
|
128
|
+
errors.append(f"{item_path}/{key}: must be a string")
|
|
129
|
+
return errors
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_agent(agent: Any, path: str, errors: list[str]) -> None:
|
|
133
|
+
required = {"id", "role", "provider", "model", "working_directory", "system_prompt", "tools", "permission_mode", "preferred_for", "avoid_for", "output_contract"}
|
|
134
|
+
allowed = required | {"paused", "auth_mode", "profile", "credential_ref"}
|
|
135
|
+
_check_keys(agent, path, required, allowed, errors)
|
|
136
|
+
if not isinstance(agent, dict):
|
|
137
|
+
return
|
|
138
|
+
_check_keys(agent.get("system_prompt"), f"{path}/system_prompt", {"inline", "file"}, {"inline", "file"}, errors)
|
|
139
|
+
_check_list(agent.get("tools"), f"{path}/tools", errors)
|
|
140
|
+
_check_list(agent.get("preferred_for"), f"{path}/preferred_for", errors)
|
|
141
|
+
_check_list(agent.get("avoid_for"), f"{path}/avoid_for", errors)
|
|
142
|
+
_check_keys(agent.get("output_contract"), f"{path}/output_contract", {"format", "required_fields"}, {"format", "required_fields"}, errors)
|
|
143
|
+
if agent.get("output_contract", {}).get("format") != "result_envelope_v1":
|
|
144
|
+
errors.append(f"{path}/output_contract/format: must be result_envelope_v1")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _check_context_policy(policy: Any, errors: list[str]) -> None:
|
|
148
|
+
_check_keys(
|
|
149
|
+
policy,
|
|
150
|
+
"/leader/context_policy",
|
|
151
|
+
{"keep_user_thread", "receive_worker_outputs", "max_worker_result_tokens"},
|
|
152
|
+
{"keep_user_thread", "receive_worker_outputs", "max_worker_result_tokens"},
|
|
153
|
+
errors,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _check_routing(routing: Any, errors: list[str]) -> None:
|
|
158
|
+
_check_keys(routing, "/routing", {"default_assignee", "rules"}, {"default_assignee", "rules"}, errors)
|
|
159
|
+
if not isinstance(routing, dict):
|
|
160
|
+
return
|
|
161
|
+
if not isinstance(routing.get("rules"), list):
|
|
162
|
+
errors.append("/routing/rules: must be a list")
|
|
163
|
+
return
|
|
164
|
+
for idx, rule in enumerate(routing["rules"]):
|
|
165
|
+
allowed = {"id", "when", "match", "assign_to", "priority"}
|
|
166
|
+
required = {"id", "assign_to", "priority"}
|
|
167
|
+
_check_keys(rule, f"/routing/rules/{idx}", required, allowed, errors)
|
|
168
|
+
if isinstance(rule, dict) and not (rule.get("when") or rule.get("match")):
|
|
169
|
+
errors.append(f"/routing/rules/{idx}: must include when or match")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _check_communication(comm: Any, errors: list[str]) -> None:
|
|
173
|
+
required = {"protocol", "topology", "worker_to_worker", "ack_timeout_sec", "result_format", "message_store"}
|
|
174
|
+
_check_keys(comm, "/communication", required, required, errors)
|
|
175
|
+
if not isinstance(comm, dict):
|
|
176
|
+
return
|
|
177
|
+
if comm.get("protocol") not in {"mcp_inbox", "file_bus"}:
|
|
178
|
+
errors.append("/communication/protocol: invalid protocol")
|
|
179
|
+
if comm.get("result_format") != "result_envelope_v1":
|
|
180
|
+
errors.append("/communication/result_format: must be result_envelope_v1")
|
|
181
|
+
_check_keys(comm.get("message_store"), "/communication/message_store", {"sqlite", "mirror_files"}, {"sqlite", "mirror_files"}, errors)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check_runtime(runtime: Any, errors: list[str]) -> None:
|
|
185
|
+
required = {"backend", "display_backend", "session_name", "auto_launch", "require_user_approval_before_launch", "max_active_agents", "startup_order"}
|
|
186
|
+
allowed = required | {
|
|
187
|
+
"dangerous_auto_approve",
|
|
188
|
+
"auto_attach_leader",
|
|
189
|
+
"fast",
|
|
190
|
+
"tick_interval_sec",
|
|
191
|
+
"push_min_interval_sec",
|
|
192
|
+
"stuck_timeout_sec",
|
|
193
|
+
}
|
|
194
|
+
_check_keys(runtime, "/runtime", required, allowed, errors)
|
|
195
|
+
if not isinstance(runtime, dict):
|
|
196
|
+
return
|
|
197
|
+
if runtime.get("backend") not in {"tmux", "pty"}:
|
|
198
|
+
errors.append("/runtime/backend: invalid backend")
|
|
199
|
+
if runtime.get("display_backend") not in {"none", "tmux_attach", "iterm", "ghostty", "ghostty_window"}:
|
|
200
|
+
errors.append("/runtime/display_backend: invalid display backend")
|
|
201
|
+
if "dangerous_auto_approve" in runtime and not isinstance(runtime["dangerous_auto_approve"], bool):
|
|
202
|
+
errors.append("/runtime/dangerous_auto_approve: must be a boolean")
|
|
203
|
+
_check_list(runtime.get("startup_order"), "/runtime/startup_order", errors)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _check_context(context: Any, errors: list[str]) -> None:
|
|
207
|
+
required = {"state_file", "artifact_dir", "log_dir", "summarization"}
|
|
208
|
+
_check_keys(context, "/context", required, required, errors)
|
|
209
|
+
if isinstance(context, dict):
|
|
210
|
+
_check_keys(context.get("summarization"), "/context/summarization", {"worker_full_logs", "state_update"}, {"worker_full_logs", "state_update"}, errors)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _check_task(task: Any, path: str, errors: list[str]) -> None:
|
|
214
|
+
required = {"id", "title", "type", "assignee", "deps", "acceptance", "status"}
|
|
215
|
+
allowed = required | {"description", "requires_tools", "files", "risk", "retry_limit", "human_confirmation"}
|
|
216
|
+
_check_keys(task, path, required, allowed, errors)
|
|
217
|
+
if not isinstance(task, dict):
|
|
218
|
+
return
|
|
219
|
+
_check_list(task.get("deps"), f"{path}/deps", errors)
|
|
220
|
+
_check_list(task.get("acceptance"), f"{path}/acceptance", errors)
|
|
221
|
+
if task.get("status") not in {"pending", "ready", "running", "blocked", "needs_retry", "done", "failed", "cancelled"}:
|
|
222
|
+
errors.append(f"{path}/status: invalid task status")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _check_keys(obj: Any, path: str, required: set[str], allowed: set[str], errors: list[str]) -> None:
|
|
226
|
+
if not isinstance(obj, dict):
|
|
227
|
+
errors.append(f"{path}: must be an object")
|
|
228
|
+
return
|
|
229
|
+
missing = sorted(required - set(obj))
|
|
230
|
+
for key in missing:
|
|
231
|
+
errors.append(f"{path.rstrip('/')}/{key}: missing required field")
|
|
232
|
+
unknown = sorted(set(obj) - allowed)
|
|
233
|
+
for key in unknown:
|
|
234
|
+
errors.append(f"{path.rstrip('/')}/{key}: unknown field")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _check_list(value: Any, path: str, errors: list[str]) -> None:
|
|
238
|
+
if not isinstance(value, list):
|
|
239
|
+
errors.append(f"{path}: must be a list")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _semantic_errors(spec: dict[str, Any], base_dir: Path) -> list[str]:
|
|
243
|
+
errors: list[str] = []
|
|
244
|
+
leader = spec.get("leader", {})
|
|
245
|
+
agents = spec.get("agents", [])
|
|
246
|
+
agent_ids = {a.get("id") for a in agents if isinstance(a, dict)}
|
|
247
|
+
all_ids = set(agent_ids)
|
|
248
|
+
if leader.get("id"):
|
|
249
|
+
all_ids.add(leader["id"])
|
|
250
|
+
|
|
251
|
+
for path, provider in [("/leader/provider", leader.get("provider"))]:
|
|
252
|
+
if provider not in SUPPORTED_PROVIDERS:
|
|
253
|
+
errors.append(f"{path}: unknown provider {provider!r}")
|
|
254
|
+
for idx, agent in enumerate(agents):
|
|
255
|
+
provider = agent.get("provider")
|
|
256
|
+
if provider not in SUPPORTED_PROVIDERS:
|
|
257
|
+
errors.append(f"/agents/{idx}/provider: unknown provider {provider!r}")
|
|
258
|
+
auth_mode = agent.get("auth_mode")
|
|
259
|
+
if auth_mode is not None and auth_mode not in AUTH_MODES:
|
|
260
|
+
errors.append(f"/agents/{idx}/auth_mode: unknown auth_mode {auth_mode!r}")
|
|
261
|
+
prompt_file = agent.get("system_prompt", {}).get("file")
|
|
262
|
+
if prompt_file:
|
|
263
|
+
candidate = Path(prompt_file)
|
|
264
|
+
if not candidate.is_absolute():
|
|
265
|
+
candidate = base_dir / candidate
|
|
266
|
+
if not candidate.exists():
|
|
267
|
+
errors.append(f"/agents/{idx}/system_prompt/file: file not found: {candidate}")
|
|
268
|
+
for tool in expand_tools(agent.get("tools", [])):
|
|
269
|
+
if tool not in CANONICAL_TOOLS:
|
|
270
|
+
errors.append(f"/agents/{idx}/tools: unknown tool {tool!r}")
|
|
271
|
+
|
|
272
|
+
leader_tools = leader.get("tools", [])
|
|
273
|
+
for tool in expand_tools(leader_tools):
|
|
274
|
+
if tool not in CANONICAL_TOOLS:
|
|
275
|
+
errors.append(f"/leader/tools: unknown tool {tool!r}")
|
|
276
|
+
|
|
277
|
+
routing = spec.get("routing", {})
|
|
278
|
+
default_assignee = routing.get("default_assignee")
|
|
279
|
+
if default_assignee and default_assignee not in all_ids:
|
|
280
|
+
errors.append(f"/routing/default_assignee: unknown agent {default_assignee!r}")
|
|
281
|
+
for idx, rule in enumerate(routing.get("rules", [])):
|
|
282
|
+
target = rule.get("assign_to")
|
|
283
|
+
if target not in all_ids:
|
|
284
|
+
errors.append(f"/routing/rules/{idx}/assign_to: unknown agent {target!r}")
|
|
285
|
+
|
|
286
|
+
tasks = spec.get("tasks", [])
|
|
287
|
+
task_ids = {t.get("id") for t in tasks if isinstance(t, dict)}
|
|
288
|
+
for idx, task in enumerate(tasks):
|
|
289
|
+
assignee = task.get("assignee")
|
|
290
|
+
if assignee and assignee not in all_ids:
|
|
291
|
+
errors.append(f"/tasks/{idx}/assignee: unknown agent {assignee!r}")
|
|
292
|
+
for dep in task.get("deps", []):
|
|
293
|
+
if dep not in task_ids:
|
|
294
|
+
errors.append(f"/tasks/{idx}/deps: unknown dependency {dep!r}")
|
|
295
|
+
|
|
296
|
+
cycle = find_dependency_cycle(tasks)
|
|
297
|
+
if cycle:
|
|
298
|
+
errors.append(f"/tasks: dependency cycle detected: {' -> '.join(cycle)}")
|
|
299
|
+
return errors
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def workspace_from_spec(spec: dict[str, Any], spec_path: Path | None = None) -> Path:
|
|
303
|
+
raw = spec.get("team", {}).get("workspace") or "."
|
|
304
|
+
path = Path(raw)
|
|
305
|
+
if path.is_absolute():
|
|
306
|
+
return path
|
|
307
|
+
base = spec_path.parent if spec_path else Path.cwd()
|
|
308
|
+
return (base / path).resolve()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from team_agent.paths import runtime_dir
|
|
10
|
+
from team_agent.simple_yaml import dumps
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SESSION_STATE_FIELDS = [
|
|
14
|
+
"session_id",
|
|
15
|
+
"rollout_path",
|
|
16
|
+
"captured_at",
|
|
17
|
+
"captured_via",
|
|
18
|
+
"attribution_confidence",
|
|
19
|
+
"spawn_cwd",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def runtime_state_path(workspace: Path) -> Path:
|
|
24
|
+
return runtime_dir(workspace) / "state.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_runtime_state(workspace: Path) -> dict[str, Any]:
|
|
28
|
+
path = runtime_state_path(workspace)
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return {"agents": {}, "tasks": [], "session_name": None}
|
|
31
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
32
|
+
for agent_state in state.get("agents", {}).values():
|
|
33
|
+
if isinstance(agent_state, dict):
|
|
34
|
+
for field in SESSION_STATE_FIELDS:
|
|
35
|
+
agent_state.setdefault(field, None)
|
|
36
|
+
return state
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_runtime_state(workspace: Path, state: dict[str, Any]) -> None:
|
|
40
|
+
path = runtime_state_path(workspace)
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
43
|
+
tmp_path.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
44
|
+
os.replace(tmp_path, path)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def write_team_state(workspace: Path, spec: dict[str, Any], runtime: dict[str, Any], results: list[dict[str, Any]] | None = None) -> Path:
|
|
48
|
+
path = workspace / spec.get("context", {}).get("state_file", "team_state.md")
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
lines = [
|
|
51
|
+
"# Team State",
|
|
52
|
+
"",
|
|
53
|
+
f"Updated: {datetime.now(timezone.utc).isoformat()}",
|
|
54
|
+
"",
|
|
55
|
+
"## Objective",
|
|
56
|
+
"",
|
|
57
|
+
spec.get("team", {}).get("objective", ""),
|
|
58
|
+
"",
|
|
59
|
+
"## Team",
|
|
60
|
+
"",
|
|
61
|
+
f"- Name: {spec.get('team', {}).get('name')}",
|
|
62
|
+
f"- Runtime session: {runtime.get('session_name')}",
|
|
63
|
+
]
|
|
64
|
+
receiver = runtime.get("leader_receiver") or {}
|
|
65
|
+
if receiver:
|
|
66
|
+
if receiver.get("mode") == "direct_tmux":
|
|
67
|
+
lines.append(
|
|
68
|
+
f"- Leader receiver: direct tmux {receiver.get('pane_id')} "
|
|
69
|
+
f"({receiver.get('provider')}, {receiver.get('status')})"
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
lines.append(f"- Leader inbox fallback: {receiver.get('session')}:{receiver.get('window')} ({receiver.get('status')})")
|
|
73
|
+
lines.append(f"- Leader inbox log: {receiver.get('path')}")
|
|
74
|
+
lines.extend(["", "## Agents", ""])
|
|
75
|
+
for agent in spec.get("agents", []):
|
|
76
|
+
status = runtime.get("agents", {}).get(agent["id"], {}).get("status", "unknown")
|
|
77
|
+
lines.append(f"- {agent['id']}: {agent['role']} on {agent['provider']} ({status})")
|
|
78
|
+
lines.extend(["", "## Task Graph", ""])
|
|
79
|
+
for task in runtime.get("tasks", spec.get("tasks", [])):
|
|
80
|
+
deps = ", ".join(task.get("deps", [])) or "none"
|
|
81
|
+
assignee = task.get("assignee") or "unassigned"
|
|
82
|
+
lines.append(f"- {task['id']} [{task.get('status', 'pending')}], assignee={assignee}, deps={deps}: {task['title']}")
|
|
83
|
+
if task.get("last_result_summary"):
|
|
84
|
+
lines.append(f" Summary: {task['last_result_summary']}")
|
|
85
|
+
if task.get("artifact_refs"):
|
|
86
|
+
for ref in task["artifact_refs"]:
|
|
87
|
+
if isinstance(ref, dict):
|
|
88
|
+
lines.append(f" Artifact: {ref.get('path')} - {ref.get('description', '')}")
|
|
89
|
+
else:
|
|
90
|
+
lines.append(f" Artifact: INVALID artifact ref {ref!r}")
|
|
91
|
+
lines.extend(["", "## Latest Results", ""])
|
|
92
|
+
for result in results or []:
|
|
93
|
+
envelope = json.loads(result["envelope"]) if isinstance(result.get("envelope"), str) else result
|
|
94
|
+
lines.append(f"- {envelope.get('task_id')} from {envelope.get('agent_id')}: {envelope.get('status')} - {envelope.get('summary')}")
|
|
95
|
+
lines.extend(["", "## Blockers", ""])
|
|
96
|
+
blockers = [
|
|
97
|
+
task
|
|
98
|
+
for task in runtime.get("tasks", spec.get("tasks", []))
|
|
99
|
+
if task.get("status") in {"blocked", "failed", "needs_retry"}
|
|
100
|
+
]
|
|
101
|
+
if blockers:
|
|
102
|
+
for task in blockers:
|
|
103
|
+
lines.append(f"- {task['id']}: {task.get('last_result_summary', task.get('title'))}")
|
|
104
|
+
else:
|
|
105
|
+
lines.append("- None")
|
|
106
|
+
lines.extend(["", "## Next Step", "", "- Continue routing ready tasks and collect result envelopes."])
|
|
107
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
108
|
+
return path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def write_spec(path: Path, spec: dict[str, Any]) -> None:
|
|
112
|
+
path.write_text(dumps(spec), encoding="utf-8")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
TASK_STATUSES = {
|
|
6
|
+
"pending",
|
|
7
|
+
"ready",
|
|
8
|
+
"running",
|
|
9
|
+
"blocked",
|
|
10
|
+
"needs_retry",
|
|
11
|
+
"done",
|
|
12
|
+
"failed",
|
|
13
|
+
"cancelled",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
TERMINAL_TASK_STATUSES = {"done", "failed", "cancelled"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_dependency_cycle(tasks: list[dict[str, Any]]) -> list[str]:
|
|
20
|
+
graph = {t.get("id"): list(t.get("deps", [])) for t in tasks if t.get("id")}
|
|
21
|
+
visiting: set[str] = set()
|
|
22
|
+
visited: set[str] = set()
|
|
23
|
+
stack: list[str] = []
|
|
24
|
+
|
|
25
|
+
def visit(node: str) -> list[str] | None:
|
|
26
|
+
if node in visited:
|
|
27
|
+
return None
|
|
28
|
+
if node in visiting:
|
|
29
|
+
if node in stack:
|
|
30
|
+
return stack[stack.index(node) :] + [node]
|
|
31
|
+
return [node, node]
|
|
32
|
+
visiting.add(node)
|
|
33
|
+
stack.append(node)
|
|
34
|
+
for dep in graph.get(node, []):
|
|
35
|
+
if dep in graph:
|
|
36
|
+
cycle = visit(dep)
|
|
37
|
+
if cycle:
|
|
38
|
+
return cycle
|
|
39
|
+
stack.pop()
|
|
40
|
+
visiting.remove(node)
|
|
41
|
+
visited.add(node)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
for node in graph:
|
|
45
|
+
cycle = visit(node)
|
|
46
|
+
if cycle:
|
|
47
|
+
return cycle
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ready_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
52
|
+
by_id = {t["id"]: t for t in tasks}
|
|
53
|
+
ready: list[dict[str, Any]] = []
|
|
54
|
+
for task in tasks:
|
|
55
|
+
if task.get("status", "pending") not in {"pending", "ready", "needs_retry"}:
|
|
56
|
+
continue
|
|
57
|
+
deps_done = all(by_id.get(dep, {}).get("status") == "done" for dep in task.get("deps", []))
|
|
58
|
+
if deps_done:
|
|
59
|
+
ready.append(task)
|
|
60
|
+
return ready
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def update_task_status(
|
|
64
|
+
tasks: list[dict[str, Any]],
|
|
65
|
+
task_id: str,
|
|
66
|
+
status: str,
|
|
67
|
+
summary: str | None = None,
|
|
68
|
+
artifact_refs: list[dict[str, Any]] | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
if status not in TASK_STATUSES:
|
|
71
|
+
raise ValueError(f"Unknown task status: {status}")
|
|
72
|
+
for task in tasks:
|
|
73
|
+
if task.get("id") == task_id:
|
|
74
|
+
task["status"] = status
|
|
75
|
+
if summary is not None:
|
|
76
|
+
task["last_result_summary"] = summary
|
|
77
|
+
if artifact_refs is not None:
|
|
78
|
+
task["artifact_refs"] = artifact_refs
|
|
79
|
+
return
|
|
80
|
+
raise KeyError(f"Unknown task id: {task_id}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Team State
|
|
2
|
+
|
|
3
|
+
Updated: not launched
|
|
4
|
+
|
|
5
|
+
## Objective
|
|
6
|
+
|
|
7
|
+
Pending.
|
|
8
|
+
|
|
9
|
+
## Team
|
|
10
|
+
|
|
11
|
+
- Name: pending
|
|
12
|
+
- Runtime session: pending
|
|
13
|
+
|
|
14
|
+
## Agents
|
|
15
|
+
|
|
16
|
+
- Pending launch.
|
|
17
|
+
|
|
18
|
+
## Task Graph
|
|
19
|
+
|
|
20
|
+
- Pending task graph.
|
|
21
|
+
|
|
22
|
+
## Latest Results
|
|
23
|
+
|
|
24
|
+
- None.
|
|
25
|
+
|
|
26
|
+
## Blockers
|
|
27
|
+
|
|
28
|
+
- None.
|
|
29
|
+
|
|
30
|
+
## Next Step
|
|
31
|
+
|
|
32
|
+
- Run `team-agent validate team.spec.yaml`, review permissions, then run `team-agent launch team.spec.yaml --yes`.
|