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/runtime_chat.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Interactive MAP runtime chat — resume a persona session from the terminal.
|
|
2
|
+
|
|
3
|
+
Unlike map-runtime-waker (one prompt per wake), ``map runtime chat`` keeps the
|
|
4
|
+
Claude SDK connection open and accepts multiple user turns. Manual mode does not
|
|
5
|
+
call the D6 ``inbound-event record`` server gate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from map_client.project_config import find_map_dir
|
|
19
|
+
|
|
20
|
+
from cli.agent_client import PersonaAgentClient, WakeUpEvent
|
|
21
|
+
from cli.bridge_state import load_bridge_state, save_bridge_state
|
|
22
|
+
from cli.host_worker_types import WorkerError
|
|
23
|
+
from cli.wake_backend import sync_runtime_skills
|
|
24
|
+
|
|
25
|
+
DEFAULT_STATE_TEMPLATE = ".map/runtime-waker-state-{persona}.json"
|
|
26
|
+
DEFAULT_RUNTIME_HOME_TEMPLATE = ".map/claude-runtime-home-{persona}"
|
|
27
|
+
|
|
28
|
+
_REPL_HELP = """\
|
|
29
|
+
Commands:
|
|
30
|
+
/help, /? Show this help
|
|
31
|
+
/session Print resumed Claude session id
|
|
32
|
+
/quit, /exit End chat (Ctrl+D also quits)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_project_root(project_root: Path | None) -> Path:
|
|
37
|
+
if project_root is not None:
|
|
38
|
+
return project_root.resolve()
|
|
39
|
+
map_dir = find_map_dir(None)
|
|
40
|
+
return map_dir.parent.resolve()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def default_state_file(project_root: Path, persona: str) -> Path:
|
|
44
|
+
return project_root / DEFAULT_STATE_TEMPLATE.format(persona=persona)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_runtime_home(project_root: Path, persona: str) -> Path:
|
|
48
|
+
return project_root / DEFAULT_RUNTIME_HOME_TEMPLATE.format(persona=persona)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_runtime_state(state_file: Path) -> dict[str, Any]:
|
|
52
|
+
return load_bridge_state(
|
|
53
|
+
state_file,
|
|
54
|
+
bridge_name="runtime-waker",
|
|
55
|
+
default_collections=("personas",),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def persona_agent_state(state: dict[str, Any], persona: str) -> dict[str, Any]:
|
|
60
|
+
personas = state.setdefault("personas", {})
|
|
61
|
+
if not isinstance(personas, dict):
|
|
62
|
+
personas = {}
|
|
63
|
+
state["personas"] = personas
|
|
64
|
+
persona_state = personas.setdefault(persona, {})
|
|
65
|
+
if not isinstance(persona_state, dict):
|
|
66
|
+
persona_state = {}
|
|
67
|
+
personas[persona] = persona_state
|
|
68
|
+
return persona_state
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_session_id(
|
|
72
|
+
persona_state: dict[str, Any],
|
|
73
|
+
*,
|
|
74
|
+
session_id: str | None,
|
|
75
|
+
new_session: bool,
|
|
76
|
+
) -> str | None:
|
|
77
|
+
if new_session:
|
|
78
|
+
return None
|
|
79
|
+
if session_id:
|
|
80
|
+
return session_id
|
|
81
|
+
for key in ("claude_session_id", "runtime_session_id"):
|
|
82
|
+
value = persona_state.get(key)
|
|
83
|
+
if value:
|
|
84
|
+
return str(value)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_runtime_waker_pids(persona: str) -> list[int]:
|
|
89
|
+
# simple-waker 是默认 waker,实际进程是
|
|
90
|
+
# `python3 -m cli.simple_waker --persona <persona>`(legacy runtime-waker
|
|
91
|
+
# SSE 路径已下线)。
|
|
92
|
+
pattern = rf"cli\.simple_waker.*--persona {persona}"
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
["pgrep", "-f", pattern],
|
|
95
|
+
check=False,
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
)
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
return []
|
|
101
|
+
pids: list[int] = []
|
|
102
|
+
for line in result.stdout.splitlines():
|
|
103
|
+
line = line.strip()
|
|
104
|
+
if not line:
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
pids.append(int(line))
|
|
108
|
+
except ValueError:
|
|
109
|
+
continue
|
|
110
|
+
return pids
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def ensure_waker_not_running(*, persona: str, ignore_waker: bool) -> None:
|
|
114
|
+
if ignore_waker:
|
|
115
|
+
return
|
|
116
|
+
pids = find_runtime_waker_pids(persona)
|
|
117
|
+
if not pids:
|
|
118
|
+
return
|
|
119
|
+
pid_list = ", ".join(str(pid) for pid in pids)
|
|
120
|
+
raise WorkerError(
|
|
121
|
+
f"runtime waker for persona={persona!r} is already running (pid(s): {pid_list}). "
|
|
122
|
+
"Stop it before `map runtime chat` to avoid session conflicts, or pass --ignore-waker."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def format_session_banner(
|
|
127
|
+
*,
|
|
128
|
+
persona: str,
|
|
129
|
+
session_id: str | None,
|
|
130
|
+
state_file: Path,
|
|
131
|
+
runtime_home: Path,
|
|
132
|
+
resumed: bool,
|
|
133
|
+
) -> str:
|
|
134
|
+
session_label = session_id or "<new session>"
|
|
135
|
+
mode = "resume" if resumed else "new"
|
|
136
|
+
return (
|
|
137
|
+
f"MAP runtime chat · persona={persona} · {mode} · session={session_label}\n"
|
|
138
|
+
f"state={state_file}\n"
|
|
139
|
+
f"runtime_home={runtime_home}\n"
|
|
140
|
+
"Type /help for commands. Empty line is ignored.\n"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _default_on_event(event: WakeUpEvent) -> None:
|
|
145
|
+
if event.get("type") == "text":
|
|
146
|
+
sys.stdout.write(str(event.get("content") or ""))
|
|
147
|
+
sys.stdout.flush()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def run_chat_loop(
|
|
151
|
+
client: PersonaAgentClient,
|
|
152
|
+
*,
|
|
153
|
+
initial_prompt: str | None,
|
|
154
|
+
input_fn: Callable[[str], str] = input,
|
|
155
|
+
on_event: Callable[[WakeUpEvent], None] | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
emit = on_event or _default_on_event
|
|
158
|
+
|
|
159
|
+
async def turn(prompt: str) -> str:
|
|
160
|
+
return await client.wake_up(
|
|
161
|
+
prompt,
|
|
162
|
+
on_event=emit,
|
|
163
|
+
event_source="manual",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
await client.connect()
|
|
167
|
+
|
|
168
|
+
if initial_prompt:
|
|
169
|
+
typer.echo(f"\n[prompt] {initial_prompt}\n")
|
|
170
|
+
status = await turn(initial_prompt)
|
|
171
|
+
if status == "error":
|
|
172
|
+
raise WorkerError("Claude returned error status for initial prompt")
|
|
173
|
+
typer.echo("\n")
|
|
174
|
+
|
|
175
|
+
while True:
|
|
176
|
+
try:
|
|
177
|
+
line = input_fn("map> ").strip()
|
|
178
|
+
except EOFError:
|
|
179
|
+
typer.echo("")
|
|
180
|
+
break
|
|
181
|
+
if not line:
|
|
182
|
+
continue
|
|
183
|
+
lowered = line.lower()
|
|
184
|
+
if lowered in {"/quit", "/exit", "/q"}:
|
|
185
|
+
break
|
|
186
|
+
if lowered in {"/help", "/?"}:
|
|
187
|
+
typer.echo(_REPL_HELP)
|
|
188
|
+
continue
|
|
189
|
+
if lowered == "/session":
|
|
190
|
+
sid = client.state.get("claude_session_id") or client.state.get("runtime_session_id")
|
|
191
|
+
typer.echo(sid or "<none>")
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
typer.echo("")
|
|
195
|
+
status = await turn(line)
|
|
196
|
+
if status == "error":
|
|
197
|
+
typer.echo("\n[error] Claude returned error status for this turn.", err=True)
|
|
198
|
+
typer.echo("\n")
|
|
199
|
+
|
|
200
|
+
await client.disconnect()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def run_runtime_chat_async(
|
|
204
|
+
*,
|
|
205
|
+
persona: str,
|
|
206
|
+
project_root: Path | None,
|
|
207
|
+
state_file: Path | None,
|
|
208
|
+
runtime_home: Path | None,
|
|
209
|
+
session_id: str | None,
|
|
210
|
+
new_session: bool,
|
|
211
|
+
initial_prompt: str | None,
|
|
212
|
+
ignore_waker: bool,
|
|
213
|
+
model: str | None,
|
|
214
|
+
) -> None:
|
|
215
|
+
root = resolve_project_root(project_root)
|
|
216
|
+
resolved_state_file = (state_file or default_state_file(root, persona)).resolve()
|
|
217
|
+
resolved_runtime_home = (runtime_home or default_runtime_home(root, persona)).resolve()
|
|
218
|
+
|
|
219
|
+
ensure_waker_not_running(persona=persona, ignore_waker=ignore_waker)
|
|
220
|
+
|
|
221
|
+
state = load_runtime_state(resolved_state_file)
|
|
222
|
+
agent_state = persona_agent_state(state, persona)
|
|
223
|
+
resume_id = resolve_session_id(agent_state, session_id=session_id, new_session=new_session)
|
|
224
|
+
if new_session:
|
|
225
|
+
agent_state.pop("claude_session_id", None)
|
|
226
|
+
agent_state.pop("runtime_session_id", None)
|
|
227
|
+
|
|
228
|
+
sync_runtime_skills(project_root=root, runtime_home=resolved_runtime_home)
|
|
229
|
+
resolved_runtime_home.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
|
|
231
|
+
def save_state() -> None:
|
|
232
|
+
save_bridge_state(resolved_state_file, state)
|
|
233
|
+
|
|
234
|
+
extra_env = {
|
|
235
|
+
"HOME": str(resolved_runtime_home),
|
|
236
|
+
"MAP_RUNTIME_CHAT_PERSONA": persona,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
client = PersonaAgentClient(
|
|
240
|
+
persona=persona,
|
|
241
|
+
state=agent_state,
|
|
242
|
+
save_state_fn=save_state,
|
|
243
|
+
project_root=root,
|
|
244
|
+
extra_env=extra_env,
|
|
245
|
+
model=model,
|
|
246
|
+
integration="manual",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if resume_id and not new_session:
|
|
250
|
+
client.state["claude_session_id"] = resume_id
|
|
251
|
+
client.state["runtime_session_id"] = resume_id
|
|
252
|
+
|
|
253
|
+
typer.echo(
|
|
254
|
+
format_session_banner(
|
|
255
|
+
persona=persona,
|
|
256
|
+
session_id=resume_id,
|
|
257
|
+
state_file=resolved_state_file,
|
|
258
|
+
runtime_home=resolved_runtime_home,
|
|
259
|
+
resumed=bool(resume_id),
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
await run_chat_loop(client, initial_prompt=initial_prompt)
|
|
265
|
+
finally:
|
|
266
|
+
save_state()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def run_runtime_chat(**kwargs: Any) -> None:
|
|
270
|
+
try:
|
|
271
|
+
asyncio.run(run_runtime_chat_async(**kwargs))
|
|
272
|
+
except WorkerError as exc:
|
|
273
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
274
|
+
raise typer.Exit(1) from exc
|
|
275
|
+
except KeyboardInterrupt:
|
|
276
|
+
typer.echo("\nInterrupted.")
|
|
277
|
+
raise typer.Exit(130) from None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def dump_runtime_chat_status(
|
|
281
|
+
*,
|
|
282
|
+
persona: str,
|
|
283
|
+
project_root: Path | None,
|
|
284
|
+
state_file: Path | None,
|
|
285
|
+
) -> dict[str, Any]:
|
|
286
|
+
root = resolve_project_root(project_root)
|
|
287
|
+
resolved_state_file = (state_file or default_state_file(root, persona)).resolve()
|
|
288
|
+
state = load_runtime_state(resolved_state_file)
|
|
289
|
+
agent_state = persona_agent_state(state, persona)
|
|
290
|
+
session_id = resolve_session_id(agent_state, session_id=None, new_session=False)
|
|
291
|
+
return {
|
|
292
|
+
"persona": persona,
|
|
293
|
+
"project_root": str(root),
|
|
294
|
+
"state_file": str(resolved_state_file),
|
|
295
|
+
"runtime_home": str(default_runtime_home(root, persona)),
|
|
296
|
+
"session_id": session_id,
|
|
297
|
+
"waker_pids": find_runtime_waker_pids(persona),
|
|
298
|
+
"last_wakeup_at": agent_state.get("last_wakeup_at"),
|
|
299
|
+
"last_wakeup_status": agent_state.get("last_wakeup_status"),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
__all__ = [
|
|
304
|
+
"default_runtime_home",
|
|
305
|
+
"default_state_file",
|
|
306
|
+
"dump_runtime_chat_status",
|
|
307
|
+
"ensure_waker_not_running",
|
|
308
|
+
"find_runtime_waker_pids",
|
|
309
|
+
"format_session_banner",
|
|
310
|
+
"load_runtime_state",
|
|
311
|
+
"persona_agent_state",
|
|
312
|
+
"resolve_project_root",
|
|
313
|
+
"resolve_session_id",
|
|
314
|
+
"run_chat_loop",
|
|
315
|
+
"run_runtime_chat",
|
|
316
|
+
"run_runtime_chat_async",
|
|
317
|
+
]
|
cli/session_wake_log.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Append-only per-session wake logs for PersonaAgentClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from zoneinfo import ZoneInfo
|
|
12
|
+
|
|
13
|
+
DEFAULT_SESSION_LOG_DIR = Path(".map/runtime-waker-sessions")
|
|
14
|
+
DEFAULT_LOG_TIMEZONE = "Asia/Shanghai"
|
|
15
|
+
RESPONSE_PREVIEW_MAX = 200
|
|
16
|
+
|
|
17
|
+
_SAFE_SESSION_ID_RE = re.compile(r"[^\w.\-]+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def log_timezone() -> ZoneInfo:
|
|
21
|
+
"""Timezone for human-readable session log timestamps (default: Asia/Shanghai)."""
|
|
22
|
+
return ZoneInfo(os.environ.get("MAP_LOG_TIMEZONE", DEFAULT_LOG_TIMEZONE))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def log_now() -> datetime:
|
|
26
|
+
return datetime.now(log_timezone())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def safe_session_log_name(session_id: str) -> str:
|
|
30
|
+
"""Turn a session id into a single path segment safe for log filenames."""
|
|
31
|
+
safe = _SAFE_SESSION_ID_RE.sub("_", session_id.strip())
|
|
32
|
+
return safe or "unknown"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_session_log_path(log_dir: Path, session_id: str, persona: str) -> Path:
|
|
36
|
+
"""Resolve the JSONL path for a session (reuse existing file across resume wakes)."""
|
|
37
|
+
safe_id = safe_session_log_name(session_id)
|
|
38
|
+
safe_persona = safe_session_log_name(persona)
|
|
39
|
+
# Persona must be part of the lookup — multiple wakers can share the same
|
|
40
|
+
# provisional ``new-YYYYMMDDTHHMMSS`` id when they wake in the same second.
|
|
41
|
+
stamped = sorted(
|
|
42
|
+
log_dir.glob(f"*_{safe_persona}_{safe_id}.jsonl"),
|
|
43
|
+
key=lambda path: path.stat().st_mtime,
|
|
44
|
+
)
|
|
45
|
+
if stamped:
|
|
46
|
+
return stamped[-1]
|
|
47
|
+
legacy = log_dir / f"{safe_id}.jsonl"
|
|
48
|
+
if legacy.exists():
|
|
49
|
+
return legacy
|
|
50
|
+
ts = log_now().strftime("%Y%m%d-%H%M%S")
|
|
51
|
+
return log_dir / f"{ts}_{safe_persona}_{safe_id}.jsonl"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def response_preview(text: str, *, max_chars: int = RESPONSE_PREVIEW_MAX) -> str:
|
|
55
|
+
if max_chars <= 0:
|
|
56
|
+
return ""
|
|
57
|
+
return text[:max_chars]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
EVENT_SUMMARY_MAX = 160
|
|
61
|
+
|
|
62
|
+
# Scalar input fields that best describe what a tool call is doing, in priority
|
|
63
|
+
# order — keeps tool_use summaries short and human-scannable (which command,
|
|
64
|
+
# which file) instead of dumping the whole input dict.
|
|
65
|
+
_TOOL_INPUT_PRIORITY: tuple[str, ...] = (
|
|
66
|
+
"command",
|
|
67
|
+
"description",
|
|
68
|
+
"file_path",
|
|
69
|
+
"path",
|
|
70
|
+
"pattern",
|
|
71
|
+
"url",
|
|
72
|
+
"query",
|
|
73
|
+
"body",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _scalar_preview(value: Any, *, max_chars: int) -> str:
|
|
78
|
+
if value is None:
|
|
79
|
+
return ""
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
return value[:max_chars]
|
|
82
|
+
return json.dumps(value, ensure_ascii=False)[:max_chars]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def text_summary(text: str | None, *, max_chars: int = EVENT_SUMMARY_MAX) -> str:
|
|
86
|
+
"""One-line preview of assistant text (newlines collapsed)."""
|
|
87
|
+
if not text:
|
|
88
|
+
return ""
|
|
89
|
+
return " ".join(text.split())[:max_chars]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def tool_use_summary(
|
|
93
|
+
name: str,
|
|
94
|
+
tool_input: Any,
|
|
95
|
+
*,
|
|
96
|
+
max_chars: int = EVENT_SUMMARY_MAX,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Short summary of a tool call, e.g. ``Bash: pytest tests/ -q``."""
|
|
99
|
+
if isinstance(tool_input, dict):
|
|
100
|
+
for key in _TOOL_INPUT_PRIORITY:
|
|
101
|
+
if tool_input.get(key) is not None:
|
|
102
|
+
return f"{name}: {_scalar_preview(tool_input[key], max_chars=max_chars)}"
|
|
103
|
+
for value in tool_input.values():
|
|
104
|
+
if isinstance(value, (str, int, float, bool)):
|
|
105
|
+
return f"{name}: {_scalar_preview(value, max_chars=max_chars)}"
|
|
106
|
+
return str(name)
|
|
107
|
+
return f"{name}: {_scalar_preview(tool_input, max_chars=max_chars)}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tool_result_summary(
|
|
111
|
+
content: Any,
|
|
112
|
+
is_error: bool | None,
|
|
113
|
+
*,
|
|
114
|
+
max_chars: int = EVENT_SUMMARY_MAX,
|
|
115
|
+
) -> str:
|
|
116
|
+
"""Short summary of a tool result, e.g. ``ok: 45 passed`` or ``error: ...``."""
|
|
117
|
+
label = "error" if is_error else "ok"
|
|
118
|
+
if content is None:
|
|
119
|
+
return label
|
|
120
|
+
if isinstance(content, str):
|
|
121
|
+
body = content
|
|
122
|
+
elif isinstance(content, list):
|
|
123
|
+
parts: list[str] = []
|
|
124
|
+
for item in content:
|
|
125
|
+
if isinstance(item, dict):
|
|
126
|
+
parts.append(str(item.get("text") or item.get("content") or ""))
|
|
127
|
+
else:
|
|
128
|
+
parts.append(str(item))
|
|
129
|
+
body = " ".join(p for p in parts if p)
|
|
130
|
+
else:
|
|
131
|
+
body = str(content)
|
|
132
|
+
body = " ".join(body.split())
|
|
133
|
+
if not body:
|
|
134
|
+
return label
|
|
135
|
+
return f"{label}: {body[:max_chars]}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def append_session_wake_log(
|
|
139
|
+
*,
|
|
140
|
+
log_dir: Path,
|
|
141
|
+
session_id: str,
|
|
142
|
+
persona: str,
|
|
143
|
+
integration: str,
|
|
144
|
+
prompt: str,
|
|
145
|
+
response_text: str,
|
|
146
|
+
status: str,
|
|
147
|
+
preview_chars: int = RESPONSE_PREVIEW_MAX,
|
|
148
|
+
event_id: str | None = None,
|
|
149
|
+
event_source: str = "polling",
|
|
150
|
+
fingerprint: str | None = None,
|
|
151
|
+
log_path: Path | None = None,
|
|
152
|
+
) -> Path:
|
|
153
|
+
"""Append one JSON line to the session's JSONL file under ``log_dir``.
|
|
154
|
+
|
|
155
|
+
``event_id`` and ``event_source`` are the join keys for the A3 audit
|
|
156
|
+
three-way join (notification ↔ inbound_event ↔ sessions jsonl). Default
|
|
157
|
+
``event_source="polling"`` matches Phase 1 reality; Phase 2 will start
|
|
158
|
+
emitting ``sse`` once the event-driven path is wired.
|
|
159
|
+
``fingerprint`` is optional — included when the caller has it on hand so
|
|
160
|
+
A3 join fallbacks can pivot on it without consulting inbound_event.
|
|
161
|
+
"""
|
|
162
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
# When the caller already resolved a log_path (e.g. wake_up writes live
|
|
164
|
+
# events into one file and wants the result summary in that same file),
|
|
165
|
+
# honor it instead of re-resolving by session_id.
|
|
166
|
+
path = log_path if log_path is not None else resolve_session_log_path(log_dir, session_id, persona)
|
|
167
|
+
entry: dict[str, Any] = {
|
|
168
|
+
"ts": log_now().isoformat(),
|
|
169
|
+
"session_id": session_id,
|
|
170
|
+
"persona": persona,
|
|
171
|
+
"integration": integration,
|
|
172
|
+
"status": status,
|
|
173
|
+
"event_source": event_source,
|
|
174
|
+
"event_id": event_id,
|
|
175
|
+
"fingerprint": fingerprint,
|
|
176
|
+
"prompt": prompt,
|
|
177
|
+
"response_preview": response_preview(response_text, max_chars=preview_chars),
|
|
178
|
+
"response_chars": len(response_text),
|
|
179
|
+
}
|
|
180
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
181
|
+
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
182
|
+
return path
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def append_session_event(
|
|
186
|
+
*,
|
|
187
|
+
log_path: Path,
|
|
188
|
+
persona: str,
|
|
189
|
+
integration: str,
|
|
190
|
+
event: str,
|
|
191
|
+
summary: str,
|
|
192
|
+
event_id: str | None = None,
|
|
193
|
+
event_source: str = "polling",
|
|
194
|
+
fingerprint: str | None = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Append one real-time event line to a session's JSONL file.
|
|
197
|
+
|
|
198
|
+
Called live during ``PersonaAgentClient.wake_up()`` for each assistant text
|
|
199
|
+
block, tool call, and tool result, so an agent stuck mid-turn is visible
|
|
200
|
+
from the timestamp of the last written event. ``event`` is one of
|
|
201
|
+
``wake``/``text``/``tool_use``/``tool_result``/``result``. The caller
|
|
202
|
+
resolves ``log_path`` once up front (via :func:`resolve_session_log_path`)
|
|
203
|
+
so every event of one wake lands in the same file.
|
|
204
|
+
"""
|
|
205
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
entry: dict[str, Any] = {
|
|
207
|
+
"ts": log_now().isoformat(),
|
|
208
|
+
"persona": persona,
|
|
209
|
+
"integration": integration,
|
|
210
|
+
"event": event,
|
|
211
|
+
"summary": summary,
|
|
212
|
+
"event_source": event_source,
|
|
213
|
+
"event_id": event_id,
|
|
214
|
+
"fingerprint": fingerprint,
|
|
215
|
+
}
|
|
216
|
+
with log_path.open("a", encoding="utf-8") as handle:
|
|
217
|
+
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
__all__ = [
|
|
221
|
+
"DEFAULT_LOG_TIMEZONE",
|
|
222
|
+
"DEFAULT_SESSION_LOG_DIR",
|
|
223
|
+
"EVENT_SUMMARY_MAX",
|
|
224
|
+
"RESPONSE_PREVIEW_MAX",
|
|
225
|
+
"append_session_event",
|
|
226
|
+
"append_session_wake_log",
|
|
227
|
+
"log_now",
|
|
228
|
+
"log_timezone",
|
|
229
|
+
"resolve_session_log_path",
|
|
230
|
+
"response_preview",
|
|
231
|
+
"safe_session_log_name",
|
|
232
|
+
"text_summary",
|
|
233
|
+
"tool_result_summary",
|
|
234
|
+
"tool_use_summary",
|
|
235
|
+
]
|