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.
- bareagent/__init__.py +10 -0
- bareagent/concurrency/__init__.py +6 -0
- bareagent/concurrency/background.py +97 -0
- bareagent/concurrency/notification.py +61 -0
- bareagent/concurrency/scheduler.py +136 -0
- bareagent/config.toml +299 -0
- bareagent/core/__init__.py +1 -0
- bareagent/core/config_paths.py +49 -0
- bareagent/core/context.py +127 -0
- bareagent/core/fileutil.py +103 -0
- bareagent/core/goal.py +214 -0
- bareagent/core/handlers/__init__.py +1 -0
- bareagent/core/handlers/bash.py +79 -0
- bareagent/core/handlers/file_edit.py +47 -0
- bareagent/core/handlers/file_read.py +270 -0
- bareagent/core/handlers/file_write.py +34 -0
- bareagent/core/handlers/glob_search.py +30 -0
- bareagent/core/handlers/goal.py +60 -0
- bareagent/core/handlers/grep_search.py +52 -0
- bareagent/core/handlers/memory.py +71 -0
- bareagent/core/handlers/plan.py +106 -0
- bareagent/core/handlers/search_utils.py +77 -0
- bareagent/core/handlers/skill.py +87 -0
- bareagent/core/handlers/subagent_send.py +70 -0
- bareagent/core/handlers/web_fetch.py +126 -0
- bareagent/core/handlers/web_search.py +165 -0
- bareagent/core/handlers/workflow.py +190 -0
- bareagent/core/loop.py +535 -0
- bareagent/core/retry.py +131 -0
- bareagent/core/sandbox.py +27 -0
- bareagent/core/schema.py +21 -0
- bareagent/core/tools.py +779 -0
- bareagent/core/workflow.py +517 -0
- bareagent/core/workflow_registry.py +219 -0
- bareagent/debug/__init__.py +0 -0
- bareagent/debug/interaction_log.py +263 -0
- bareagent/debug/viewer.html +1750 -0
- bareagent/debug/web_viewer.py +157 -0
- bareagent/hooks/__init__.py +32 -0
- bareagent/hooks/config.py +118 -0
- bareagent/hooks/engine.py +197 -0
- bareagent/hooks/errors.py +14 -0
- bareagent/hooks/events.py +22 -0
- bareagent/lsp/__init__.py +63 -0
- bareagent/lsp/config.py +134 -0
- bareagent/lsp/coord.py +118 -0
- bareagent/lsp/diagnostics.py +240 -0
- bareagent/lsp/errors.py +24 -0
- bareagent/lsp/manager.py +866 -0
- bareagent/lsp/tools.py +629 -0
- bareagent/lsp/workspace_edit.py +305 -0
- bareagent/main.py +4205 -0
- bareagent/mcp/__init__.py +69 -0
- bareagent/mcp/_sse.py +69 -0
- bareagent/mcp/client.py +341 -0
- bareagent/mcp/config.py +169 -0
- bareagent/mcp/errors.py +32 -0
- bareagent/mcp/manager.py +318 -0
- bareagent/mcp/protocol.py +187 -0
- bareagent/mcp/registry.py +557 -0
- bareagent/mcp/transport/__init__.py +15 -0
- bareagent/mcp/transport/base.py +149 -0
- bareagent/mcp/transport/http_legacy.py +192 -0
- bareagent/mcp/transport/http_streamable.py +217 -0
- bareagent/mcp/transport/stdio.py +202 -0
- bareagent/memory/__init__.py +1 -0
- bareagent/memory/compact.py +203 -0
- bareagent/memory/conversation_io.py +226 -0
- bareagent/memory/embedding.py +194 -0
- bareagent/memory/persistent.py +515 -0
- bareagent/memory/token_counter.py +67 -0
- bareagent/memory/token_tracker.py +262 -0
- bareagent/memory/transcript.py +100 -0
- bareagent/permission/__init__.py +1 -0
- bareagent/permission/guard.py +329 -0
- bareagent/permission/rules.py +19 -0
- bareagent/planning/__init__.py +19 -0
- bareagent/planning/agent_types.py +169 -0
- bareagent/planning/skill_gen.py +141 -0
- bareagent/planning/skill_store.py +173 -0
- bareagent/planning/skills.py +146 -0
- bareagent/planning/subagent.py +355 -0
- bareagent/planning/subagent_registry.py +77 -0
- bareagent/planning/tasks.py +348 -0
- bareagent/planning/todo.py +153 -0
- bareagent/planning/worktree.py +122 -0
- bareagent/provider/__init__.py +1 -0
- bareagent/provider/anthropic.py +348 -0
- bareagent/provider/base.py +136 -0
- bareagent/provider/factory.py +130 -0
- bareagent/provider/openai.py +881 -0
- bareagent/provider/presets.py +72 -0
- bareagent/provider/setup.py +356 -0
- bareagent/skills/.gitkeep +1 -0
- bareagent/skills/code-review/SKILL.md +68 -0
- bareagent/skills/git/SKILL.md +68 -0
- bareagent/skills/test/SKILL.md +70 -0
- bareagent/team/__init__.py +17 -0
- bareagent/team/autonomous.py +193 -0
- bareagent/team/mailbox.py +239 -0
- bareagent/team/manager.py +155 -0
- bareagent/team/protocols.py +129 -0
- bareagent/tracing/__init__.py +12 -0
- bareagent/tracing/_api.py +92 -0
- bareagent/tracing/_proxy.py +60 -0
- bareagent/tracing/composite.py +115 -0
- bareagent/tracing/json_file.py +115 -0
- bareagent/tracing/langfuse.py +139 -0
- bareagent/tracing/otel.py +107 -0
- bareagent/tracing/setup.py +85 -0
- bareagent/ui/__init__.py +24 -0
- bareagent/ui/console.py +167 -0
- bareagent/ui/prompt.py +78 -0
- bareagent/ui/protocol.py +24 -0
- bareagent/ui/stream.py +66 -0
- bareagent/ui/theme.py +240 -0
- bareagent_cli-0.1.0.dist-info/METADATA +331 -0
- bareagent_cli-0.1.0.dist-info/RECORD +121 -0
- bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
- bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|