bareagent-cli 0.1.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.
Files changed (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from bareagent.core.loop import agent_loop
7
+ from bareagent.planning.tasks import Task, TaskManager
8
+ from bareagent.team.mailbox import Message, MessageBus
9
+ from bareagent.team.protocols import Protocol, ProtocolFSM, decode_protocol_content
10
+ from bareagent.tracing import tracer as global_tracer
11
+
12
+
13
+ def _noop_compact(_messages: list[dict[str, Any]]) -> None:
14
+ """Default compaction hook: do nothing (stateless / test-friendly)."""
15
+
16
+
17
+ class AutonomousAgent:
18
+ """Daemon-friendly idle-poll-claim-work loop for teammate agents."""
19
+
20
+ def __init__(
21
+ self,
22
+ name: str,
23
+ provider: Any,
24
+ tools: list[dict[str, Any]],
25
+ handlers: dict[str, Any],
26
+ bus: MessageBus,
27
+ task_manager: TaskManager | None,
28
+ *,
29
+ permission: Any = None,
30
+ system_prompt: str = "",
31
+ poll_interval: float = 5.0,
32
+ compact_fn: Any = None,
33
+ memory_enabled: bool = False,
34
+ ) -> None:
35
+ self.name = name
36
+ self.provider = provider
37
+ self.tools = tools
38
+ self.handlers = handlers
39
+ self.bus = bus
40
+ self.task_manager = task_manager
41
+ self.permission = permission
42
+ self.system_prompt = system_prompt.strip()
43
+ self.poll_interval = poll_interval
44
+ self._memory_enabled = memory_enabled
45
+ # Injected per-teammate Compactor (mirrors the main loop's compact_fn).
46
+ # A no-op default keeps stateless teammates and unit tests simple while
47
+ # decoupling AutonomousAgent from the Compactor implementation.
48
+ self._compact_fn = compact_fn if compact_fn is not None else _noop_compact
49
+ # Conversational memory accrues across *requests* only (Q1); the system
50
+ # prompt is seeded once here so it is not re-prepended every turn.
51
+ self._messages: list[dict[str, Any]] = []
52
+ if self._memory_enabled and self.system_prompt:
53
+ self._messages.append({"role": "system", "content": self.system_prompt})
54
+ self._shutdown = False
55
+ self.bus.ensure_mailbox(name)
56
+ self._last_seen_id: str | None = self.bus.latest_message_id(name)
57
+ self._protocol = ProtocolFSM(bus, agent_name=name)
58
+
59
+ def run(self) -> str:
60
+ while not self._shutdown:
61
+ incoming = self.bus.receive(self.name, since_id=self._last_seen_id)
62
+ if incoming:
63
+ self._last_seen_id = incoming[-1].id
64
+ self._handle_messages(incoming)
65
+ continue
66
+
67
+ if self.task_manager is not None:
68
+ ready_tasks = self.task_manager.get_ready_tasks()
69
+ for task in ready_tasks:
70
+ claimed_task = self._claim_task(task)
71
+ if claimed_task is None:
72
+ continue
73
+ self._execute_task(claimed_task)
74
+ break
75
+ else:
76
+ self.bus.wait_for_message(self.name, timeout=self.poll_interval)
77
+ continue
78
+
79
+ self.bus.wait_for_message(self.name, timeout=self.poll_interval)
80
+
81
+ return f"{self.name} stopped"
82
+
83
+ def _handle_messages(self, messages: list[Message]) -> None:
84
+ for message in messages:
85
+ protocol, content = decode_protocol_content(message.content)
86
+ if protocol == Protocol.SHUTDOWN:
87
+ self._shutdown = True
88
+ break
89
+
90
+ if message.msg_type != "request":
91
+ continue
92
+
93
+ # Isolate request handling: a single failing request must not kill
94
+ # the daemon thread (which would silently strand the teammate and
95
+ # leave a blocking ``team_send`` waiting out its full timeout). On
96
+ # error, reply with the reason so the requester learns immediately.
97
+ try:
98
+ prompt = self._build_incoming_prompt(content, protocol=protocol)
99
+ # Requests accrue conversational memory (Q1) when enabled; tasks
100
+ # always stay stateless (handled in _execute_task).
101
+ if self._memory_enabled:
102
+ response_text = self._run_request(prompt)
103
+ else:
104
+ response_text = self._run_prompt(prompt)
105
+ except Exception as exc:
106
+ logging.exception("Request handling failed in agent %s", self.name)
107
+ response_text = f"[error] {type(exc).__name__}: {exc}"
108
+ self._protocol.respond(message.id, response_text)
109
+
110
+ def _claim_task(self, task: Task) -> Task | None:
111
+ if self.task_manager is None:
112
+ return None
113
+
114
+ try:
115
+ return self.task_manager.update(
116
+ task.id,
117
+ status="in_progress",
118
+ expected_status="pending",
119
+ )
120
+ except ValueError:
121
+ return None
122
+
123
+ def _execute_task(self, task: Task) -> None:
124
+ if self.task_manager is None:
125
+ return
126
+
127
+ try:
128
+ self._run_prompt(self._build_task_prompt(task))
129
+ except Exception:
130
+ logging.exception("Task %s failed in agent %s", task.id, self.name)
131
+ self.task_manager.update(task.id, status="failed")
132
+ return
133
+
134
+ self.task_manager.update(task.id, status="done")
135
+
136
+ def _run_request(self, prompt: str) -> str:
137
+ """Stateful request turn: append onto the persistent conversation, run.
138
+
139
+ On failure roll the in-flight turn back so a transient error cannot
140
+ poison the memory list's user/assistant alternation (mirrors the
141
+ ``/goal`` ``_drive_goal`` rollback). The injected ``compact_fn`` keeps
142
+ the accumulated history bounded.
143
+ """
144
+ snapshot = len(self._messages)
145
+ self._messages.append({"role": "user", "content": prompt})
146
+ try:
147
+ with global_tracer.trace("teammate_run", tags={"agent": self.name}):
148
+ return agent_loop(
149
+ provider=self.provider,
150
+ messages=self._messages,
151
+ tools=self.tools,
152
+ handlers=self.handlers,
153
+ permission=self.permission,
154
+ compact_fn=self._compact_fn,
155
+ )
156
+ except BaseException:
157
+ del self._messages[snapshot:]
158
+ raise
159
+
160
+ def _run_prompt(self, prompt: str) -> str:
161
+ messages: list[dict[str, Any]] = []
162
+ if self.system_prompt:
163
+ messages.append({"role": "system", "content": self.system_prompt})
164
+ messages.append({"role": "user", "content": prompt})
165
+ with global_tracer.trace("teammate_run", tags={"agent": self.name}):
166
+ return agent_loop(
167
+ provider=self.provider,
168
+ messages=messages,
169
+ tools=self.tools,
170
+ handlers=self.handlers,
171
+ permission=self.permission,
172
+ compact_fn=lambda _messages: None,
173
+ )
174
+
175
+ def _build_incoming_prompt(
176
+ self,
177
+ content: str,
178
+ *,
179
+ protocol: Protocol | None,
180
+ ) -> str:
181
+ if protocol == Protocol.PLAN_APPROVAL:
182
+ return "请审阅下面的计划,判断是否应批准,并给出简洁理由。\n\n" + content
183
+ return content
184
+
185
+ def _build_task_prompt(self, task: Task) -> str:
186
+ lines = [
187
+ f"你是队友 {self.name},请完成下面的任务。",
188
+ f"任务标题: {task.title}",
189
+ ]
190
+ if task.description:
191
+ lines.append(f"任务描述: {task.description}")
192
+ lines.append("完成后给出简洁结果。")
193
+ return "\n".join(lines)
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import threading
6
+ from collections import OrderedDict
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from bareagent.core.fileutil import generate_random_id, optional_string, utc_timestamp_iso
12
+
13
+ _VALID_AGENT_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
14
+
15
+
16
+ def _validate_agent_name(name: str) -> str:
17
+ normalized = name.strip()
18
+ if not normalized:
19
+ raise ValueError("agent_name must not be empty")
20
+ if not _VALID_AGENT_NAME.fullmatch(normalized):
21
+ raise ValueError(
22
+ f"Invalid agent name (only alphanumeric, _, - allowed): {normalized!r}"
23
+ )
24
+ return normalized
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class Message:
29
+ id: str
30
+ from_agent: str
31
+ to_agent: str
32
+ content: str
33
+ msg_type: str
34
+ timestamp: str
35
+ in_reply_to: str | None = None
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return asdict(self)
39
+
40
+ @classmethod
41
+ def from_dict(cls, payload: dict[str, Any]) -> Message:
42
+ return cls(
43
+ id=str(payload.get("id", "")),
44
+ from_agent=str(payload.get("from_agent", "")),
45
+ to_agent=str(payload.get("to_agent", "")),
46
+ content=str(payload.get("content", "")),
47
+ msg_type=str(payload.get("msg_type", "")),
48
+ timestamp=str(payload.get("timestamp", "")),
49
+ in_reply_to=optional_string(payload.get("in_reply_to")),
50
+ )
51
+
52
+
53
+ class MessageBus:
54
+ """Append-only JSONL mailboxes, one file per agent."""
55
+
56
+ def __init__(self, mailbox_dir: str | Path = ".mailbox") -> None:
57
+ self.mailbox_dir = Path(mailbox_dir)
58
+ self.mailbox_dir.mkdir(parents=True, exist_ok=True)
59
+ self._locks: dict[str, threading.Lock] = {}
60
+ self._locks_guard = threading.Lock()
61
+ self._message_index: OrderedDict[str, tuple[str, Message]] = OrderedDict()
62
+ self._index_lock = threading.Lock()
63
+ self._max_index_size = 10000
64
+ self._conds: dict[str, threading.Condition] = {}
65
+ self._conds_guard = threading.Lock()
66
+ self._signal_counts: dict[str, int] = {}
67
+ # Ids of messages already handed to the consumer out of band -- e.g. a
68
+ # blocking ``team_send`` that synchronously waited for and returned the
69
+ # reply. The polling drain consults this so it does not deliver the same
70
+ # message a second time. Bound to the bus instance, so it resets for free
71
+ # when a new session swaps in a fresh bus.
72
+ self._delivered: set[str] = set()
73
+ self._delivered_lock = threading.Lock()
74
+
75
+ def send(self, msg: Message) -> str:
76
+ resolved = self._prepare_message(msg)
77
+ self._append(resolved.to_agent, resolved)
78
+ return resolved.id
79
+
80
+ def receive(self, agent_name: str, since_id: str | None = None) -> list[Message]:
81
+ normalized_name = _validate_agent_name(agent_name)
82
+
83
+ mailbox_path = self._mailbox_path(normalized_name)
84
+ self.ensure_mailbox(normalized_name)
85
+ with self._lock_for(normalized_name):
86
+ lines = mailbox_path.read_text(encoding="utf-8").splitlines()
87
+
88
+ messages: list[Message] = []
89
+ found_cursor = since_id is None
90
+ for index, line in enumerate(lines, start=1):
91
+ if not line.strip():
92
+ continue
93
+ try:
94
+ payload = json.loads(line)
95
+ except json.JSONDecodeError as exc:
96
+ raise ValueError(
97
+ f"Invalid mailbox entry in {mailbox_path} at line {index}: {exc}"
98
+ ) from exc
99
+ message = Message.from_dict(payload)
100
+ if not found_cursor:
101
+ if message.id == since_id:
102
+ found_cursor = True
103
+ continue
104
+ messages.append(message)
105
+ return messages
106
+
107
+ def ensure_mailbox(self, agent_name: str) -> Path:
108
+ normalized_name = _validate_agent_name(agent_name)
109
+ mailbox_path = self._mailbox_path(normalized_name)
110
+ mailbox_path.parent.mkdir(parents=True, exist_ok=True)
111
+ mailbox_path.touch(exist_ok=True)
112
+ return mailbox_path
113
+
114
+ def list_agents(self) -> list[str]:
115
+ self.mailbox_dir.mkdir(parents=True, exist_ok=True)
116
+ return sorted(path.stem for path in self.mailbox_dir.glob("*.jsonl"))
117
+
118
+ def latest_message_id(self, agent_name: str) -> str | None:
119
+ normalized_name = _validate_agent_name(agent_name)
120
+
121
+ mailbox_path = self.ensure_mailbox(normalized_name)
122
+ with self._lock_for(normalized_name):
123
+ lines = mailbox_path.read_text(encoding="utf-8").splitlines()
124
+
125
+ for index, line in enumerate(reversed(lines), start=1):
126
+ if not line.strip():
127
+ continue
128
+ try:
129
+ payload = json.loads(line)
130
+ except json.JSONDecodeError as exc:
131
+ raise ValueError(
132
+ f"Invalid mailbox entry in {mailbox_path} near tail line {index}: {exc}"
133
+ ) from exc
134
+ message = Message.from_dict(payload)
135
+ return message.id
136
+ return None
137
+
138
+ def find_message(self, message_id: str) -> Message | None:
139
+ normalized_id = message_id.strip()
140
+ if not normalized_id:
141
+ raise ValueError("message_id must not be empty")
142
+
143
+ with self._index_lock:
144
+ cached = self._message_index.get(normalized_id)
145
+ if cached is not None:
146
+ return cached[1]
147
+
148
+ for agent_name in self.list_agents():
149
+ for message in self.receive(agent_name):
150
+ with self._index_lock:
151
+ self._message_index[message.id] = (agent_name, message)
152
+ if len(self._message_index) > self._max_index_size:
153
+ self._message_index.popitem(last=False)
154
+ if message.id == normalized_id:
155
+ return message
156
+ return None
157
+
158
+ def _append(self, agent_name: str, msg: Message) -> None:
159
+ mailbox_path = self.ensure_mailbox(agent_name)
160
+ line = json.dumps(msg.to_dict(), ensure_ascii=False)
161
+ with self._lock_for(agent_name):
162
+ with mailbox_path.open("a", encoding="utf-8") as file:
163
+ file.write(line)
164
+ file.write("\n")
165
+ with self._index_lock:
166
+ self._message_index[msg.id] = (agent_name, msg)
167
+ if len(self._message_index) > self._max_index_size:
168
+ self._message_index.popitem(last=False)
169
+ cond = self._cond_for(agent_name)
170
+ with cond:
171
+ self._signal_counts[agent_name] = self._signal_counts.get(agent_name, 0) + 1
172
+ cond.notify_all()
173
+
174
+ def _cond_for(self, agent_name: str) -> threading.Condition:
175
+ with self._conds_guard:
176
+ cond = self._conds.get(agent_name)
177
+ if cond is None:
178
+ cond = threading.Condition()
179
+ self._conds[agent_name] = cond
180
+ return cond
181
+
182
+ def wait_for_message(self, agent_name: str, timeout: float) -> None:
183
+ """Block until a message is appended to *agent_name*'s mailbox or *timeout* elapses."""
184
+ cond = self._cond_for(agent_name)
185
+ with cond:
186
+ initial = self._signal_counts.get(agent_name, 0)
187
+ cond.wait_for(
188
+ lambda: self._signal_counts.get(agent_name, 0) != initial,
189
+ timeout=timeout,
190
+ )
191
+
192
+ def mark_delivered(self, message_id: str) -> None:
193
+ """Record that ``message_id`` was already delivered out of band.
194
+
195
+ Used by a blocking consumer (e.g. ``team_send`` waiting for a reply) so
196
+ the polling drain does not surface the same message a second time.
197
+ """
198
+ normalized_id = message_id.strip()
199
+ if not normalized_id:
200
+ return
201
+ with self._delivered_lock:
202
+ self._delivered.add(normalized_id)
203
+
204
+ def was_delivered(self, message_id: str) -> bool:
205
+ with self._delivered_lock:
206
+ return message_id.strip() in self._delivered
207
+
208
+ def _mailbox_path(self, agent_name: str) -> Path:
209
+ return self.mailbox_dir / f"{agent_name}.jsonl"
210
+
211
+ def _lock_for(self, agent_name: str) -> threading.Lock:
212
+ with self._locks_guard:
213
+ lock = self._locks.get(agent_name)
214
+ if lock is None:
215
+ lock = threading.Lock()
216
+ self._locks[agent_name] = lock
217
+ return lock
218
+
219
+ def _prepare_message(self, msg: Message) -> Message:
220
+ from_agent = _validate_agent_name(msg.from_agent)
221
+ to_agent = _validate_agent_name(msg.to_agent)
222
+ if not msg.msg_type.strip():
223
+ raise ValueError("msg_type must not be empty")
224
+
225
+ message_id = msg.id.strip() or _generate_message_id()
226
+ timestamp = msg.timestamp.strip() or utc_timestamp_iso()
227
+ return Message(
228
+ id=message_id,
229
+ from_agent=from_agent,
230
+ to_agent=to_agent,
231
+ content=msg.content,
232
+ msg_type=msg.msg_type.strip(),
233
+ timestamp=timestamp,
234
+ in_reply_to=optional_string(msg.in_reply_to),
235
+ )
236
+
237
+
238
+ def _generate_message_id(length: int = 12) -> str:
239
+ return generate_random_id(length)
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ from collections.abc import Callable
6
+ from dataclasses import asdict, dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from bareagent.core.fileutil import atomic_write_json
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class Teammate:
15
+ name: str
16
+ role: str
17
+ system_prompt: str
18
+ provider_config: dict[str, Any] = field(default_factory=dict)
19
+
20
+ def to_dict(self) -> dict[str, Any]:
21
+ return asdict(self)
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class AgentInstance:
26
+ name: str
27
+ role: str
28
+ system_prompt: str
29
+ provider: Any
30
+ provider_config: dict[str, Any] = field(default_factory=dict)
31
+
32
+
33
+ class TeammateManager:
34
+ """Persist teammate definitions and spawn independent agent instances."""
35
+
36
+ def __init__(self, config_file: str | Path = ".team.json") -> None:
37
+ self.config_file = Path(config_file)
38
+ self.teammates: dict[str, Teammate] = {}
39
+ self._lock = threading.RLock()
40
+ self._load()
41
+
42
+ @classmethod
43
+ def create_empty(cls, config_file: str | Path) -> TeammateManager:
44
+ instance = cls.__new__(cls)
45
+ instance.config_file = Path(config_file)
46
+ instance.teammates = {}
47
+ instance._lock = threading.RLock()
48
+ return instance
49
+
50
+ def register(
51
+ self,
52
+ name: str,
53
+ role: str,
54
+ system_prompt: str,
55
+ provider_config: dict[str, Any] | None = None,
56
+ ) -> Teammate:
57
+ with self._lock:
58
+ normalized_name = name.strip()
59
+ normalized_role = role.strip()
60
+ normalized_prompt = system_prompt.strip()
61
+ if not normalized_name:
62
+ raise ValueError("name must not be empty")
63
+ if not normalized_role:
64
+ raise ValueError("role must not be empty")
65
+ if not normalized_prompt:
66
+ raise ValueError("system_prompt must not be empty")
67
+
68
+ teammate = Teammate(
69
+ name=normalized_name,
70
+ role=normalized_role,
71
+ system_prompt=normalized_prompt,
72
+ provider_config=dict(provider_config or {}),
73
+ )
74
+ self.teammates[normalized_name] = teammate
75
+ self._save()
76
+ return teammate
77
+
78
+ def get(self, name: str) -> Teammate:
79
+ with self._lock:
80
+ teammate = self.teammates.get(name.strip())
81
+ if teammate is None:
82
+ raise ValueError(f"Unknown teammate: {name}")
83
+ return teammate
84
+
85
+ def list(self) -> list[Teammate]:
86
+ with self._lock:
87
+ return sorted(self.teammates.values(), key=lambda teammate: teammate.name)
88
+
89
+ def spawn(
90
+ self,
91
+ name: str,
92
+ provider_factory: Callable[[dict[str, Any]], Any],
93
+ ) -> AgentInstance:
94
+ with self._lock:
95
+ teammate = self.get(name)
96
+ provider_config = dict(teammate.provider_config)
97
+ snapshot_name = teammate.name
98
+ snapshot_role = teammate.role
99
+ snapshot_prompt = teammate.system_prompt
100
+ provider = provider_factory(provider_config)
101
+ return AgentInstance(
102
+ name=snapshot_name,
103
+ role=snapshot_role,
104
+ system_prompt=snapshot_prompt,
105
+ provider=provider,
106
+ provider_config=provider_config,
107
+ )
108
+
109
+ def _save(self) -> None:
110
+ payload = {
111
+ "teammates": {teammate.name: teammate.to_dict() for teammate in self.list()}
112
+ }
113
+ atomic_write_json(self.config_file, payload)
114
+
115
+ def _load(self) -> None:
116
+ try:
117
+ with self.config_file.open("r", encoding="utf-8") as file:
118
+ payload = json.load(file)
119
+ except FileNotFoundError:
120
+ self.teammates = {}
121
+ return
122
+
123
+ if not isinstance(payload, dict):
124
+ raise ValueError("Team config must contain a JSON object")
125
+
126
+ raw_teammates = payload.get("teammates", {})
127
+ if not isinstance(raw_teammates, dict):
128
+ raise ValueError("Team config 'teammates' field must be an object")
129
+
130
+ loaded: dict[str, Teammate] = {}
131
+ for teammate_name, raw_teammate in raw_teammates.items():
132
+ if not isinstance(raw_teammate, dict):
133
+ raise ValueError(f"Invalid teammate payload for {teammate_name}")
134
+ provider_config = raw_teammate.get("provider_config") or {}
135
+ if not isinstance(provider_config, dict):
136
+ raise ValueError(
137
+ f"Teammate provider_config must be an object: {teammate_name}"
138
+ )
139
+ teammate = Teammate(
140
+ name=str(raw_teammate.get("name", teammate_name)).strip(),
141
+ role=str(raw_teammate.get("role", "")).strip(),
142
+ system_prompt=str(raw_teammate.get("system_prompt", "")).strip(),
143
+ provider_config=dict(provider_config),
144
+ )
145
+ if not teammate.name:
146
+ raise ValueError(f"Teammate name must not be empty: {teammate_name}")
147
+ if not teammate.role:
148
+ raise ValueError(f"Teammate role must not be empty: {teammate_name}")
149
+ if not teammate.system_prompt:
150
+ raise ValueError(
151
+ f"Teammate system_prompt must not be empty: {teammate_name}"
152
+ )
153
+ loaded[teammate.name] = teammate
154
+
155
+ self.teammates = loaded