@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,497 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import uuid
6
+ from contextlib import closing
7
+ from datetime import datetime, timedelta, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from team_agent.paths import runtime_dir
12
+ from team_agent.spec import validate_result_envelope
13
+
14
+
15
+ def utcnow() -> str:
16
+ return datetime.now(timezone.utc).isoformat()
17
+
18
+
19
+ class MessageStore:
20
+ def __init__(self, workspace: Path):
21
+ self.workspace = workspace
22
+ self.path = runtime_dir(workspace) / "team.db"
23
+ self.path.parent.mkdir(parents=True, exist_ok=True)
24
+ self._init()
25
+
26
+ def connect(self) -> sqlite3.Connection:
27
+ conn = sqlite3.connect(self.path)
28
+ conn.row_factory = sqlite3.Row
29
+ return conn
30
+
31
+ def _init(self) -> None:
32
+ with closing(self.connect()) as conn:
33
+ with conn:
34
+ conn.execute(
35
+ """
36
+ create table if not exists messages (
37
+ message_id text primary key,
38
+ task_id text,
39
+ sender text,
40
+ recipient text,
41
+ reply_to text,
42
+ requires_ack integer,
43
+ status text,
44
+ content text,
45
+ artifact_refs text,
46
+ created_at text,
47
+ updated_at text,
48
+ delivered_at text,
49
+ acknowledged_at text,
50
+ error text
51
+ )
52
+ """
53
+ )
54
+ conn.execute(
55
+ """
56
+ create table if not exists results (
57
+ result_id text primary key,
58
+ task_id text not null,
59
+ agent_id text not null,
60
+ envelope text not null,
61
+ status text not null,
62
+ created_at text not null
63
+ )
64
+ """
65
+ )
66
+ conn.execute(
67
+ """
68
+ create table if not exists scheduled_events (
69
+ id integer primary key,
70
+ due_at text not null,
71
+ target text not null,
72
+ kind text not null,
73
+ payload_json text not null,
74
+ status text not null,
75
+ created_at text not null,
76
+ fired_at text,
77
+ result_json text
78
+ )
79
+ """
80
+ )
81
+ conn.execute(
82
+ """
83
+ create table if not exists delivery_tokens (
84
+ message_id text primary key,
85
+ unique_token text not null,
86
+ injected_at text not null,
87
+ visible_at text,
88
+ consumed_at text,
89
+ failed_at text,
90
+ failure_reason text
91
+ )
92
+ """
93
+ )
94
+ conn.execute(
95
+ """
96
+ create table if not exists agent_health (
97
+ agent_id text primary key,
98
+ status text not null,
99
+ last_output_at text,
100
+ context_usage_pct integer,
101
+ current_task_id text,
102
+ updated_at text not null
103
+ )
104
+ """
105
+ )
106
+ conn.execute(
107
+ """
108
+ create table if not exists peer_allowlist (
109
+ a text not null,
110
+ b text not null,
111
+ created_at text not null,
112
+ primary key (a, b)
113
+ )
114
+ """
115
+ )
116
+ conn.execute(
117
+ """
118
+ create table if not exists result_watchers (
119
+ watcher_id text primary key,
120
+ task_id text,
121
+ agent_id text,
122
+ message_id text,
123
+ leader_id text not null,
124
+ status text not null,
125
+ created_at text not null,
126
+ completed_at text,
127
+ result_id text,
128
+ notified_message_id text,
129
+ error text
130
+ )
131
+ """
132
+ )
133
+
134
+ def create_message(
135
+ self,
136
+ task_id: str | None,
137
+ sender: str,
138
+ recipient: str,
139
+ content: str,
140
+ reply_to: str | None = None,
141
+ requires_ack: bool = True,
142
+ artifact_refs: list[dict[str, Any]] | None = None,
143
+ ) -> str:
144
+ message_id = f"msg_{uuid.uuid4().hex[:12]}"
145
+ now = utcnow()
146
+ with closing(self.connect()) as conn:
147
+ with conn:
148
+ conn.execute(
149
+ """
150
+ insert into messages values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
151
+ """,
152
+ (
153
+ message_id,
154
+ task_id,
155
+ sender,
156
+ recipient,
157
+ reply_to,
158
+ int(requires_ack),
159
+ "accepted",
160
+ content,
161
+ json.dumps(artifact_refs or []),
162
+ now,
163
+ now,
164
+ None,
165
+ None,
166
+ None,
167
+ ),
168
+ )
169
+ return message_id
170
+
171
+ def mark(self, message_id: str, status: str, error: str | None = None) -> None:
172
+ now = utcnow()
173
+ delivered_at = now if status in {"injected", "visible", "submitted", "submitted_unverified", "delivered"} else None
174
+ acknowledged_at = now if status == "acknowledged" else None
175
+ with closing(self.connect()) as conn:
176
+ with conn:
177
+ conn.execute(
178
+ """
179
+ update messages
180
+ set status = ?,
181
+ updated_at = ?,
182
+ delivered_at = coalesce(?, delivered_at),
183
+ acknowledged_at = coalesce(?, acknowledged_at),
184
+ error = coalesce(?, error)
185
+ where message_id = ?
186
+ """,
187
+ (status, now, delivered_at, acknowledged_at, error, message_id),
188
+ )
189
+ if status in {"injected", "visible", "submitted", "submitted_unverified", "injected_unverified", "failed"}:
190
+ conn.execute(
191
+ """
192
+ insert into delivery_tokens(
193
+ message_id, unique_token, injected_at, visible_at, failed_at, failure_reason
194
+ )
195
+ values (?, ?, ?, ?, ?, ?)
196
+ on conflict(message_id) do update set
197
+ visible_at = coalesce(excluded.visible_at, delivery_tokens.visible_at),
198
+ failed_at = coalesce(excluded.failed_at, delivery_tokens.failed_at),
199
+ failure_reason = coalesce(excluded.failure_reason, delivery_tokens.failure_reason)
200
+ """,
201
+ (
202
+ message_id,
203
+ message_id,
204
+ now,
205
+ now if status in {"visible", "submitted"} else None,
206
+ now if status in {"failed", "injected_unverified"} else None,
207
+ error if status in {"failed", "injected_unverified"} else None,
208
+ ),
209
+ )
210
+ if status == "acknowledged":
211
+ conn.execute(
212
+ "update delivery_tokens set consumed_at = coalesce(consumed_at, ?) where message_id = ?",
213
+ (now, message_id),
214
+ )
215
+
216
+ def claim_for_delivery(self, message_id: str) -> bool:
217
+ now = utcnow()
218
+ with closing(self.connect()) as conn:
219
+ with conn:
220
+ result = conn.execute(
221
+ """
222
+ update messages
223
+ set status = 'target_resolved',
224
+ updated_at = ?
225
+ where message_id = ?
226
+ and status in ('pending', 'accepted')
227
+ """,
228
+ (now, message_id),
229
+ )
230
+ return result.rowcount == 1
231
+
232
+ def fail_timeouts(self, timeout_sec: int) -> list[str]:
233
+ cutoff = datetime.now(timezone.utc) - timedelta(seconds=timeout_sec)
234
+ failed: list[str] = []
235
+ with closing(self.connect()) as conn:
236
+ with conn:
237
+ rows = conn.execute(
238
+ "select message_id, updated_at from messages where requires_ack = 1 and status in ('pending','accepted','target_resolved','injected','visible','submitted','delivered')"
239
+ ).fetchall()
240
+ for row in rows:
241
+ try:
242
+ updated = datetime.fromisoformat(row["updated_at"])
243
+ except ValueError:
244
+ continue
245
+ if updated < cutoff:
246
+ failed.append(row["message_id"])
247
+ conn.execute(
248
+ "update messages set status = ?, updated_at = ?, error = ? where message_id = ?",
249
+ ("failed", utcnow(), f"ack timeout after {timeout_sec}s", row["message_id"]),
250
+ )
251
+ return failed
252
+
253
+ def messages(self) -> list[dict[str, Any]]:
254
+ with closing(self.connect()) as conn:
255
+ rows = conn.execute("select * from messages order by created_at").fetchall()
256
+ return [dict(row) for row in rows]
257
+
258
+ def inbox(self, agent_id: str, limit: int = 20) -> list[dict[str, Any]]:
259
+ with closing(self.connect()) as conn:
260
+ rows = conn.execute(
261
+ """
262
+ select * from messages
263
+ where sender = ? or recipient = ?
264
+ order by created_at desc
265
+ limit ?
266
+ """,
267
+ (agent_id, agent_id, limit),
268
+ ).fetchall()
269
+ return [dict(row) for row in reversed(rows)]
270
+
271
+ def delivery_tokens(self) -> list[dict[str, Any]]:
272
+ with closing(self.connect()) as conn:
273
+ rows = conn.execute("select * from delivery_tokens order by injected_at").fetchall()
274
+ return [dict(row) for row in rows]
275
+
276
+ def upsert_agent_health(
277
+ self,
278
+ agent_id: str,
279
+ status: str,
280
+ last_output_at: str | None = None,
281
+ context_usage_pct: int | None = None,
282
+ current_task_id: str | None = None,
283
+ ) -> None:
284
+ now = utcnow()
285
+ with closing(self.connect()) as conn:
286
+ with conn:
287
+ conn.execute(
288
+ """
289
+ insert into agent_health(agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at)
290
+ values (?, ?, ?, ?, ?, ?)
291
+ on conflict(agent_id) do update set
292
+ status = excluded.status,
293
+ last_output_at = coalesce(excluded.last_output_at, agent_health.last_output_at),
294
+ context_usage_pct = excluded.context_usage_pct,
295
+ current_task_id = excluded.current_task_id,
296
+ updated_at = excluded.updated_at
297
+ """,
298
+ (agent_id, status, last_output_at, context_usage_pct, current_task_id, now),
299
+ )
300
+
301
+ def agent_health(self) -> dict[str, dict[str, Any]]:
302
+ with closing(self.connect()) as conn:
303
+ rows = conn.execute("select * from agent_health order by agent_id").fetchall()
304
+ return {row["agent_id"]: dict(row) for row in rows}
305
+
306
+ def add_scheduled_event(self, due_at: str, target: str, kind: str, payload: dict[str, Any]) -> int:
307
+ with closing(self.connect()) as conn:
308
+ with conn:
309
+ cur = conn.execute(
310
+ """
311
+ insert into scheduled_events(due_at, target, kind, payload_json, status, created_at)
312
+ values (?, ?, ?, ?, 'pending', ?)
313
+ """,
314
+ (due_at, target, kind, json.dumps(payload, ensure_ascii=False), utcnow()),
315
+ )
316
+ return int(cur.lastrowid)
317
+
318
+ def due_scheduled_events(self, now: str | None = None) -> list[dict[str, Any]]:
319
+ with closing(self.connect()) as conn:
320
+ rows = conn.execute(
321
+ """
322
+ select * from scheduled_events
323
+ where status = 'pending' and due_at <= ?
324
+ order by due_at, id
325
+ """,
326
+ (now or utcnow(),),
327
+ ).fetchall()
328
+ return [dict(row) for row in rows]
329
+
330
+ def mark_scheduled_event(self, event_id: int, status: str, result: dict[str, Any]) -> None:
331
+ with closing(self.connect()) as conn:
332
+ with conn:
333
+ conn.execute(
334
+ """
335
+ update scheduled_events
336
+ set status = ?, fired_at = ?, result_json = ?
337
+ where id = ?
338
+ """,
339
+ (status, utcnow(), json.dumps(result, ensure_ascii=False), event_id),
340
+ )
341
+
342
+ def allow_peer(self, a: str, b: str) -> None:
343
+ now = utcnow()
344
+ pairs = [(a, b), (b, a)]
345
+ with closing(self.connect()) as conn:
346
+ with conn:
347
+ conn.executemany(
348
+ "insert or ignore into peer_allowlist(a, b, created_at) values (?, ?, ?)",
349
+ [(left, right, now) for left, right in pairs],
350
+ )
351
+
352
+ def peer_allowed(self, a: str, b: str) -> bool:
353
+ with closing(self.connect()) as conn:
354
+ row = conn.execute("select 1 from peer_allowlist where a = ? and b = ?", (a, b)).fetchone()
355
+ return row is not None
356
+
357
+ def message_counts(self) -> dict[str, int]:
358
+ counts: dict[str, int] = {}
359
+ with closing(self.connect()) as conn:
360
+ rows = conn.execute("select status, count(*) as n from messages group by status").fetchall()
361
+ for row in rows:
362
+ counts[row["status"]] = row["n"]
363
+ return counts
364
+
365
+ def result_counts(self) -> dict[str, Any]:
366
+ counts: dict[str, Any] = {"total": 0, "uncollected": 0, "collected": 0, "invalid": 0, "by_status": {}}
367
+ with closing(self.connect()) as conn:
368
+ rows = conn.execute("select status, count(*) as n from results group by status").fetchall()
369
+ for row in rows:
370
+ status = row["status"]
371
+ n = row["n"]
372
+ counts["total"] += n
373
+ if status == "collected":
374
+ counts["collected"] += n
375
+ elif status == "invalid":
376
+ counts["invalid"] += n
377
+ else:
378
+ counts["uncollected"] += n
379
+ counts["by_status"][status] = n
380
+ return counts
381
+
382
+ def create_result_watcher(
383
+ self,
384
+ task_id: str | None,
385
+ agent_id: str | None,
386
+ message_id: str | None,
387
+ leader_id: str = "leader",
388
+ ) -> str:
389
+ watcher_id = f"watch_{uuid.uuid4().hex[:12]}"
390
+ with closing(self.connect()) as conn:
391
+ with conn:
392
+ conn.execute(
393
+ """
394
+ insert into result_watchers(
395
+ watcher_id, task_id, agent_id, message_id, leader_id, status, created_at
396
+ )
397
+ values (?, ?, ?, ?, ?, 'pending', ?)
398
+ """,
399
+ (watcher_id, task_id, agent_id, message_id, leader_id, utcnow()),
400
+ )
401
+ return watcher_id
402
+
403
+ def pending_result_watchers(self) -> list[dict[str, Any]]:
404
+ with closing(self.connect()) as conn:
405
+ rows = conn.execute(
406
+ "select * from result_watchers where status = 'pending' order by created_at"
407
+ ).fetchall()
408
+ return [dict(row) for row in rows]
409
+
410
+ def result_watchers(self) -> list[dict[str, Any]]:
411
+ with closing(self.connect()) as conn:
412
+ rows = conn.execute("select * from result_watchers order by created_at").fetchall()
413
+ return [dict(row) for row in rows]
414
+
415
+ def mark_result_watcher(
416
+ self,
417
+ watcher_id: str,
418
+ status: str,
419
+ result_id: str | None = None,
420
+ notified_message_id: str | None = None,
421
+ error: str | None = None,
422
+ ) -> None:
423
+ with closing(self.connect()) as conn:
424
+ with conn:
425
+ conn.execute(
426
+ """
427
+ update result_watchers
428
+ set status = ?,
429
+ completed_at = ?,
430
+ result_id = coalesce(?, result_id),
431
+ notified_message_id = coalesce(?, notified_message_id),
432
+ error = coalesce(?, error)
433
+ where watcher_id = ?
434
+ """,
435
+ (status, utcnow(), result_id, notified_message_id, error, watcher_id),
436
+ )
437
+
438
+ def add_result(self, envelope: dict[str, Any]) -> str:
439
+ validate_result_envelope(envelope)
440
+ result_id = f"res_{uuid.uuid4().hex[:12]}"
441
+ with closing(self.connect()) as conn:
442
+ with conn:
443
+ conn.execute(
444
+ "insert into results values (?, ?, ?, ?, ?, ?)",
445
+ (
446
+ result_id,
447
+ envelope["task_id"],
448
+ envelope["agent_id"],
449
+ json.dumps(envelope, ensure_ascii=False),
450
+ envelope["status"],
451
+ utcnow(),
452
+ ),
453
+ )
454
+ return result_id
455
+
456
+ def acknowledge_task_messages(self, task_id: str, agent_id: str) -> list[str]:
457
+ now = utcnow()
458
+ with closing(self.connect()) as conn:
459
+ with conn:
460
+ rows = conn.execute(
461
+ """
462
+ select message_id from messages
463
+ where task_id = ? and recipient = ? and status in ('pending', 'accepted', 'target_resolved', 'injected', 'visible', 'submitted', 'delivered')
464
+ """,
465
+ (task_id, agent_id),
466
+ ).fetchall()
467
+ ids = [row["message_id"] for row in rows]
468
+ conn.execute(
469
+ """
470
+ update messages
471
+ set status = 'acknowledged', acknowledged_at = ?, updated_at = ?
472
+ where task_id = ? and recipient = ? and status in ('pending', 'accepted', 'target_resolved', 'injected', 'visible', 'submitted', 'delivered')
473
+ """,
474
+ (now, now, task_id, agent_id),
475
+ )
476
+ return ids
477
+
478
+ def results(self, uncollected_only: bool = False) -> list[dict[str, Any]]:
479
+ query = "select * from results order by created_at"
480
+ if uncollected_only:
481
+ query = "select * from results where status not in ('collected', 'invalid') order by created_at"
482
+ with closing(self.connect()) as conn:
483
+ rows = conn.execute(query).fetchall()
484
+ return [dict(row) for row in rows]
485
+
486
+ def mark_result_collected(self, result_id: str) -> None:
487
+ with closing(self.connect()) as conn:
488
+ with conn:
489
+ conn.execute("update results set status = 'collected' where result_id = ?", (result_id,))
490
+
491
+ def mark_result_invalid(self, result_id: str, error: str) -> None:
492
+ with closing(self.connect()) as conn:
493
+ with conn:
494
+ conn.execute(
495
+ "update results set status = 'invalid' where result_id = ?",
496
+ (result_id,),
497
+ )
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ PACKAGE_ROOT = Path(__file__).resolve().parents[2]
7
+
8
+
9
+ def repo_root() -> Path:
10
+ return PACKAGE_ROOT
11
+
12
+
13
+ def schema_path(name: str) -> Path:
14
+ return repo_root() / "schemas" / name
15
+
16
+
17
+ def template_path(name: str) -> Path:
18
+ return repo_root() / "templates" / name
19
+
20
+
21
+ def example_path(name: str) -> Path:
22
+ return repo_root() / "examples" / name
23
+
24
+
25
+ def runtime_dir(workspace: Path) -> Path:
26
+ return workspace / ".team" / "runtime"
27
+
28
+
29
+ def logs_dir(workspace: Path) -> Path:
30
+ return workspace / ".team" / "logs"
31
+
32
+
33
+ def artifacts_dir(workspace: Path) -> Path:
34
+ return workspace / ".team" / "artifacts"
35
+
36
+
37
+ def messages_dir(workspace: Path) -> Path:
38
+ return workspace / ".team" / "messages"
39
+
40
+
41
+ def team_workspace(team_dir: Path) -> Path:
42
+ team_dir = team_dir.resolve()
43
+ if team_dir.parent.name == ".team":
44
+ return team_dir.parent.parent
45
+ return team_dir.parent
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ CANONICAL_TOOLS = {
6
+ "fs_read",
7
+ "fs_write",
8
+ "fs_list",
9
+ "execute_bash",
10
+ "git_diff",
11
+ "network",
12
+ "mcp_team",
13
+ "provider_builtin",
14
+ }
15
+
16
+ ROLE_DEFAULTS = {
17
+ "leader": ["fs_read", "fs_list", "mcp_team", "provider_builtin"],
18
+ "supervisor": ["fs_read", "fs_list", "mcp_team", "provider_builtin"],
19
+ "implementation_engineer": [
20
+ "fs_read",
21
+ "fs_write",
22
+ "fs_list",
23
+ "execute_bash",
24
+ "git_diff",
25
+ "mcp_team",
26
+ "provider_builtin",
27
+ ],
28
+ "developer": [
29
+ "fs_read",
30
+ "fs_write",
31
+ "fs_list",
32
+ "execute_bash",
33
+ "git_diff",
34
+ "mcp_team",
35
+ "provider_builtin",
36
+ ],
37
+ "researcher": ["fs_read", "fs_list", "network", "mcp_team", "provider_builtin"],
38
+ "reviewer": ["fs_read", "fs_list", "git_diff", "mcp_team", "provider_builtin"],
39
+ "code_reviewer": ["fs_read", "fs_list", "git_diff", "mcp_team", "provider_builtin"],
40
+ }
41
+
42
+ PROVIDER_ENFORCEMENT = {
43
+ "claude_code": {
44
+ "fs_read": "hard",
45
+ "fs_write": "hard",
46
+ "fs_list": "hard",
47
+ "execute_bash": "hard",
48
+ "git_diff": "hard",
49
+ "network": "prompt_only",
50
+ "mcp_team": "hard",
51
+ "provider_builtin": "hard",
52
+ },
53
+ "codex": {tool: "prompt_only" for tool in CANONICAL_TOOLS},
54
+ "gemini_cli": {
55
+ "fs_read": "hard",
56
+ "fs_write": "hard",
57
+ "fs_list": "hard",
58
+ "execute_bash": "hard",
59
+ "git_diff": "hard",
60
+ "network": "prompt_only",
61
+ "mcp_team": "prompt_only",
62
+ "provider_builtin": "hard",
63
+ },
64
+ "fake": {tool: "hard" for tool in CANONICAL_TOOLS},
65
+ }
66
+
67
+
68
+ def expand_tools(tools: list[str]) -> list[str]:
69
+ expanded: list[str] = []
70
+ for tool in tools:
71
+ if tool == "fs_*":
72
+ expanded.extend(["fs_read", "fs_write", "fs_list"])
73
+ elif tool == "@builtin":
74
+ expanded.append("provider_builtin")
75
+ elif tool in {"@team-orchestrator", "@cao-mcp-server"}:
76
+ expanded.append("mcp_team")
77
+ elif tool == "*":
78
+ expanded.extend(sorted(CANONICAL_TOOLS))
79
+ else:
80
+ expanded.append(tool)
81
+ return sorted(set(expanded))
82
+
83
+
84
+ def default_tools_for_role(role: str) -> list[str]:
85
+ return list(ROLE_DEFAULTS.get(role, ROLE_DEFAULTS["developer"]))
86
+
87
+
88
+ def resolve_permissions(agent: dict[str, Any]) -> dict[str, Any]:
89
+ provider = agent["provider"]
90
+ tools = agent.get("tools") or default_tools_for_role(agent.get("role", "developer"))
91
+ resolved = expand_tools(tools)
92
+ enforcement_map = PROVIDER_ENFORCEMENT.get(provider, {})
93
+ entries = [
94
+ {
95
+ "tool": tool,
96
+ "enforcement": enforcement_map.get(tool, "prompt_only"),
97
+ }
98
+ for tool in resolved
99
+ ]
100
+ return {
101
+ "agent_id": agent.get("id"),
102
+ "provider": provider,
103
+ "tools": resolved,
104
+ "resolved_tools": entries,
105
+ "has_prompt_only": any(e["enforcement"] == "prompt_only" for e in entries),
106
+ }
107
+
108
+
109
+ def task_required_tools(task: dict[str, Any]) -> list[str]:
110
+ required = list(task.get("requires_tools", []))
111
+ task_type = task.get("type")
112
+ if task_type in {"implementation", "bug_fix", "test"}:
113
+ required.extend(["fs_write", "execute_bash"])
114
+ if task_type in {"review", "risk_check"}:
115
+ required.extend(["fs_read", "git_diff"])
116
+ if task_type in {"research", "architecture"}:
117
+ required.extend(["fs_read"])
118
+ return expand_tools(required)
119
+
120
+
121
+ def missing_tools(agent: dict[str, Any], task: dict[str, Any]) -> list[str]:
122
+ allowed = set(resolve_permissions(agent)["tools"])
123
+ return [tool for tool in task_required_tools(task) if tool not in allowed]