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,202 @@
|
|
|
1
|
+
"""Stdio transport: subprocess + newline-delimited JSON.
|
|
2
|
+
|
|
3
|
+
Two background daemon threads consume the subprocess pipes:
|
|
4
|
+
|
|
5
|
+
- `_reader`: parses stdout as NDJSON, routes responses + notifications.
|
|
6
|
+
- `_stderr_reader`: drains stderr into the project logger so server banners
|
|
7
|
+
do not block the pipe.
|
|
8
|
+
|
|
9
|
+
On EOF / subprocess death, every pending future is failed with
|
|
10
|
+
`MCPTransportError` so callers never hang forever.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import subprocess
|
|
17
|
+
import threading
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from typing import IO
|
|
20
|
+
|
|
21
|
+
from ..errors import MCPProtocolError, MCPTransportError
|
|
22
|
+
from ..protocol import Notification, Request, Response, decode_message
|
|
23
|
+
from .base import Transport
|
|
24
|
+
|
|
25
|
+
_log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_TERMINATE_TIMEOUT = 2.0
|
|
28
|
+
_KILL_TIMEOUT = 2.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StdioTransport(Transport):
|
|
32
|
+
"""Spawn an MCP server as a subprocess and talk to it over stdin/stdout."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
command: list[str],
|
|
37
|
+
*,
|
|
38
|
+
env: dict[str, str] | None = None,
|
|
39
|
+
cwd: str | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__()
|
|
42
|
+
if not command:
|
|
43
|
+
raise ValueError("command must be a non-empty list")
|
|
44
|
+
self._command = list(command)
|
|
45
|
+
self._env = env
|
|
46
|
+
self._cwd = cwd
|
|
47
|
+
self._proc: subprocess.Popen[bytes] | None = None
|
|
48
|
+
self._reader: threading.Thread | None = None
|
|
49
|
+
self._stderr_reader: threading.Thread | None = None
|
|
50
|
+
self._write_lock = threading.Lock()
|
|
51
|
+
self._closed = False
|
|
52
|
+
# Set true by ``close()`` so the reader thread can distinguish graceful
|
|
53
|
+
# shutdown (don't fire disconnect handler) from unexpected EOF.
|
|
54
|
+
self._closing = False
|
|
55
|
+
|
|
56
|
+
def start(self) -> None:
|
|
57
|
+
if self._proc is not None:
|
|
58
|
+
raise RuntimeError("StdioTransport already started")
|
|
59
|
+
try:
|
|
60
|
+
self._proc = subprocess.Popen(
|
|
61
|
+
self._command,
|
|
62
|
+
stdin=subprocess.PIPE,
|
|
63
|
+
stdout=subprocess.PIPE,
|
|
64
|
+
stderr=subprocess.PIPE,
|
|
65
|
+
env=self._env,
|
|
66
|
+
cwd=self._cwd,
|
|
67
|
+
bufsize=0,
|
|
68
|
+
)
|
|
69
|
+
except (OSError, FileNotFoundError) as exc:
|
|
70
|
+
raise MCPTransportError(f"failed to spawn MCP server: {exc}") from exc
|
|
71
|
+
|
|
72
|
+
self._reader = threading.Thread(
|
|
73
|
+
target=self._read_loop, name="mcp-stdio-reader", daemon=True
|
|
74
|
+
)
|
|
75
|
+
self._stderr_reader = threading.Thread(
|
|
76
|
+
target=self._stderr_loop, name="mcp-stdio-stderr", daemon=True
|
|
77
|
+
)
|
|
78
|
+
self._reader.start()
|
|
79
|
+
self._stderr_reader.start()
|
|
80
|
+
|
|
81
|
+
def send(self, message: str) -> None:
|
|
82
|
+
proc = self._proc
|
|
83
|
+
if proc is None or proc.stdin is None:
|
|
84
|
+
raise MCPTransportError("transport not started")
|
|
85
|
+
if proc.poll() is not None:
|
|
86
|
+
raise MCPTransportError(f"subprocess exited with code {proc.returncode}")
|
|
87
|
+
line = message + "\n"
|
|
88
|
+
try:
|
|
89
|
+
with self._write_lock:
|
|
90
|
+
proc.stdin.write(line.encode("utf-8"))
|
|
91
|
+
proc.stdin.flush()
|
|
92
|
+
except (BrokenPipeError, OSError) as exc:
|
|
93
|
+
raise MCPTransportError(
|
|
94
|
+
f"failed to write to subprocess stdin: {exc}"
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
if self._closed:
|
|
99
|
+
return
|
|
100
|
+
self._closed = True
|
|
101
|
+
self._closing = True
|
|
102
|
+
proc = self._proc
|
|
103
|
+
if proc is None:
|
|
104
|
+
return
|
|
105
|
+
# Graceful shutdown: close stdin → wait → terminate → wait → kill.
|
|
106
|
+
try:
|
|
107
|
+
if proc.stdin is not None and not proc.stdin.closed:
|
|
108
|
+
with self._write_lock:
|
|
109
|
+
try:
|
|
110
|
+
proc.stdin.close()
|
|
111
|
+
except OSError:
|
|
112
|
+
pass
|
|
113
|
+
try:
|
|
114
|
+
proc.wait(timeout=_TERMINATE_TIMEOUT)
|
|
115
|
+
except subprocess.TimeoutExpired:
|
|
116
|
+
proc.terminate()
|
|
117
|
+
try:
|
|
118
|
+
proc.wait(timeout=_KILL_TIMEOUT)
|
|
119
|
+
except subprocess.TimeoutExpired:
|
|
120
|
+
proc.kill()
|
|
121
|
+
try:
|
|
122
|
+
proc.wait(timeout=_KILL_TIMEOUT)
|
|
123
|
+
except subprocess.TimeoutExpired:
|
|
124
|
+
pass
|
|
125
|
+
finally:
|
|
126
|
+
self._fail_all_pending("stdio transport closed")
|
|
127
|
+
|
|
128
|
+
def is_alive(self) -> bool:
|
|
129
|
+
proc = self._proc
|
|
130
|
+
return proc is not None and proc.poll() is None
|
|
131
|
+
|
|
132
|
+
# --- background threads ---
|
|
133
|
+
|
|
134
|
+
def _read_loop(self) -> None:
|
|
135
|
+
proc = self._proc
|
|
136
|
+
assert proc is not None and proc.stdout is not None
|
|
137
|
+
disconnect_reason: str | None = None
|
|
138
|
+
try:
|
|
139
|
+
for raw in self._iter_lines(proc.stdout):
|
|
140
|
+
line = raw.decode("utf-8", errors="replace").strip()
|
|
141
|
+
if not line:
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
msg = decode_message(line)
|
|
145
|
+
except MCPProtocolError as exc:
|
|
146
|
+
_log.warning(
|
|
147
|
+
"MCP stdio: ignoring non-protocol line (%s): %r",
|
|
148
|
+
exc,
|
|
149
|
+
line[:200],
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
self._dispatch(msg)
|
|
153
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
154
|
+
disconnect_reason = f"stdio reader crashed: {exc}"
|
|
155
|
+
_log.warning("MCP stdio reader crashed: %s", exc)
|
|
156
|
+
finally:
|
|
157
|
+
# Distinguish graceful (user called close) from unexpected EOF /
|
|
158
|
+
# subprocess crash. Only fire disconnect handler in the unexpected
|
|
159
|
+
# case so a normal shutdown does not spuriously notify the manager.
|
|
160
|
+
if not self._closing:
|
|
161
|
+
if disconnect_reason is None:
|
|
162
|
+
returncode = proc.poll()
|
|
163
|
+
if returncode is not None:
|
|
164
|
+
disconnect_reason = (
|
|
165
|
+
f"stdout EOF (subprocess exited with code {returncode})"
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
disconnect_reason = "stdout EOF (subprocess exited)"
|
|
169
|
+
self._invoke_disconnect(disconnect_reason)
|
|
170
|
+
self._fail_all_pending("stdio reader exited (subprocess EOF or error)")
|
|
171
|
+
|
|
172
|
+
def _stderr_loop(self) -> None:
|
|
173
|
+
proc = self._proc
|
|
174
|
+
assert proc is not None and proc.stderr is not None
|
|
175
|
+
try:
|
|
176
|
+
for raw in self._iter_lines(proc.stderr):
|
|
177
|
+
text = raw.decode("utf-8", errors="replace").rstrip()
|
|
178
|
+
if text:
|
|
179
|
+
_log.warning("MCP server stderr: %s", text)
|
|
180
|
+
except Exception: # pragma: no cover
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _iter_lines(stream: IO[bytes]) -> Iterator[bytes]:
|
|
185
|
+
# subprocess pipes opened in bytes mode iterate by line (\n-delimited).
|
|
186
|
+
while True:
|
|
187
|
+
line = stream.readline()
|
|
188
|
+
if not line:
|
|
189
|
+
return
|
|
190
|
+
yield line
|
|
191
|
+
|
|
192
|
+
def _dispatch(self, msg: Request | Response | Notification) -> None:
|
|
193
|
+
if isinstance(msg, Response):
|
|
194
|
+
self._route_response(msg)
|
|
195
|
+
elif isinstance(msg, Notification):
|
|
196
|
+
self._route_notification(msg)
|
|
197
|
+
else:
|
|
198
|
+
# Server-initiated request. PR1 does not support these; in PR2 the
|
|
199
|
+
# client will handle method dispatch (ping, sampling, elicitation).
|
|
200
|
+
_log.warning(
|
|
201
|
+
"MCP stdio: ignoring server-to-client request (method=%r)", msg.method
|
|
202
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Memory modules for BareAgent."""
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from bareagent.core.fileutil import collect_tool_names, is_tool_result_message, stringify
|
|
8
|
+
from bareagent.memory.token_counter import estimate_tokens
|
|
9
|
+
from bareagent.memory.transcript import TranscriptManager
|
|
10
|
+
from bareagent.provider.base import BaseLLMProvider
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_TRUNCATED_PREFIX = "[truncated:"
|
|
15
|
+
_SUMMARY_SYSTEM_PROMPT = (
|
|
16
|
+
"你是 BareAgent 的上下文压缩助手。请用中文总结对话中的目标、已完成工作、"
|
|
17
|
+
"关键约束、重要文件路径、工具执行结果要点,以及接下来继续工作所需的上下文。"
|
|
18
|
+
"输出简洁但不能遗漏事实。"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _micro_compact(
|
|
23
|
+
messages: list[dict[str, Any]],
|
|
24
|
+
keep_recent: int = 3,
|
|
25
|
+
tool_name_by_id: dict[str, str] | None = None,
|
|
26
|
+
) -> dict[str, str]:
|
|
27
|
+
if tool_name_by_id is None:
|
|
28
|
+
tool_name_by_id = collect_tool_names(messages)
|
|
29
|
+
tool_result_indices = [
|
|
30
|
+
index
|
|
31
|
+
for index, message in enumerate(messages)
|
|
32
|
+
if is_tool_result_message(message)
|
|
33
|
+
]
|
|
34
|
+
if keep_recent > 0:
|
|
35
|
+
compact_indices = set(tool_result_indices[:-keep_recent])
|
|
36
|
+
else:
|
|
37
|
+
compact_indices = set(tool_result_indices)
|
|
38
|
+
|
|
39
|
+
for index in compact_indices:
|
|
40
|
+
message = messages[index]
|
|
41
|
+
content = message.get("content")
|
|
42
|
+
if not isinstance(content, list):
|
|
43
|
+
continue
|
|
44
|
+
for block in content:
|
|
45
|
+
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
46
|
+
continue
|
|
47
|
+
original_text = stringify(block.get("content", ""))
|
|
48
|
+
if original_text.startswith(_TRUNCATED_PREFIX):
|
|
49
|
+
continue
|
|
50
|
+
tool_use_id = str(block.get("tool_use_id", ""))
|
|
51
|
+
tool_name = tool_name_by_id.get(tool_use_id, "unknown")
|
|
52
|
+
block["content"] = (
|
|
53
|
+
f"[truncated: {tool_name} result, {len(original_text)} chars]"
|
|
54
|
+
)
|
|
55
|
+
return tool_name_by_id
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _serialize(
|
|
59
|
+
messages: list[dict[str, Any]],
|
|
60
|
+
tool_name_by_id: dict[str, str] | None = None,
|
|
61
|
+
) -> str:
|
|
62
|
+
lines: list[str] = []
|
|
63
|
+
if tool_name_by_id is None:
|
|
64
|
+
tool_name_by_id = collect_tool_names(messages)
|
|
65
|
+
for message in messages:
|
|
66
|
+
role = str(message.get("role", "unknown"))
|
|
67
|
+
lines.append(f"[{role}]")
|
|
68
|
+
lines.append(_serialize_content(message.get("content"), tool_name_by_id))
|
|
69
|
+
lines.append("")
|
|
70
|
+
return "\n".join(lines).strip()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Compactor:
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
provider: BaseLLMProvider,
|
|
77
|
+
transcript_mgr: TranscriptManager | None,
|
|
78
|
+
threshold: int = 50000,
|
|
79
|
+
session_id: str = "default",
|
|
80
|
+
) -> None:
|
|
81
|
+
self._provider = provider
|
|
82
|
+
self._transcript_mgr = transcript_mgr
|
|
83
|
+
self._threshold = threshold
|
|
84
|
+
self._session_id = session_id
|
|
85
|
+
|
|
86
|
+
def get_session_id(self) -> str:
|
|
87
|
+
return self._session_id
|
|
88
|
+
|
|
89
|
+
def set_session_id(self, new_session_id: str) -> None:
|
|
90
|
+
self._session_id = new_session_id
|
|
91
|
+
|
|
92
|
+
def __call__(self, messages: list[dict[str, Any]], force: bool = False) -> None:
|
|
93
|
+
if not force and estimate_tokens(messages) <= self._threshold:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
_backup = [_clone_message(m) for m in messages]
|
|
97
|
+
|
|
98
|
+
tool_name_by_id = _micro_compact(messages, keep_recent=3)
|
|
99
|
+
|
|
100
|
+
history_messages, pending_user_message = _split_pending_user_turn(messages)
|
|
101
|
+
summary_source_messages = [
|
|
102
|
+
message for message in history_messages if message.get("role") != "system"
|
|
103
|
+
]
|
|
104
|
+
if not summary_source_messages:
|
|
105
|
+
messages[:] = _backup
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if self._transcript_mgr is not None:
|
|
109
|
+
self._transcript_mgr.save(messages, self._session_id)
|
|
110
|
+
try:
|
|
111
|
+
summary = self._provider.create(
|
|
112
|
+
messages=[
|
|
113
|
+
{"role": "system", "content": _SUMMARY_SYSTEM_PROMPT},
|
|
114
|
+
{
|
|
115
|
+
"role": "user",
|
|
116
|
+
"content": (
|
|
117
|
+
"请简洁总结以下对话的关键信息和已完成的工作,供后续继续工作使用:\n\n"
|
|
118
|
+
+ _serialize(summary_source_messages, tool_name_by_id)
|
|
119
|
+
),
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
tools=[],
|
|
123
|
+
max_tokens=2000,
|
|
124
|
+
)
|
|
125
|
+
except Exception:
|
|
126
|
+
logger.warning("Context compression failed", exc_info=True)
|
|
127
|
+
messages[:] = _backup
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
system_messages = [
|
|
131
|
+
_clone_message(message)
|
|
132
|
+
for message in messages
|
|
133
|
+
if message.get("role") == "system"
|
|
134
|
+
]
|
|
135
|
+
messages.clear()
|
|
136
|
+
messages.extend(system_messages)
|
|
137
|
+
messages.extend(
|
|
138
|
+
[
|
|
139
|
+
{"role": "user", "content": f"[Context Compressed]\n{summary.text}"},
|
|
140
|
+
{
|
|
141
|
+
"role": "assistant",
|
|
142
|
+
"content": "收到,我已理解之前的上下文,继续工作。",
|
|
143
|
+
},
|
|
144
|
+
]
|
|
145
|
+
)
|
|
146
|
+
if pending_user_message is not None:
|
|
147
|
+
messages.append(pending_user_message)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
make_compact_fn = Compactor
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _serialize_content(content: Any, tool_name_by_id: dict[str, str]) -> str:
|
|
154
|
+
if content is None:
|
|
155
|
+
return ""
|
|
156
|
+
if isinstance(content, str):
|
|
157
|
+
return content
|
|
158
|
+
if isinstance(content, list):
|
|
159
|
+
parts: list[str] = []
|
|
160
|
+
for block in content:
|
|
161
|
+
serialized = _serialize_block(block, tool_name_by_id)
|
|
162
|
+
if serialized:
|
|
163
|
+
parts.append(serialized)
|
|
164
|
+
return "\n".join(parts)
|
|
165
|
+
return stringify(content)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _serialize_block(block: Any, tool_name_by_id: dict[str, str]) -> str:
|
|
169
|
+
if isinstance(block, str):
|
|
170
|
+
return block
|
|
171
|
+
if not isinstance(block, dict):
|
|
172
|
+
return stringify(block)
|
|
173
|
+
|
|
174
|
+
block_type = block.get("type")
|
|
175
|
+
if block_type == "text":
|
|
176
|
+
return str(block.get("text", ""))
|
|
177
|
+
if block_type == "tool_use":
|
|
178
|
+
return (
|
|
179
|
+
f"[tool_use:{block.get('name', 'unknown')}] "
|
|
180
|
+
f"{stringify(block.get('input', {}))}"
|
|
181
|
+
)
|
|
182
|
+
if block_type == "tool_result":
|
|
183
|
+
tool_use_id = str(block.get("tool_use_id", ""))
|
|
184
|
+
tool_name = tool_name_by_id.get(tool_use_id, "unknown")
|
|
185
|
+
return f"[tool_result:{tool_name}] {stringify(block.get('content', ''))}"
|
|
186
|
+
|
|
187
|
+
if "content" in block:
|
|
188
|
+
return _serialize_content(block.get("content"), tool_name_by_id)
|
|
189
|
+
if "text" in block:
|
|
190
|
+
return str(block.get("text", ""))
|
|
191
|
+
return stringify(block)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _clone_message(message: dict[str, Any]) -> dict[str, Any]:
|
|
195
|
+
return copy.deepcopy(message)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _split_pending_user_turn(
|
|
199
|
+
messages: list[dict[str, Any]],
|
|
200
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
|
|
201
|
+
if messages and messages[-1].get("role") == "user":
|
|
202
|
+
return messages[:-1], _clone_message(messages[-1])
|
|
203
|
+
return messages, None
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Conversation import/export serialization (REPL-independent, unit-testable).
|
|
2
|
+
|
|
3
|
+
Pure helpers backing the ``/export`` and ``/import`` REPL commands:
|
|
4
|
+
|
|
5
|
+
- :func:`render_markdown` — human-readable Markdown (mirrors the traversal
|
|
6
|
+
structure of ``main._replay_stdio_transcript``).
|
|
7
|
+
- :func:`to_export_json` — self-contained, faithful JSON wrapper.
|
|
8
|
+
- :func:`parse_import` — auto-detecting loader with shape validation.
|
|
9
|
+
|
|
10
|
+
No dependency on ``bareagent.main`` / UI / loop, so this module can be exercised in
|
|
11
|
+
isolation.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
EXPORT_VERSION = 1
|
|
20
|
+
_DEFAULT_MAX_TOOL_CHARS = 2000
|
|
21
|
+
_TOOL_INPUT_PREVIEW_CHARS = 200
|
|
22
|
+
_TRUNCATION_MARKER = "… (truncated)"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _truncate(text: str, limit: int) -> str:
|
|
26
|
+
if limit < 0 or len(text) <= limit:
|
|
27
|
+
return text
|
|
28
|
+
return text[:limit] + _TRUNCATION_MARKER
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _stringify_content(content: Any) -> str:
|
|
32
|
+
"""Coerce arbitrary tool_result content into display text."""
|
|
33
|
+
if isinstance(content, str):
|
|
34
|
+
return content
|
|
35
|
+
if content is None:
|
|
36
|
+
return ""
|
|
37
|
+
return json.dumps(content, ensure_ascii=False, default=str)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _tool_use_summary(block: dict[str, Any]) -> str:
|
|
41
|
+
name = str(block.get("name", "unknown"))
|
|
42
|
+
raw_input = block.get("input", {})
|
|
43
|
+
preview = json.dumps(raw_input, ensure_ascii=False, default=str)
|
|
44
|
+
preview = _truncate(preview, _TOOL_INPUT_PREVIEW_CHARS)
|
|
45
|
+
return f"- **Tool call** `{name}`: `{preview}`"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def render_markdown(
|
|
49
|
+
messages: list[dict[str, Any]],
|
|
50
|
+
*,
|
|
51
|
+
include_thinking: bool = False,
|
|
52
|
+
max_tool_chars: int = _DEFAULT_MAX_TOOL_CHARS,
|
|
53
|
+
title: str | None = None,
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Render *messages* as human-readable Markdown.
|
|
56
|
+
|
|
57
|
+
Skips ``system`` messages, renders user/assistant text, turns ``tool_use``
|
|
58
|
+
blocks into single-line summaries and ``tool_result`` blocks into truncated
|
|
59
|
+
code fences. ``thinking`` blocks are omitted unless *include_thinking* is
|
|
60
|
+
set. Mirrors the traversal of ``main._replay_stdio_transcript`` (including
|
|
61
|
+
the ``tool_use_id`` → tool name association). *title*, when provided, is
|
|
62
|
+
emitted as a top-level heading.
|
|
63
|
+
"""
|
|
64
|
+
tool_name_by_id: dict[str, str] = {}
|
|
65
|
+
lines: list[str] = []
|
|
66
|
+
|
|
67
|
+
if title:
|
|
68
|
+
lines.append(f"# {title}")
|
|
69
|
+
lines.append("")
|
|
70
|
+
|
|
71
|
+
for message in messages:
|
|
72
|
+
role = message.get("role")
|
|
73
|
+
content = message.get("content")
|
|
74
|
+
|
|
75
|
+
if role == "system":
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if role == "user":
|
|
79
|
+
if isinstance(content, str):
|
|
80
|
+
lines.append("## User")
|
|
81
|
+
lines.append("")
|
|
82
|
+
lines.append(content)
|
|
83
|
+
lines.append("")
|
|
84
|
+
continue
|
|
85
|
+
if isinstance(content, list):
|
|
86
|
+
for block in content:
|
|
87
|
+
if not isinstance(block, dict):
|
|
88
|
+
continue
|
|
89
|
+
if block.get("type") == "text":
|
|
90
|
+
text_value = str(block.get("text", ""))
|
|
91
|
+
if text_value:
|
|
92
|
+
lines.append("## User")
|
|
93
|
+
lines.append("")
|
|
94
|
+
lines.append(text_value)
|
|
95
|
+
lines.append("")
|
|
96
|
+
continue
|
|
97
|
+
if block.get("type") != "tool_result":
|
|
98
|
+
continue
|
|
99
|
+
tool_name = tool_name_by_id.get(
|
|
100
|
+
str(block.get("tool_use_id", "")),
|
|
101
|
+
"unknown",
|
|
102
|
+
)
|
|
103
|
+
result_text = _truncate(
|
|
104
|
+
_stringify_content(block.get("content", "")),
|
|
105
|
+
max_tool_chars,
|
|
106
|
+
)
|
|
107
|
+
error_marker = " (error)" if block.get("is_error") else ""
|
|
108
|
+
lines.append(f"### Tool result: {tool_name}{error_marker}")
|
|
109
|
+
lines.append("")
|
|
110
|
+
lines.append("```")
|
|
111
|
+
lines.append(result_text)
|
|
112
|
+
lines.append("```")
|
|
113
|
+
lines.append("")
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if role != "assistant":
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if isinstance(content, str):
|
|
120
|
+
lines.append("## Assistant")
|
|
121
|
+
lines.append("")
|
|
122
|
+
lines.append(content)
|
|
123
|
+
lines.append("")
|
|
124
|
+
continue
|
|
125
|
+
if not isinstance(content, list):
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
for block in content:
|
|
129
|
+
if not isinstance(block, dict):
|
|
130
|
+
continue
|
|
131
|
+
block_type = block.get("type")
|
|
132
|
+
if block_type == "text":
|
|
133
|
+
text_value = str(block.get("text", ""))
|
|
134
|
+
if text_value:
|
|
135
|
+
lines.append("## Assistant")
|
|
136
|
+
lines.append("")
|
|
137
|
+
lines.append(text_value)
|
|
138
|
+
lines.append("")
|
|
139
|
+
continue
|
|
140
|
+
if block_type == "thinking":
|
|
141
|
+
if not include_thinking:
|
|
142
|
+
continue
|
|
143
|
+
thinking_value = str(block.get("thinking", ""))
|
|
144
|
+
if thinking_value:
|
|
145
|
+
lines.append("### Thinking")
|
|
146
|
+
lines.append("")
|
|
147
|
+
lines.append("```")
|
|
148
|
+
lines.append(thinking_value)
|
|
149
|
+
lines.append("```")
|
|
150
|
+
lines.append("")
|
|
151
|
+
continue
|
|
152
|
+
if block_type != "tool_use":
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
tool_id = str(block.get("id", ""))
|
|
156
|
+
if tool_id:
|
|
157
|
+
tool_name_by_id[tool_id] = str(block.get("name", "unknown"))
|
|
158
|
+
lines.append(_tool_use_summary(block))
|
|
159
|
+
lines.append("")
|
|
160
|
+
|
|
161
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def to_export_json(
|
|
165
|
+
messages: list[dict[str, Any]],
|
|
166
|
+
*,
|
|
167
|
+
session_id: str,
|
|
168
|
+
exported_at: str,
|
|
169
|
+
) -> str:
|
|
170
|
+
"""Serialize *messages* into a self-contained, faithful JSON wrapper.
|
|
171
|
+
|
|
172
|
+
The wrapper preserves every message verbatim (including ``system`` /
|
|
173
|
+
``thinking`` / tool blocks) so a round-trip through :func:`parse_import`
|
|
174
|
+
recovers an equivalent conversation.
|
|
175
|
+
"""
|
|
176
|
+
payload = {
|
|
177
|
+
"version": EXPORT_VERSION,
|
|
178
|
+
"session_id": session_id,
|
|
179
|
+
"exported_at": exported_at,
|
|
180
|
+
"messages": messages,
|
|
181
|
+
}
|
|
182
|
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _validate_messages(value: Any) -> list[dict[str, Any]]:
|
|
186
|
+
if not isinstance(value, list):
|
|
187
|
+
raise ValueError("conversation must be a list of messages")
|
|
188
|
+
for index, item in enumerate(value):
|
|
189
|
+
if not isinstance(item, dict):
|
|
190
|
+
raise ValueError(f"message at index {index} is not an object")
|
|
191
|
+
if "role" not in item:
|
|
192
|
+
raise ValueError(f"message at index {index} is missing a 'role' key")
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def parse_import(text: str) -> list[dict[str, Any]]:
|
|
197
|
+
"""Parse imported conversation *text* into validated messages.
|
|
198
|
+
|
|
199
|
+
Auto-detects the format: first try parsing the whole document as JSON — a
|
|
200
|
+
dict with a ``messages`` key yields those messages, a bare list is used
|
|
201
|
+
directly. If whole-document JSON parsing fails, fall back to JSONL
|
|
202
|
+
(one JSON object per non-blank line).
|
|
203
|
+
|
|
204
|
+
Validation: the result must be a list where every element is a dict
|
|
205
|
+
containing a ``role`` key; otherwise :class:`ValueError` is raised with a
|
|
206
|
+
human-readable reason. No other rewriting is performed (faithful load).
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
document = json.loads(text)
|
|
210
|
+
except json.JSONDecodeError:
|
|
211
|
+
messages: list[Any] = []
|
|
212
|
+
for line_no, line in enumerate(text.splitlines(), 1):
|
|
213
|
+
stripped = line.strip()
|
|
214
|
+
if not stripped:
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
messages.append(json.loads(stripped))
|
|
218
|
+
except json.JSONDecodeError as exc:
|
|
219
|
+
raise ValueError(f"invalid JSON on line {line_no}: {exc}") from exc
|
|
220
|
+
return _validate_messages(messages)
|
|
221
|
+
|
|
222
|
+
if isinstance(document, dict):
|
|
223
|
+
if "messages" not in document:
|
|
224
|
+
raise ValueError("JSON object is missing a 'messages' key")
|
|
225
|
+
return _validate_messages(document["messages"])
|
|
226
|
+
return _validate_messages(document)
|