multi-agent-platform 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.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- server/services/webhook_service.py +273 -0
cli/agent_client.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Persistent in-process Claude SDK client with session resume.
|
|
2
|
+
|
|
3
|
+
Used by map-runtime-waker (event-driven wake) and legacy bridges. The process
|
|
4
|
+
holds ONE ``ClaudeSDKClient`` for its lifetime; ``claude_session_id`` is
|
|
5
|
+
persisted in local state so the next restart resumes the same Claude session
|
|
6
|
+
(via ``ClaudeAgentOptions(resume=...)``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Literal, Protocol, TypedDict
|
|
19
|
+
|
|
20
|
+
from cli.session_wake_log import (
|
|
21
|
+
DEFAULT_SESSION_LOG_DIR,
|
|
22
|
+
append_session_event,
|
|
23
|
+
append_session_wake_log,
|
|
24
|
+
resolve_session_log_path,
|
|
25
|
+
text_summary,
|
|
26
|
+
tool_result_summary,
|
|
27
|
+
tool_use_summary,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("map.agent_client")
|
|
31
|
+
|
|
32
|
+
DEFAULT_ALLOWED_TOOLS: list[str] = [
|
|
33
|
+
"Skill",
|
|
34
|
+
"Bash",
|
|
35
|
+
"Read",
|
|
36
|
+
"Write",
|
|
37
|
+
"Edit",
|
|
38
|
+
"Glob",
|
|
39
|
+
"Grep",
|
|
40
|
+
"TodoWrite",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
_CREDENTIAL_ENV_KEYS: tuple[str, ...] = (
|
|
44
|
+
"ANTHROPIC_API_KEY",
|
|
45
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
46
|
+
"ANTHROPIC_BASE_URL",
|
|
47
|
+
)
|
|
48
|
+
_MODEL_ENV_KEYS: tuple[str, ...] = ("ANTHROPIC_MODEL", "CLAUDE_MODEL")
|
|
49
|
+
|
|
50
|
+
_EXPORT_RE = re.compile(r"^\s*export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
|
51
|
+
|
|
52
|
+
IntegrationMode = Literal["bridge", "waker", "manual"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class WakeUpEvent(TypedDict, total=False):
|
|
56
|
+
"""Streamed wake-up event consumed by bridge / waker cycle logs."""
|
|
57
|
+
|
|
58
|
+
type: str # "text" | "result" | "tool"
|
|
59
|
+
content: str
|
|
60
|
+
session_id: str
|
|
61
|
+
is_error: bool
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PersonaAgentLike(Protocol):
|
|
65
|
+
"""Minimal Claude SDK client surface used by PersonaAgentClient."""
|
|
66
|
+
|
|
67
|
+
async def connect(self, prompt: str | None = None) -> None: ...
|
|
68
|
+
async def disconnect(self) -> None: ...
|
|
69
|
+
async def query(self, prompt: str) -> None: ...
|
|
70
|
+
def receive_response(self) -> Any: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PersonaAgentClient:
|
|
74
|
+
"""Per-persona persistent Claude SDK client with long-lived connection."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
persona: str,
|
|
80
|
+
state: dict[str, Any],
|
|
81
|
+
save_state_fn: Callable[[], None],
|
|
82
|
+
project_root: Path,
|
|
83
|
+
extra_env: dict[str, str] | None = None,
|
|
84
|
+
model: str | None = None,
|
|
85
|
+
allowed_tools: list[str] | None = None,
|
|
86
|
+
integration: IntegrationMode = "bridge",
|
|
87
|
+
session_log_dir: Path | None = None,
|
|
88
|
+
_client_factory: Callable[[Any], PersonaAgentLike] | None = None,
|
|
89
|
+
wake_timeout: float = 1800.0,
|
|
90
|
+
) -> None:
|
|
91
|
+
self.persona = persona
|
|
92
|
+
self.state = state
|
|
93
|
+
self._save_state_fn = save_state_fn
|
|
94
|
+
self.project_root = project_root
|
|
95
|
+
self.extra_env = dict(extra_env or {})
|
|
96
|
+
self.model = model or self._resolve_model()
|
|
97
|
+
self.allowed_tools = list(allowed_tools or DEFAULT_ALLOWED_TOOLS)
|
|
98
|
+
self.integration = integration
|
|
99
|
+
self.session_log_dir = session_log_dir
|
|
100
|
+
# 单次 wake 内,两条 agent 消息之间的最大间隔(秒)。超过则判定 Agent
|
|
101
|
+
# Runtime 卡死并中断,避免 bridge / waker 永久阻塞在 receive_response()
|
|
102
|
+
# 上。合法长任务期间 Agent 会持续流式发消息,不会触发;真正挂起(网络
|
|
103
|
+
# 掉线、SDK 无响应)才会被超时打断。
|
|
104
|
+
self.wake_timeout = wake_timeout
|
|
105
|
+
self._client_factory = _client_factory
|
|
106
|
+
self._client: PersonaAgentLike | None = None
|
|
107
|
+
self._connected = False
|
|
108
|
+
|
|
109
|
+
async def connect(self) -> None:
|
|
110
|
+
if self._connected:
|
|
111
|
+
return
|
|
112
|
+
resume_session_id = self.state.get("claude_session_id")
|
|
113
|
+
options = self._build_options(resume=resume_session_id)
|
|
114
|
+
client = self._make_client(options)
|
|
115
|
+
await client.connect()
|
|
116
|
+
self._client = client
|
|
117
|
+
self._connected = True
|
|
118
|
+
logger.info(
|
|
119
|
+
"[%s] Claude SDK connected (resume=%s, integration=%s)",
|
|
120
|
+
self.persona,
|
|
121
|
+
resume_session_id or "<new>",
|
|
122
|
+
self.integration,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def wake_up(
|
|
126
|
+
self,
|
|
127
|
+
prompt: str,
|
|
128
|
+
*,
|
|
129
|
+
on_event: Callable[[WakeUpEvent], None] | None = None,
|
|
130
|
+
event_id: str | None = None,
|
|
131
|
+
event_source: str = "polling",
|
|
132
|
+
fingerprint: str | None = None,
|
|
133
|
+
) -> str:
|
|
134
|
+
if not self._connected or self._client is None:
|
|
135
|
+
await self.connect()
|
|
136
|
+
assert self._client is not None
|
|
137
|
+
|
|
138
|
+
from claude_agent_sdk import (
|
|
139
|
+
AssistantMessage,
|
|
140
|
+
ResultMessage,
|
|
141
|
+
TextBlock,
|
|
142
|
+
ToolResultBlock,
|
|
143
|
+
ToolUseBlock,
|
|
144
|
+
UserMessage,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
resume_session_id = self.state.get("claude_session_id")
|
|
148
|
+
# Resolve the session log path up front so every event of this wake
|
|
149
|
+
# (wake/text/tool_use/tool_result/result) lands in one file, even when
|
|
150
|
+
# the real session_id only arrives with the ResultMessage. A long
|
|
151
|
+
# running turn is then observable live — a stuck agent shows up as the
|
|
152
|
+
# last event ts going stale.
|
|
153
|
+
pre_sid = resume_session_id or f"new-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%f')}"
|
|
154
|
+
log_path = resolve_session_log_path(self._resolve_session_log_dir(), pre_sid, self.persona)
|
|
155
|
+
self._log_event(
|
|
156
|
+
log_path,
|
|
157
|
+
event="wake",
|
|
158
|
+
summary=text_summary(prompt),
|
|
159
|
+
event_id=event_id,
|
|
160
|
+
event_source=event_source,
|
|
161
|
+
fingerprint=fingerprint,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
await self._client.query(prompt)
|
|
165
|
+
result: ResultMessage | None = None
|
|
166
|
+
response_parts: list[str] = []
|
|
167
|
+
_response_iter = self._client.receive_response().__aiter__()
|
|
168
|
+
while True:
|
|
169
|
+
# 单步超时:两条 agent 消息之间最多等 wake_timeout 秒。Agent 流式
|
|
170
|
+
# 响应期间持续有消息;只有真正卡死(无任何消息)才超时中断,
|
|
171
|
+
# 防止 bridge / waker 永久阻塞在 receive_response() 上。
|
|
172
|
+
try:
|
|
173
|
+
msg = await asyncio.wait_for(
|
|
174
|
+
_response_iter.__anext__(), timeout=self.wake_timeout
|
|
175
|
+
)
|
|
176
|
+
except StopAsyncIteration:
|
|
177
|
+
break
|
|
178
|
+
except TimeoutError:
|
|
179
|
+
self._log_event(
|
|
180
|
+
log_path,
|
|
181
|
+
event="timeout",
|
|
182
|
+
summary=f"no agent message for {self.wake_timeout}s; aborting wake",
|
|
183
|
+
event_id=event_id,
|
|
184
|
+
event_source=event_source,
|
|
185
|
+
fingerprint=fingerprint,
|
|
186
|
+
)
|
|
187
|
+
result = None
|
|
188
|
+
break
|
|
189
|
+
if isinstance(msg, AssistantMessage):
|
|
190
|
+
for block in msg.content:
|
|
191
|
+
if isinstance(block, TextBlock):
|
|
192
|
+
response_parts.append(block.text)
|
|
193
|
+
if on_event is not None:
|
|
194
|
+
on_event({"type": "text", "content": block.text})
|
|
195
|
+
self._log_event(
|
|
196
|
+
log_path,
|
|
197
|
+
event="text",
|
|
198
|
+
summary=text_summary(block.text),
|
|
199
|
+
event_id=event_id,
|
|
200
|
+
event_source=event_source,
|
|
201
|
+
fingerprint=fingerprint,
|
|
202
|
+
)
|
|
203
|
+
elif isinstance(block, ToolUseBlock):
|
|
204
|
+
if on_event is not None:
|
|
205
|
+
on_event(
|
|
206
|
+
{
|
|
207
|
+
"type": "tool_use",
|
|
208
|
+
"name": block.name,
|
|
209
|
+
"content": tool_use_summary(block.name, block.input),
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
self._log_event(
|
|
213
|
+
log_path,
|
|
214
|
+
event="tool_use",
|
|
215
|
+
summary=tool_use_summary(block.name, block.input),
|
|
216
|
+
event_id=event_id,
|
|
217
|
+
event_source=event_source,
|
|
218
|
+
fingerprint=fingerprint,
|
|
219
|
+
)
|
|
220
|
+
elif isinstance(msg, UserMessage):
|
|
221
|
+
# UserMessage.content may be a plain str; only scan block lists.
|
|
222
|
+
if isinstance(msg.content, list):
|
|
223
|
+
for block in msg.content:
|
|
224
|
+
if isinstance(block, ToolResultBlock):
|
|
225
|
+
if on_event is not None:
|
|
226
|
+
on_event(
|
|
227
|
+
{
|
|
228
|
+
"type": "tool_result",
|
|
229
|
+
"is_error": bool(block.is_error),
|
|
230
|
+
"content": tool_result_summary(block.content, block.is_error),
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
self._log_event(
|
|
234
|
+
log_path,
|
|
235
|
+
event="tool_result",
|
|
236
|
+
summary=tool_result_summary(block.content, block.is_error),
|
|
237
|
+
event_id=event_id,
|
|
238
|
+
event_source=event_source,
|
|
239
|
+
fingerprint=fingerprint,
|
|
240
|
+
)
|
|
241
|
+
elif isinstance(msg, ResultMessage):
|
|
242
|
+
result = msg
|
|
243
|
+
if on_event is not None:
|
|
244
|
+
on_event(
|
|
245
|
+
{
|
|
246
|
+
"type": "result",
|
|
247
|
+
"session_id": msg.session_id,
|
|
248
|
+
"is_error": bool(getattr(msg, "is_error", False)),
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if result is not None and getattr(result, "session_id", None):
|
|
253
|
+
new_sid = result.session_id
|
|
254
|
+
if new_sid != self.state.get("claude_session_id"):
|
|
255
|
+
self.state["claude_session_id"] = new_sid
|
|
256
|
+
self._save_state_fn()
|
|
257
|
+
if result is not None and getattr(result, "is_error", False):
|
|
258
|
+
status = "error"
|
|
259
|
+
elif result is None:
|
|
260
|
+
status = "no_response"
|
|
261
|
+
else:
|
|
262
|
+
status = "ok"
|
|
263
|
+
|
|
264
|
+
self.state["last_wakeup_at"] = datetime.now(UTC).isoformat()
|
|
265
|
+
self.state["last_wakeup_status"] = status
|
|
266
|
+
self._save_state_fn()
|
|
267
|
+
|
|
268
|
+
response_text = "".join(response_parts)
|
|
269
|
+
log_session_id = self._wake_log_session_id(result, resume_session_id)
|
|
270
|
+
# Result summary goes into the same file as the live events above; keep
|
|
271
|
+
# the A3-audit field set by routing through append_session_wake_log with
|
|
272
|
+
# the pre-resolved log_path.
|
|
273
|
+
self._append_wake_session_log(
|
|
274
|
+
session_id=log_session_id,
|
|
275
|
+
prompt=prompt,
|
|
276
|
+
response_text=response_text,
|
|
277
|
+
status=status,
|
|
278
|
+
event_id=event_id,
|
|
279
|
+
event_source=event_source,
|
|
280
|
+
fingerprint=fingerprint,
|
|
281
|
+
log_path=log_path,
|
|
282
|
+
)
|
|
283
|
+
return status
|
|
284
|
+
|
|
285
|
+
async def disconnect(self) -> None:
|
|
286
|
+
if self._client is None or not self._connected:
|
|
287
|
+
self._connected = False
|
|
288
|
+
return
|
|
289
|
+
try:
|
|
290
|
+
await self._client.disconnect()
|
|
291
|
+
except Exception as exc: # noqa: BLE001
|
|
292
|
+
logger.warning("[%s] Claude SDK disconnect failed: %s", self.persona, exc)
|
|
293
|
+
finally:
|
|
294
|
+
self._client = None
|
|
295
|
+
self._connected = False
|
|
296
|
+
|
|
297
|
+
def _make_client(self, options: Any) -> PersonaAgentLike:
|
|
298
|
+
if self._client_factory is not None:
|
|
299
|
+
return self._client_factory(options)
|
|
300
|
+
from claude_agent_sdk import ClaudeSDKClient
|
|
301
|
+
|
|
302
|
+
return ClaudeSDKClient(options=options)
|
|
303
|
+
|
|
304
|
+
def _build_options(self, *, resume: str | None) -> Any:
|
|
305
|
+
from claude_agent_sdk import ClaudeAgentOptions
|
|
306
|
+
|
|
307
|
+
env = self._resolve_env()
|
|
308
|
+
append_prompt = self._system_append_prompt()
|
|
309
|
+
kwargs: dict[str, Any] = dict(
|
|
310
|
+
cwd=str(self.project_root),
|
|
311
|
+
setting_sources=["project"],
|
|
312
|
+
system_prompt={"type": "preset", "preset": "claude_code", "append": append_prompt},
|
|
313
|
+
allowed_tools=self.allowed_tools,
|
|
314
|
+
permission_mode="acceptEdits",
|
|
315
|
+
env=env,
|
|
316
|
+
)
|
|
317
|
+
if resume:
|
|
318
|
+
kwargs["resume"] = resume
|
|
319
|
+
if self.model:
|
|
320
|
+
kwargs["model"] = self.model
|
|
321
|
+
return ClaudeAgentOptions(**kwargs)
|
|
322
|
+
|
|
323
|
+
def _resolve_session_log_dir(self) -> Path:
|
|
324
|
+
if self.session_log_dir is not None:
|
|
325
|
+
return self.session_log_dir
|
|
326
|
+
override = os.environ.get("MAP_SESSION_WAKE_LOG_DIR", "").strip()
|
|
327
|
+
if override:
|
|
328
|
+
return Path(override)
|
|
329
|
+
return self.project_root / DEFAULT_SESSION_LOG_DIR
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _wake_log_session_id(
|
|
333
|
+
result: Any,
|
|
334
|
+
resume_session_id: str | None,
|
|
335
|
+
) -> str:
|
|
336
|
+
if result is not None and getattr(result, "session_id", None):
|
|
337
|
+
return str(result.session_id)
|
|
338
|
+
if resume_session_id:
|
|
339
|
+
return str(resume_session_id)
|
|
340
|
+
return f"unknown-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}"
|
|
341
|
+
|
|
342
|
+
def _log_event(
|
|
343
|
+
self,
|
|
344
|
+
log_path: Path,
|
|
345
|
+
*,
|
|
346
|
+
event: str,
|
|
347
|
+
summary: str,
|
|
348
|
+
event_id: str | None = None,
|
|
349
|
+
event_source: str = "polling",
|
|
350
|
+
fingerprint: str | None = None,
|
|
351
|
+
) -> None:
|
|
352
|
+
if os.environ.get("MAP_SESSION_WAKE_LOG", "1").strip().lower() in {
|
|
353
|
+
"0",
|
|
354
|
+
"false",
|
|
355
|
+
"no",
|
|
356
|
+
"off",
|
|
357
|
+
}:
|
|
358
|
+
return
|
|
359
|
+
try:
|
|
360
|
+
append_session_event(
|
|
361
|
+
log_path=log_path,
|
|
362
|
+
persona=self.persona,
|
|
363
|
+
integration=self.integration,
|
|
364
|
+
event=event,
|
|
365
|
+
summary=summary,
|
|
366
|
+
event_id=event_id,
|
|
367
|
+
event_source=event_source,
|
|
368
|
+
fingerprint=fingerprint,
|
|
369
|
+
)
|
|
370
|
+
except OSError as exc:
|
|
371
|
+
logger.warning("[%s] session event log write failed: %s", self.persona, exc)
|
|
372
|
+
|
|
373
|
+
def _append_wake_session_log(
|
|
374
|
+
self,
|
|
375
|
+
*,
|
|
376
|
+
session_id: str,
|
|
377
|
+
prompt: str,
|
|
378
|
+
response_text: str,
|
|
379
|
+
status: str,
|
|
380
|
+
event_id: str | None = None,
|
|
381
|
+
event_source: str = "polling",
|
|
382
|
+
fingerprint: str | None = None,
|
|
383
|
+
log_path: Path | None = None,
|
|
384
|
+
) -> None:
|
|
385
|
+
if os.environ.get("MAP_SESSION_WAKE_LOG", "1").strip().lower() in {
|
|
386
|
+
"0",
|
|
387
|
+
"false",
|
|
388
|
+
"no",
|
|
389
|
+
"off",
|
|
390
|
+
}:
|
|
391
|
+
return
|
|
392
|
+
try:
|
|
393
|
+
append_session_wake_log(
|
|
394
|
+
log_dir=self._resolve_session_log_dir(),
|
|
395
|
+
session_id=session_id,
|
|
396
|
+
persona=self.persona,
|
|
397
|
+
integration=self.integration,
|
|
398
|
+
prompt=prompt,
|
|
399
|
+
response_text=response_text,
|
|
400
|
+
status=status,
|
|
401
|
+
event_id=event_id,
|
|
402
|
+
event_source=event_source,
|
|
403
|
+
fingerprint=fingerprint,
|
|
404
|
+
log_path=log_path,
|
|
405
|
+
)
|
|
406
|
+
except OSError as exc:
|
|
407
|
+
logger.warning("[%s] session wake log write failed: %s", self.persona, exc)
|
|
408
|
+
|
|
409
|
+
def _system_append_prompt(self) -> str:
|
|
410
|
+
if self.integration == "waker":
|
|
411
|
+
return (
|
|
412
|
+
f"You are the **{self.persona}** persona of the MAP (Multi-Agent Platform) "
|
|
413
|
+
"project. When woken by map-runtime-waker you receive a single wake event. "
|
|
414
|
+
f"Confirm identity with `map --persona {self.persona} persona whoami`, read "
|
|
415
|
+
f"latest work with `map --persona {self.persona} todos`, then handle only the "
|
|
416
|
+
"event in the wake prompt using the appropriate skill from `.cursor/skills/`. "
|
|
417
|
+
"Gather missing context via the `map` CLI; do not wait for the waker to supply "
|
|
418
|
+
"a full plan."
|
|
419
|
+
)
|
|
420
|
+
if self.integration == "manual":
|
|
421
|
+
return (
|
|
422
|
+
f"You are the **{self.persona}** persona of the MAP (Multi-Agent Platform) "
|
|
423
|
+
"project. A human operator resumed this session via `map runtime chat`. "
|
|
424
|
+
f"Use `map --persona {self.persona}` CLI and skills under `.cursor/skills/` "
|
|
425
|
+
"(topic-host, topic-participant, experiment-host, experiment-reviewer, "
|
|
426
|
+
"map-project-collab). Follow the operator's instructions for the current task. "
|
|
427
|
+
"Do not assume map-runtime-waker will send another wake for the same work item."
|
|
428
|
+
)
|
|
429
|
+
return (
|
|
430
|
+
f"You are the **{self.persona}** persona of the MAP (Multi-Agent Platform) "
|
|
431
|
+
"project. When woken up by the bridge, ALWAYS start by running "
|
|
432
|
+
f"`map --persona {self.persona} todos` to inspect pending work, then invoke "
|
|
433
|
+
"the appropriate skill from `.cursor/skills/` (topic-host, topic-participant, "
|
|
434
|
+
"topic-reviewer, experiment-host, or map-project-collab) to act on each item. "
|
|
435
|
+
"Do not wait for the bridge to feed you a complete plan; gather state "
|
|
436
|
+
"yourself via the `map` CLI."
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def _resolve_env(self) -> dict[str, str]:
|
|
440
|
+
env: dict[str, str] = {}
|
|
441
|
+
for key in _CREDENTIAL_ENV_KEYS:
|
|
442
|
+
value = self._resolve_env_key(key)
|
|
443
|
+
if value:
|
|
444
|
+
env[key] = value
|
|
445
|
+
env.update(self.extra_env)
|
|
446
|
+
return env
|
|
447
|
+
|
|
448
|
+
def _resolve_env_key(self, name: str) -> str | None:
|
|
449
|
+
existing = os.environ.get(name, "").strip()
|
|
450
|
+
if existing:
|
|
451
|
+
return existing
|
|
452
|
+
for path in self._candidate_rc_files():
|
|
453
|
+
found = self._read_export(path, name)
|
|
454
|
+
if found:
|
|
455
|
+
return found
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
def _resolve_model(self) -> str | None:
|
|
459
|
+
for key in _MODEL_ENV_KEYS:
|
|
460
|
+
value = self._resolve_env_key(key)
|
|
461
|
+
if value:
|
|
462
|
+
return value
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
def _candidate_rc_files(self) -> list[Path]:
|
|
466
|
+
home = Path.home()
|
|
467
|
+
return [
|
|
468
|
+
self.project_root / ".map" / ".claude-env",
|
|
469
|
+
home / ".bashrc",
|
|
470
|
+
home / ".profile",
|
|
471
|
+
home / ".bash_profile",
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
@staticmethod
|
|
475
|
+
def _read_export(path: Path, name: str) -> str | None:
|
|
476
|
+
if not path.is_file():
|
|
477
|
+
return None
|
|
478
|
+
try:
|
|
479
|
+
text = path.read_text(encoding="utf-8")
|
|
480
|
+
except OSError:
|
|
481
|
+
return None
|
|
482
|
+
for line in text.splitlines():
|
|
483
|
+
match = _EXPORT_RE.match(line)
|
|
484
|
+
if match and match.group(1) == name:
|
|
485
|
+
value = match.group(2).strip()
|
|
486
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
487
|
+
value = value[1:-1]
|
|
488
|
+
elif value and value[0] not in {"'", '"'}:
|
|
489
|
+
value = re.sub(r"\s+#.*$", "", value).strip()
|
|
490
|
+
if value:
|
|
491
|
+
return value
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def make_wakeup_prompt(persona: str, todos: dict[str, Any]) -> str:
|
|
496
|
+
"""Build a brief bridge wake-up prompt from a todos snapshot."""
|
|
497
|
+
lines = [
|
|
498
|
+
f"You are the **{persona}** persona of MAP.",
|
|
499
|
+
"",
|
|
500
|
+
"You have pending work. Inspect it, then act:",
|
|
501
|
+
"",
|
|
502
|
+
f" $ map --persona {persona} todos",
|
|
503
|
+
"",
|
|
504
|
+
]
|
|
505
|
+
pending_replies = todos.get("pending_replies") or []
|
|
506
|
+
pending_topic_replies = todos.get("pending_topic_replies") or []
|
|
507
|
+
pending_reviews = todos.get("pending_reviews") or []
|
|
508
|
+
my_open_experiments = todos.get("my_open_experiments") or []
|
|
509
|
+
my_open_topics = todos.get("my_open_topics") or []
|
|
510
|
+
mentions = todos.get("mentions") or []
|
|
511
|
+
|
|
512
|
+
if pending_replies:
|
|
513
|
+
lines.append(f"- {len(pending_replies)} pending review reply(ies)")
|
|
514
|
+
if pending_topic_replies:
|
|
515
|
+
lines.append(f"- {len(pending_topic_replies)} pending topic reply(ies)")
|
|
516
|
+
if pending_reviews:
|
|
517
|
+
lines.append(f"- {len(pending_reviews)} pending review(s)")
|
|
518
|
+
if my_open_experiments:
|
|
519
|
+
lines.append(f"- {len(my_open_experiments)} open experiment(s)")
|
|
520
|
+
if my_open_topics:
|
|
521
|
+
lines.append(f"- {len(my_open_topics)} open topic(s)")
|
|
522
|
+
if mentions:
|
|
523
|
+
lines.append(f"- {len(mentions)} mention(s)")
|
|
524
|
+
if not any(
|
|
525
|
+
[
|
|
526
|
+
pending_replies,
|
|
527
|
+
pending_topic_replies,
|
|
528
|
+
pending_reviews,
|
|
529
|
+
my_open_experiments,
|
|
530
|
+
my_open_topics,
|
|
531
|
+
mentions,
|
|
532
|
+
]
|
|
533
|
+
):
|
|
534
|
+
lines.append("(no pending items — short-circuit and report idle)")
|
|
535
|
+
|
|
536
|
+
lines.extend(
|
|
537
|
+
[
|
|
538
|
+
"",
|
|
539
|
+
"Use the appropriate skill from `.cursor/skills/` (topic-host, "
|
|
540
|
+
"topic-participant, topic-reviewer, experiment-host, or "
|
|
541
|
+
"map-project-collab). Report a brief status when done.",
|
|
542
|
+
]
|
|
543
|
+
)
|
|
544
|
+
return "\n".join(lines)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
__all__ = [
|
|
548
|
+
"DEFAULT_ALLOWED_TOOLS",
|
|
549
|
+
"IntegrationMode",
|
|
550
|
+
"PersonaAgentClient",
|
|
551
|
+
"PersonaAgentLike",
|
|
552
|
+
"WakeUpEvent",
|
|
553
|
+
"make_wakeup_prompt",
|
|
554
|
+
]
|
cli/bridge_state.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cli.host_worker_types import WorkerError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_bridge_state(
|
|
11
|
+
path: Path | None,
|
|
12
|
+
*,
|
|
13
|
+
bridge_name: str,
|
|
14
|
+
default_collections: tuple[str, ...],
|
|
15
|
+
validate_schema: bool = True,
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
if path is None or not path.exists():
|
|
18
|
+
return _default_state(default_collections)
|
|
19
|
+
try:
|
|
20
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
21
|
+
except json.JSONDecodeError as exc:
|
|
22
|
+
raise WorkerError(f"Invalid {bridge_name} bridge state file: {path}") from exc
|
|
23
|
+
if not isinstance(state, dict):
|
|
24
|
+
raise WorkerError(f"Invalid {bridge_name} bridge state file: {path}")
|
|
25
|
+
if validate_schema and state.get("schema_version", 1) != 1:
|
|
26
|
+
raise WorkerError(f"Unsupported {bridge_name} bridge state schema: {state.get('schema_version')}")
|
|
27
|
+
state.setdefault("schema_version", 1)
|
|
28
|
+
for collection in default_collections:
|
|
29
|
+
state.setdefault(collection, {})
|
|
30
|
+
return state
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def save_bridge_state(path: Path | None, state: dict[str, Any]) -> None:
|
|
34
|
+
if path is None:
|
|
35
|
+
return
|
|
36
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
38
|
+
tmp_path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
39
|
+
tmp_path.replace(path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _default_state(default_collections: tuple[str, ...]) -> dict[str, Any]:
|
|
43
|
+
return {"schema_version": 1, **{collection: {} for collection in default_collections}}
|
cli/commands/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""cli.commands — sub-app command modules (arch experiment 0519e2a3 PR3+).
|
|
2
|
+
|
|
3
|
+
Each module in this package owns one Typer ``*_app`` sub-application
|
|
4
|
+
(``agent_app``, ``topic_app``, etc.) and registers its commands. The
|
|
5
|
+
parent ``cli/main.py`` only does ``app.add_typer(..., name=...)`` to
|
|
6
|
+
expose them under the user-visible ``map <name> ...`` path.
|
|
7
|
+
|
|
8
|
+
Helpers (``_run``, ``_client_ctx``, ``_print_json``, ``_cli_options``)
|
|
9
|
+
live in ``cli/main.py`` and are imported lazily inside command bodies
|
|
10
|
+
to keep ``cli.main`` importable without instantiating the sub-apps
|
|
11
|
+
(avoids the circular import: ``cli.main → cli.commands.agent →
|
|
12
|
+
cli.main._run``).
|
|
13
|
+
"""
|